Compare commits

..

981 Commits

Author SHA1 Message Date
rubenwardy
27e2b64e41 WIP prototype oauth scopes 2023-11-01 01:07:23 +00:00
rubenwardy
d4b1344f6a Fix review voting JS not showing removals
Fixes #474
2023-10-31 23:28:20 +00:00
rubenwardy
3279e00aa4 OAuth2: Improve authorize page formatting 2023-10-31 21:18:28 +00:00
Buckaroo Banzai
c09f190712 Add missing \ in oauth curl example (#486)
Co-authored-by: BuckarooBanzay <BuckarooBanzay@users.noreply.github.com>
2023-10-31 20:57:23 +00:00
rubenwardy
047bf936b4 OAuth2: Allow normal users to create clients (but unapproved) 2023-10-31 20:29:49 +00:00
rubenwardy
fa389273ab OAuth2: Fix typo 2023-10-31 20:14:21 +00:00
rubenwardy
00f7dbb28d OAuth2: Add approval and verified apps 2023-10-31 20:11:55 +00:00
rubenwardy
073dcf9517 OAuth2: Improve authorize page wording 2023-10-31 19:50:29 +00:00
rubenwardy
8b03ca6c63 OAuth2: Add ability to revoke all tokens 2023-10-31 19:38:32 +00:00
rubenwardy
e0553d0a50 OAuth2: Add example authorize URL to edit form 2023-10-31 19:26:23 +00:00
rubenwardy
76f9f58175 OAuth2: Add audit logs 2023-10-31 19:18:08 +00:00
rubenwardy
540603ed7a OAuth2 docs: check access token + scopes 2023-10-31 18:58:37 +00:00
rubenwardy
bc38094a41 Remove dummy data from API 2023-10-31 18:54:58 +00:00
rubenwardy
c4fac34e6a Fix skip button not being styled 2023-10-31 18:50:33 +00:00
rubenwardy
5ab6b84fe7 Add delete-token API 2023-10-31 18:50:33 +00:00
rubenwardy
604fb010d2 Fix codehilite guessing languages 2023-10-31 18:50:33 +00:00
rubenwardy
72b608b158 Respect next when logging in using GitHub 2023-10-31 18:50:33 +00:00
rubenwardy
a29715775e Add OAuth2 Applications API
Fixes #344
2023-10-31 18:45:45 +00:00
rubenwardy
1627fa50f2 Enable Spanish translation 2023-10-23 22:36:48 +01:00
rubenwardy
3855ca1361 Fix error in Brazil translation 2023-10-23 22:20:24 +01:00
rubenwardy
9e39f5e155 Update translations 2023-10-23 22:17:05 +01:00
Bas Huis
d37ff4a55c Translated using Weblate (Dutch)
Currently translated at 60.2% (534 of 886 strings)

Co-authored-by: Bas Huis <bassimhuis@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nl/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
Ярослав Рукавицын
1918e93421 Translated using Weblate (Russian)
Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: Ярослав Рукавицын <skybuilderoffical@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
José Muñoz
1705130d64 Translated using Weblate (Swedish)
Currently translated at 95.4% (846 of 886 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
gallegonovato
38ea454585 Translated using Weblate (Spanish)
Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
Lemente
f74ab6ed77 Translated using Weblate (French)
Currently translated at 97.1% (861 of 886 strings)

Co-authored-by: Lemente <crafted.by.lemente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
José Muñoz
70b2d4fbcd Translated using Weblate (Spanish)
Currently translated at 83.4% (739 of 886 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
Wuzzy
97aba174a6 Translated using Weblate (German)
Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
Matyáš Pilz
c7ef3e6810 Translated using Weblate (Czech)
Currently translated at 62.0% (550 of 886 strings)

Translated using Weblate (Czech)

Currently translated at 61.3% (544 of 886 strings)

Co-authored-by: Matyáš Pilz <matys.pilz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/cs/
Translation: Minetest/ContentDB
2023-10-23 23:07:29 +02:00
Farooq Karimi Zadeh
179326973e Translated using Weblate (Persian)
Currently translated at 12.3% (109 of 886 strings)

Co-authored-by: Farooq Karimi Zadeh <fkz@riseup.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fa/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
Tirifto
2fbec35746 Translated using Weblate (Esperanto)
Currently translated at 18.7% (166 of 886 strings)

Co-authored-by: Tirifto <tirifto@posteo.cz>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/eo/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
José Douglas
0bfb7c0509 Translated using Weblate (Portuguese (Brazil))
Currently translated at 57.7% (512 of 886 strings)

Added translation using Weblate (Portuguese)

Co-authored-by: José Douglas <josedouglas20002014@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pt_BR/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
nyommer
a78e4e171e Translated using Weblate (Hungarian)
Currently translated at 39.0% (346 of 886 strings)

Translated using Weblate (Hungarian)

Currently translated at 38.9% (345 of 886 strings)

Co-authored-by: nyommer <jishnu.ifeoluwa@fullangle.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
Jakub Z
8998fe9241 Translated using Weblate (Polish)
Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: Jakub Z <mrkubax10@onet.pl>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
Giov4
898cd547b6 Translated using Weblate (Italian)
Currently translated at 94.4% (837 of 886 strings)

Translated using Weblate (Italian)

Currently translated at 91.1% (808 of 886 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
Muhammad Rifqi Priyo Susanto
342ea117c8 Translated using Weblate (Indonesian)
Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
Linerly
4aeb694131 Translated using Weblate (Indonesian)
Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
1bdc3bbb42 Translated using Weblate (Malay)
Currently translated at 100.0% (886 of 886 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (886 of 886 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-10-23 23:07:28 +02:00
rubenwardy
e402b52221 Improve repo wizard messaging on create package 2023-10-23 21:50:57 +01:00
rubenwardy
a050be734c Update some dependencies 2023-10-20 22:49:42 +01:00
rubenwardy
a9533732f3 Rename "unapprove" to "unpublish" 2023-10-15 22:16:19 +01:00
rec
40a59a4d31 Adding in a carousel for packages (#475)
Co-authored-by: recluse4615 <recluse4615@users.noreply.github.com>
2023-10-15 22:14:08 +01:00
rubenwardy
fc559814d4 Prevent renaming published packages 2023-10-15 22:13:35 +01:00
rubenwardy
4fc54f12bc Remove optional banner from set password page 2023-10-11 22:33:37 +01:00
rubenwardy
0b7febae5d Stats: Point to spike blog post 2023-10-08 14:56:12 +01:00
rubenwardy
9db2fdd49a Add missing unique constraint for author and name
Swear this was already present
2023-10-02 00:32:20 +01:00
rubenwardy
16f765d0af Fix crash due to missing app context in setup.py 2023-10-02 00:24:05 +01:00
rubenwardy
7c72912913 Transfer: add option to remove current owner from maintainers 2023-10-02 00:23:30 +01:00
rubenwardy
4f4e5f8e53 Prevent transferring if destination package exists 2023-10-02 00:19:55 +01:00
rubenwardy
0ecf992f83 Post package removals to Discord webhook 2023-10-01 23:41:30 +01:00
rubenwardy
43af3a8e75 Improve user comments page performance 2023-09-29 23:10:08 +01:00
rubenwardy
315337d552 Allow indexing collections list page 2023-09-15 22:06:18 +01:00
rubenwardy
bcebb72a66 Include aliases in /api/updates/ 2023-09-14 20:41:48 +01:00
rubenwardy
83e7701eee Add a super fast latest release API 2023-09-12 21:15:55 +01:00
rubenwardy
886dec3ffd Fix crash on unauthenticated user 2023-09-02 22:45:50 +01:00
rubenwardy
383f9a43ef Move font awesome above scripts to avoid visual jump 2023-09-02 22:37:52 +01:00
rubenwardy
8dfd5c407d Readd "Sync with Forums" button to profile picture settings 2023-09-02 22:34:29 +01:00
rubenwardy
459eb02112 Make user comments page paginated
Fixes #392
2023-09-02 22:27:38 +01:00
rubenwardy
30722020c8 Flatpages: Move table of contents to the top on mobile
Fixes #459
2023-09-02 22:05:11 +01:00
rubenwardy
d4ecaee5f2 Fix crash when using limit in /api/packages/
Fixes #468
2023-09-02 21:52:14 +01:00
rubenwardy
b6995b1857 Fix UI tests due to increased password requirements 2023-09-02 21:49:13 +01:00
rubenwardy
af3c4fe987 Fix crash when saving empty collection
Fixes #464
2023-09-02 21:46:25 +01:00
rubenwardy
e94ff23bb9 Fix crash when bulk updating package configs
Fixes #467
2023-09-02 21:38:52 +01:00
rubenwardy
566d557840 Prevent editing game support until a release is created 2023-09-02 21:37:20 +01:00
rubenwardy
aa87bee014 Pin Python to 3.10.11
Python 3.10.12 and above causes threading issues (ex: "getaddrinfo() thread failed to start")
2023-09-02 21:33:16 +01:00
rubenwardy
379337ad60 Fix crash when collection.author is None 2023-08-31 09:42:37 +01:00
rubenwardy
18e8a11d00 Add improved "How to install" help page 2023-08-28 09:28:38 +01:00
rubenwardy
9ec2b05e8d Create favorites collection when viewing /add-to/ 2023-08-28 08:54:59 +01:00
rubenwardy
bef3c2f8f0 Update translations 2023-08-27 13:14:53 +01:00
Giov4
69b584d8b3 Translated using Weblate (Italian)
Currently translated at 91.3% (806 of 882 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-08-27 14:13:55 +02:00
Muhammad Rifqi Priyo Susanto
d5d3e70a48 Translated using Weblate (Indonesian)
Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-08-27 14:13:55 +02:00
Emmily
90b6b970ec Translated using Weblate (Esperanto)
Currently translated at 18.3% (162 of 882 strings)

Translated using Weblate (Spanish)

Currently translated at 84.0% (741 of 882 strings)

Co-authored-by: Emmily <Emmilyrose779@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/eo/
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-08-27 14:13:55 +02:00
Linerly
dbcbc6bedb Translated using Weblate (Indonesian)
Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-08-27 14:13:55 +02:00
rubenwardy
4c0e181336 Use same hue for link color as primary color 2023-08-26 19:55:14 +01:00
rubenwardy
3f2c7094d9 Add missing .btn to report button 2023-08-26 19:50:53 +01:00
rubenwardy
df825a0a84 Make links blue and not underlined 2023-08-26 19:49:23 +01:00
rubenwardy
4d470ce230 Increase min password length to 12 2023-08-26 14:54:58 +01:00
rubenwardy
da17fb63f3 Add review, comment, and collection counts to prometheus metrics 2023-08-26 13:52:29 +01:00
rubenwardy
416674e7ee Allow submitting reviews with ctrl+enter 2023-08-26 13:41:32 +01:00
rubenwardy
8f52c67f0f Allow submitting comment with ctrl+enter
Fixes #427
2023-08-26 13:39:03 +01:00
rubenwardy
798679ca44 Use async in polltask.js 2023-08-26 13:21:20 +01:00
rubenwardy
c8a30a27dc Move JS files to /static/js/ 2023-08-26 13:08:11 +01:00
rubenwardy
2f458ba40e Remove direct use of jQuery
jQuery is still required by jQuery UI
2023-08-26 13:03:29 +01:00
rubenwardy
9eb03c6a57 Set homepage row size to 4 2023-08-25 21:31:19 +01:00
rubenwardy
98c1cbc769 Remove lazy loading from carousel 2023-08-25 21:28:39 +01:00
rubenwardy
05a597adeb Fix package tile images not fully covering tile 2023-08-25 20:55:50 +01:00
rubenwardy
0649e5cf13 Lazy load images 2023-08-25 20:50:48 +01:00
rubenwardy
210a0a10ae Update jQuery dependencies 2023-08-25 20:49:17 +01:00
rubenwardy
e99dbda126 Fix underline in UI-autocomplete 2023-08-22 20:12:56 +01:00
rubenwardy
9df80d212e Upgrade to Bootstrap v5 (#457) 2023-08-22 19:58:43 +01:00
rubenwardy
70362ff7a6 Update two dependencies 2023-08-22 00:52:18 +01:00
rubenwardy
5f1d0ed946 Disable quick_review_voting.js for guest users 2023-08-22 00:48:08 +01:00
rubenwardy
4df15d6ff2 Fix "featured" being used instead of "spotlight" on homepage 2023-08-22 00:16:32 +01:00
rubenwardy
954826f053 Fix new items missing drag handles in collection editor 2023-08-21 00:34:55 +01:00
rubenwardy
dca6e82594 Add JS to vote on reviews without form submit
Fixes #329
2023-08-21 00:13:22 +01:00
rubenwardy
2a9f2924da Improve documentation on package create page
Fixes #447
2023-08-20 23:41:23 +01:00
rubenwardy
4433918d4c Add required text next to required fields 2023-08-20 23:41:06 +01:00
rubenwardy
bb719ad844 Show JSON decode errors to users
Fixes #431
2023-08-20 23:17:54 +01:00
rubenwardy
1b5174621d Add warning about favorites being public 2023-08-20 23:04:09 +01:00
Dominik Gęgotek
ef18f255be Translated using Weblate (Polish)
Currently translated at 100.0% (882 of 882 strings)

Co-authored-by: Dominik Gęgotek <ioutora@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2023-08-20 23:57:29 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
aac583e33b Translated using Weblate (Malay)
Currently translated at 100.0% (882 of 882 strings)

Translated using Weblate (Malay)

Currently translated at 96.3% (850 of 882 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-08-20 23:57:29 +02:00
rubenwardy
e5e68826fb Allow ordering packages in collections 2023-08-20 22:51:21 +01:00
rubenwardy
4bd53e4b1a Use collections for spotlight and featured, remove protected tags 2023-08-20 22:25:54 +01:00
rubenwardy
a2ea6573bd Add Collections API 2023-08-20 21:47:20 +01:00
rubenwardy
c0655eb9e2 Fix package grid tiles being too large
Fixes #456
2023-08-19 03:03:15 +01:00
rubenwardy
b410ab3bcc Improve behaviour of added packages in search box 2023-08-19 02:56:05 +01:00
rubenwardy
618a768f9a Fix not being able to remove packages added by query field 2023-08-19 02:48:40 +01:00
rubenwardy
cea315048b Add long description to collections 2023-08-19 02:43:38 +01:00
rubenwardy
c04cb14eec Fix placeholder not being shown in collection view 2023-08-19 02:33:16 +01:00
rubenwardy
5b4f997f3d Add ability to add packages from collection page 2023-08-19 02:31:40 +01:00
rubenwardy
57ba3e8700 Add ability to remove packages from collection page 2023-08-19 01:25:13 +01:00
rubenwardy
8d97c6b38e Update translations 2023-08-19 00:52:02 +01:00
Wuzzy
c1c272376f Translated using Weblate (German)
Currently translated at 100.0% (852 of 852 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
Muhammad Rifqi Priyo Susanto
a28644548f Translated using Weblate (Indonesian)
Currently translated at 100.0% (852 of 852 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
José Muñoz
9a08b53bf6 Translated using Weblate (Spanish)
Currently translated at 86.9% (741 of 852 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
Jakub Z
bba59bf96a Translated using Weblate (Polish)
Currently translated at 100.0% (852 of 852 strings)

Translated using Weblate (Polish)

Currently translated at 96.4% (822 of 852 strings)

Translated using Weblate (Polish)

Currently translated at 95.4% (813 of 852 strings)

Co-authored-by: Jakub Z <mrkubax10@onet.pl>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
古銭マニア
caa104bbfb Translated using Weblate (Japanese)
Currently translated at 21.1% (180 of 852 strings)

Co-authored-by: 古銭マニア <kosennotaku128@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ja/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
ROllerozxa
1b74c4c520 Translated using Weblate (Swedish)
Currently translated at 100.0% (852 of 852 strings)

Co-authored-by: ROllerozxa <rollerozxa@voxelmanip.se>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
John Donne
0dcb084a4f Translated using Weblate (French)
Currently translated at 96.9% (826 of 852 strings)

Translated using Weblate (French)

Currently translated at 96.0% (818 of 852 strings)

Co-authored-by: John Donne <akheron@zaclys.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
AlexTECPlayz
8b79340180 Translated using Weblate (Romanian)
Currently translated at 8.2% (70 of 852 strings)

Co-authored-by: AlexTECPlayz <alextec70@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ro/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
Linerly
340edaa78a Translated using Weblate (Indonesian)
Currently translated at 100.0% (852 of 852 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
2b057a2d50 Translated using Weblate (Malay)
Currently translated at 100.0% (852 of 852 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-08-19 01:49:55 +02:00
rubenwardy
e47ea249e7 Use Star instead of Heart for favourites 2023-08-19 00:46:27 +01:00
rubenwardy
c1aa12dc8c Make "Report an Issue" translatable 2023-08-19 00:43:42 +01:00
rubenwardy
b3847da28e Add report issue button to review form 2023-08-19 00:37:50 +01:00
rubenwardy
2c52f06744 Support lists of packages in create collection 2023-08-19 00:33:04 +01:00
rubenwardy
aaee730ba5 Fix crash when creating collection 2023-08-16 09:43:26 +01:00
rubenwardy
eb81674f06 Prevent naming a package the same as a collection 2023-08-16 01:03:54 +01:00
rubenwardy
ea2f1f4f6f Fix unapproved packages appearing in collections 2023-08-16 01:00:51 +01:00
rubenwardy
f470357a42 Allow changing collection URL name 2023-08-16 00:54:34 +01:00
rubenwardy
2ad25f1aa9 Fix various issues with collections 2023-08-15 20:39:32 +01:00
rubenwardy
af4f03d298 Add ability to delete collections 2023-08-14 22:17:29 +01:00
rubenwardy
4a0653bcfd Fix responsiveness of package grid on mobile 2023-08-14 22:01:01 +01:00
rubenwardy
f7a5a1218f Add package collections
Fixes #378
2023-08-14 21:48:50 +01:00
rubenwardy
bf20177756 Use CSS grid for package tiles 2023-08-14 18:55:42 +01:00
rubenwardy
800cacb003 Add API for HTML/Markdown to Hypertext 2023-08-13 13:50:02 +01:00
rubenwardy
2454738eaa Fix banned users having incorrect rank after account deactivation 2023-08-13 13:29:55 +01:00
rubenwardy
f1b2465e82 Add sorting links to profile page 2023-08-13 13:29:32 +01:00
rubenwardy
32a305c9d8 Prevent deleting banned accounts 2023-08-10 16:31:16 +01:00
rubenwardy
a1eac9959e Reduce min comment length 2023-08-06 20:55:55 +01:00
rubenwardy
7492c308ad MinetestCheck: Add more reserved mod names 2023-08-06 12:29:43 +01:00
rubenwardy
d31162a1fa MinetestCheck: Forbid the use of reserved mod names 2023-08-06 12:21:06 +01:00
rubenwardy
92a9a7268c Use restart: unless-stopped instead of restart: always 2023-07-29 21:34:37 +01:00
rubenwardy
76414cb5ba Add page to transfer packages 2023-07-29 21:34:23 +01:00
Dmitry Smirnov
6d184e0320 Allow users to see their own comments (#451)
Fixes #428
2023-07-22 13:13:12 +01:00
rubenwardy
30372b99c6 Disallow packages that ask mod security to be disabled 2023-07-22 12:48:20 +01:00
rubenwardy
18ee0108e5 Use display name rather than username in Discord webhooks 2023-07-22 12:45:50 +01:00
rubenwardy
d374ce27cf Make package approval threads private
We've found that a lot of users comment in these
threads when they shouldn't
2023-07-22 12:45:21 +01:00
rubenwardy
c24013435c Add restart: always to docker-compose.yml 2023-07-22 08:49:54 +01:00
rubenwardy
2007f3a095 Add og:image to profile pages 2023-07-15 19:11:41 +01:00
rubenwardy
a0c0cce2ab Fix og:title not being set unless description is set 2023-07-15 19:09:47 +01:00
rubenwardy
4a2d5c9066 Redirect /packages/author/ to /users/author/ 2023-07-15 19:08:36 +01:00
rubenwardy
9e446e7524 Add thread id and URL to review API 2023-07-12 21:32:22 +01:00
rubenwardy
43e9641feb API: Add option to filter reviews by package author 2023-07-09 16:48:46 +01:00
rubenwardy
e446e9011a Adjust error message to debug Yandex mail issue 2023-07-06 17:04:59 +01:00
rubenwardy
2494121615 Fix inverted condition in supports_all_games check 2023-07-03 22:49:46 +01:00
rubenwardy
a3e8dce871 Update game support docs 2023-07-03 22:36:25 +01:00
rubenwardy
ba0b4d518d Prevent supporting all games from .conf when depending on a game-specific game 2023-07-03 22:23:33 +01:00
rubenwardy
1f2478fc1b Add list of packages affected by bulk support games 2023-06-27 22:55:46 +01:00
rubenwardy
1a8d28a2d8 Prevent users being able to enable support for all games when they shouldn't 2023-06-27 21:40:31 +01:00
rubenwardy
96f9adb95f Update translations 2023-06-27 21:02:03 +01:00
ROllerozxa
15162e7860 Translated using Weblate (Swedish)
Currently translated at 96.4% (821 of 851 strings)

Co-authored-by: ROllerozxa <rollerozxa@voxelmanip.se>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-06-27 22:00:11 +02:00
José Muñoz
ec0a9296d8 Translated using Weblate (Spanish)
Currently translated at 86.3% (735 of 851 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-06-27 22:00:11 +02:00
rubenwardy
86565a0384 Fix unsupported games appearing in game hubs
Fixes #449
2023-06-27 20:41:07 +01:00
Wuzzy
870efc7fbe Translated using Weblate (German)
Currently translated at 100.0% (851 of 851 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2023-06-26 23:40:24 +02:00
Giov4
998db5d26d Translated using Weblate (Italian)
Currently translated at 94.8% (807 of 851 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-06-26 23:40:23 +02:00
Robinson
8593747712 Translated using Weblate (Czech)
Currently translated at 60.7% (517 of 851 strings)

Co-authored-by: Robinson <simekm@yahoo.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/cs/
Translation: Minetest/ContentDB
2023-06-26 23:40:23 +02:00
Linerly
19969abf63 Translated using Weblate (Indonesian)
Currently translated at 100.0% (851 of 851 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-06-26 23:40:22 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
6d7cfd1ca1 Translated using Weblate (Malay)
Currently translated at 100.0% (851 of 851 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-06-26 23:40:22 +02:00
rubenwardy
78f0d1f6c3 Fix crash due to incorrect /tasks/ param type 2023-06-25 12:20:23 +01:00
rubenwardy
adbc2b0195 Fix missing types on integer route arguments 2023-06-25 00:29:24 +01:00
rubenwardy
461d45b411 Fix typos in game support text 2023-06-23 12:11:21 +01:00
rubenwardy
0d4164c5b1 Update translations 2023-06-20 00:38:56 +01:00
Linerly
43707a5416 Translated using Weblate (Indonesian)
Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-06-20 01:38:27 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
c98ab982a2 Translated using Weblate (Malay)
Currently translated at 100.0% (849 of 849 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-06-20 01:38:27 +02:00
rubenwardy
dcce351ad2 Improve messages relating to game support 2023-06-20 00:36:33 +01:00
rubenwardy
edce45f71a Add emails_sent metric 2023-06-19 23:34:49 +01:00
rubenwardy
3992b30cc2 Allow standard users to override game support 2023-06-19 22:35:26 +01:00
rubenwardy
c7ee42a4d5 Game Support: Show correct conf file name 2023-06-19 22:33:45 +01:00
rubenwardy
bb41ea7dcc Prevent texture packs from supporting all games 2023-06-19 22:22:55 +01:00
rubenwardy
f2eee008f6 Fix endpoint argument rename causing issues 2023-06-19 22:10:21 +01:00
rubenwardy
45ed12ddf0 Use snake_case for method names 2023-06-19 21:57:54 +01:00
rubenwardy
16f93b3e13 Optimise imports and fix linter issues 2023-06-19 21:57:54 +01:00
rubenwardy
0ddf498285 Fix tests 2023-06-19 21:57:54 +01:00
rubenwardy
d808a5c822 Fix issues based on linter 2023-06-19 21:57:54 +01:00
rubenwardy
f2cfb6c17d Fix typos and grammar issues 2023-06-19 21:57:54 +01:00
rubenwardy
e8b14709e6 Fix quotes in templates 2023-06-19 21:57:54 +01:00
rubenwardy
8585357942 Use consistent naming scheme for methods/functions 2023-06-19 21:57:54 +01:00
rubenwardy
e0b25054dc Fix incorrect filter on bulk game support set 2023-06-18 23:03:41 +01:00
rubenwardy
f3d21b79ab Improve game support messages 2023-06-18 22:57:24 +01:00
rubenwardy
324d7ec1e1 Add audit logging for game support pages 2023-06-18 21:34:53 +01:00
rubenwardy
e6f36113ce Update translations 2023-06-18 21:21:04 +01:00
Giov4
eabacbf421 Translated using Weblate (Italian)
Currently translated at 99.0% (817 of 825 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
Robinson
375285162b Translated using Weblate (Czech)
Currently translated at 49.0% (405 of 825 strings)

Translated using Weblate (Czech)

Currently translated at 39.3% (325 of 825 strings)

Co-authored-by: Martin Šimek <simekm@yahoo.com>
Co-authored-by: Robinson <simekm@yahoo.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/cs/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
Nicolae Crefelean
cdb1bf0963 Translated using Weblate (Romanian)
Currently translated at 8.3% (69 of 825 strings)

Translated using Weblate (Romanian)

Currently translated at 7.5% (62 of 825 strings)

Translated using Weblate (Romanian)

Currently translated at 1.2% (10 of 825 strings)

Added translation using Weblate (Romanian)

Co-authored-by: Nicolae Crefelean <kneekoo@yahoo.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ro/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
Linerly
68ccf63486 Translated using Weblate (Indonesian)
Currently translated at 100.0% (825 of 825 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
José Muñoz
a9094ea53d Translated using Weblate (Spanish)
Currently translated at 89.4% (738 of 825 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
Translator
5736384377 Translated using Weblate (French)
Currently translated at 99.3% (820 of 825 strings)

Co-authored-by: Translator <kvb@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
fd56559cc8 Translated using Weblate (Malay)
Currently translated at 100.0% (825 of 825 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-06-18 22:19:13 +02:00
rubenwardy
3a70d6d188 Change unsupported game text 2023-06-18 21:18:42 +01:00
rubenwardy
c498818e8b Add supports_all_games to make game support explicit
Fixes #388 and fixes #441
2023-06-18 21:12:14 +01:00
rubenwardy
cb352fad47 Stats: Make date range dropdown blue when active 2023-06-15 09:00:11 +01:00
rubenwardy
2596253535 Stats: Improve summaries when range is selected
Fixes #446
2023-06-15 08:45:28 +01:00
rubenwardy
81651aee97 Stats: Update date range options 2023-06-14 23:00:22 +01:00
rubenwardy
80c42637df Stats: Add ability to select date range 2023-06-14 22:47:08 +01:00
rubenwardy
516361345e Stats: Fix annotation being added when outside graph range 2023-06-14 22:41:02 +01:00
rubenwardy
7f3b24a650 Fix typo on support packages page 2023-06-13 17:46:04 +01:00
rubenwardy
d443945b5c Fix release min/max error being shown incorrectly 2023-06-05 17:55:20 +01:00
rubenwardy
661d66cafb Update translations 2023-06-01 18:12:29 +01:00
Sharpik
3d8fdd70b3 Translated using Weblate (Czech)
Currently translated at 37.3% (307 of 821 strings)

Co-authored-by: Sharpik <david.maliska@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/cs/
Translation: Minetest/ContentDB
2023-06-01 19:11:48 +02:00
Giov4
9fcd2b7908 Translated using Weblate (Italian)
Currently translated at 100.0% (821 of 821 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-06-01 19:11:48 +02:00
nyommer
7e47730c8b Translated using Weblate (Hungarian)
Currently translated at 38.7% (318 of 821 strings)

Co-authored-by: nyommer <jishnu.ifeoluwa@fullangle.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Minetest/ContentDB
2023-06-01 19:11:48 +02:00
José Muñoz
f368734241 Translated using Weblate (Spanish)
Currently translated at 90.0% (739 of 821 strings)

Translated using Weblate (Spanish)

Currently translated at 88.6% (728 of 821 strings)

Translated using Weblate (Spanish)

Currently translated at 86.3% (709 of 821 strings)

Translated using Weblate (Spanish)

Currently translated at 85.9% (706 of 821 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-06-01 19:11:48 +02:00
rubenwardy
28b3084186 Add missing CSS for client preview 2023-06-01 18:02:26 +01:00
rubenwardy
3375276f0d Show all featured packages in client 2023-06-01 18:00:08 +01:00
rubenwardy
0a77a0110d Split Featured tag into Featured and Spotlight 2023-06-01 17:55:00 +01:00
rubenwardy
78b5986027 Add client preview to screenshots page 2023-06-01 17:48:59 +01:00
rubenwardy
26abe9275c Promote featured packages in the client 2023-05-31 17:29:20 +01:00
rubenwardy
a0491216b9 Fix normalize_whitespace double escaping text 2023-05-27 15:28:06 +01:00
rubenwardy
e5f669ccb6 Revert "Birthday" and "Add birthday number and link to forum topic"
This reverts commit 224fef683d.

This reverts commit 14e01c9007.
2023-05-26 21:40:09 +01:00
rubenwardy
224fef683d Add birthday number and link to forum topic 2023-05-25 12:54:59 +01:00
rubenwardy
14e01c9007 Birthday 2023-05-25 12:29:18 +01:00
rubenwardy
bb206da804 Enable Turkish translation 2023-05-24 00:11:30 +01:00
Furkan Baytekin
d01391325e Translated using Weblate (Turkish)
Currently translated at 100.0% (821 of 821 strings)

Translated using Weblate (Turkish)

Currently translated at 38.8% (319 of 821 strings)

Co-authored-by: Furkan Baytekin <furkanbaytekin@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/tr/
Translation: Minetest/ContentDB
2023-05-23 00:48:38 +02:00
Giov4
7fa18e59e5 Translated using Weblate (Italian)
Currently translated at 100.0% (821 of 821 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (821 of 821 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-05-23 00:48:38 +02:00
José Muñoz
6c1a97be1b Translated using Weblate (Spanish)
Currently translated at 84.1% (691 of 821 strings)

Translated using Weblate (Spanish)

Currently translated at 77.7% (638 of 821 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-05-23 00:48:37 +02:00
rubenwardy
996d46cad7 Fix all package stats API returning 0s 2023-05-20 00:56:44 +01:00
rubenwardy
8cde0cd852 Fix locked medal progress label being cut off
Fixes #365
2023-05-19 21:46:33 +01:00
rubenwardy
287aae8bd2 Disable linkify on <code> tags 2023-05-19 20:36:51 +01:00
rubenwardy
ff23f981e2 Disable linkify on text without https:// or http://
Fixes #374
2023-05-19 20:15:00 +01:00
rubenwardy
05bfa11d71 Fix test data failing txp constraint 2023-05-19 20:06:50 +01:00
rubenwardy
81b9833a81 Disable sqlalchemy-searchable sync_trigger to fix failing CI
This is a big hack and will prevent search working on databases that
haven't set it up yet, but is needed to get UI tests and such working.
2023-05-19 20:06:50 +01:00
rubenwardy
57b736b1df Todo: Add game support status 2023-05-19 19:40:03 +01:00
rubenwardy
d0f6be6826 Downgrade Flask and Werkzeug 2023-05-19 19:28:48 +01:00
rubenwardy
723994322b Refactor todo blueprint 2023-05-19 19:19:47 +01:00
rubenwardy
d8fa3342cf Discord webhooks: Fix understood failing status codes 2023-05-14 17:23:55 +01:00
rubenwardy
a5e258f7fa Fix throwing exception 2023-05-14 17:22:57 +01:00
rubenwardy
8178232911 Fix Discord webhooks failing due to avatar URL being relative 2023-05-14 17:21:23 +01:00
rubenwardy
1a173153c8 Inspect Discord webhook response 2023-05-14 17:16:56 +01:00
rubenwardy
adbbaf93c6 Fix session.execute in integration test utils 2023-05-13 19:12:52 +01:00
rubenwardy
fe64f15949 Fix stylesheet not being included 2023-05-13 18:21:13 +01:00
rubenwardy
1a8b6a23dd Fix error in old migrations caused by dependency updates 2023-05-13 17:53:21 +01:00
rubenwardy
286a598c77 Fix empty descriptions being added 2023-05-13 17:45:22 +01:00
rubenwardy
0c1d1354cb Fix name in README.md 2023-05-13 17:44:09 +01:00
Pexauteau Santander
4785ca1acc Translated using Weblate (Slovak)
Currently translated at 100.0% (821 of 821 strings)

Co-authored-by: Pexauteau Santander <pexauteau@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sk/
Translation: Minetest/ContentDB
2023-05-13 18:32:15 +02:00
José Muñoz
8b3b8dccf4 Translated using Weblate (Spanish)
Currently translated at 73.0% (600 of 821 strings)

Translated using Weblate (Spanish)

Currently translated at 68.4% (562 of 821 strings)

Translated using Weblate (Spanish)

Currently translated at 64.5% (530 of 821 strings)

Co-authored-by: José Muñoz <dr.cabra@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-05-13 18:32:15 +02:00
John Donne
acd69b1d4d Translated using Weblate (French)
Currently translated at 99.6% (818 of 821 strings)

Co-authored-by: John Donne <akheron@zaclys.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-05-13 18:32:15 +02:00
Giov4
2b69f2e6ac Translated using Weblate (Italian)
Currently translated at 100.0% (821 of 821 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (821 of 821 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-05-13 18:32:15 +02:00
Linerly
ef8e3451d6 Translated using Weblate (Indonesian)
Currently translated at 100.0% (821 of 821 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-05-13 18:32:15 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
b4a59cf135 Translated using Weblate (Malay)
Currently translated at 100.0% (821 of 821 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-05-13 18:32:15 +02:00
rubenwardy
5ffc5fe341 Update Werkzeug 2023-05-13 17:05:31 +01:00
rubenwardy
b310cb3c80 Default to 4 workers 2023-05-13 16:55:27 +01:00
rubenwardy
08ff5199e3 Allow editors to use zipgrep 2023-05-13 16:48:48 +01:00
dependabot[bot]
70e46139cc Bump flask from 2.2.3 to 2.3.2
Bumps [flask](https://github.com/pallets/flask) from 2.2.3 to 2.3.2.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/2.2.3...2.3.2)

---
updated-dependencies:
- dependency-name: flask
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-13 16:19:33 +01:00
rubenwardy
e168da4f72 Fix unapproved games showing in supported games
Fixes #429
2023-05-13 16:10:53 +01:00
rubenwardy
0658368aad Homepage: Fix tag counts including unapproved packages 2023-05-13 15:40:03 +01:00
rubenwardy
1659802ff3 Fix tag counts including unapproved packages 2023-05-13 15:34:20 +01:00
rubenwardy
35afd50f3d Fix crash when generating diff on new package 2023-05-12 15:53:06 +01:00
rubenwardy
dee9959fda Remove unused admin actions 2023-05-12 01:13:09 +01:00
rubenwardy
ed8ce8c16c Add link to search from tag edit page 2023-05-12 01:08:22 +01:00
rubenwardy
7df1b8cc0f Remove old restore method 2023-05-12 01:06:09 +01:00
rubenwardy
e88ead392b Add audit logging to type editors 2023-05-12 01:03:21 +01:00
rubenwardy
f03746f5ad Allow editors and approvers to see package audit log descriptions 2023-05-12 00:55:44 +01:00
rubenwardy
84d379d490 Fix incorrect difference detected due to order of tags 2023-05-12 00:45:40 +01:00
rubenwardy
9738a8a826 describe_difference: Limit string field diff length 2023-05-12 00:20:38 +01:00
rubenwardy
19fa91d319 Add changes to edit package audit log entry 2023-05-12 00:17:15 +01:00
rubenwardy
1fc4852e77 Add check constraint to validate texture pack licenses 2023-05-11 23:08:01 +01:00
rubenwardy
a2a38f1e14 Add descriptions to about/faq flatpages 2023-05-08 01:49:23 +01:00
rubenwardy
fb329cd76e Link to Minetest Modding Book in help pages 2023-05-08 01:45:36 +01:00
rubenwardy
3ccb165522 Import forum profile pictures and host them directly 2023-05-05 18:43:20 +01:00
rubenwardy
a026e2c2bb Fix release creation using API 2023-05-02 10:24:52 +01:00
rubenwardy
d8ee237b1d Add og:title 2023-04-30 23:22:10 +01:00
rubenwardy
50037f6cb7 Remove remaining rubenwardy.com link in footer 2023-04-30 18:15:10 +01:00
rubenwardy
05486e53e0 Update translations
Fixes #435
2023-04-30 01:44:12 +01:00
rubenwardy
9fa42df385 Improve description tag generation 2023-04-30 00:54:01 +01:00
rubenwardy
c2ab4ac308 Add meta tag to donate page 2023-04-30 00:33:20 +01:00
rubenwardy
4c66b25e7c Fix crash on donate page 2023-04-30 00:27:10 +01:00
jolesh
b1570d4632 Translated using Weblate (Esperanto)
Currently translated at 19.8% (162 of 815 strings)

Co-authored-by: jolesh <jolesh0815@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/eo/
Translation: Minetest/ContentDB
2023-04-26 14:47:38 +02:00
John Donne
0258bc7949 Translated using Weblate (French)
Currently translated at 99.6% (812 of 815 strings)

Co-authored-by: John Donne <akheron@zaclys.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-04-26 14:47:37 +02:00
Giov4
199dc6f59e Translated using Weblate (Italian)
Currently translated at 100.0% (815 of 815 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (815 of 815 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-04-26 14:47:37 +02:00
syl
726204763d Translated using Weblate (French)
Currently translated at 99.0% (807 of 815 strings)

Co-authored-by: syl <syl@gresille.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-04-26 14:47:37 +02:00
AFCMS
10eb23d760 Translated using Weblate (French)
Currently translated at 98.8% (806 of 815 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-04-26 14:47:36 +02:00
rubenwardy
b785a66ae8 Reduce number of indexed pages
Removes some package search pages from search results. Also removes all package thread lists (as they are redundant)
2023-04-24 00:17:26 +01:00
rubenwardy
d16969837c Add About page 2023-04-23 23:27:18 +01:00
rubenwardy
a5ec46f14c Update database dependencies 2023-04-23 21:51:51 +01:00
rubenwardy
660ef72532 Update non-database dependencies 2023-04-23 21:51:51 +01:00
rubenwardy
3c1ba226c4 FAQ: Clarify email verification account deletion 2023-04-23 16:36:33 +01:00
rubenwardy
2a0545210b hypertext: Add support for nested lists 2023-04-19 20:03:07 +01:00
rubenwardy
0a06e41497 Add API to provide descriptions as Minetest hypertext markup 2023-04-19 20:03:07 +01:00
rubenwardy
dfe829d59e Update some dependencies 2023-04-19 02:59:57 +01:00
rubenwardy
64280bd960 Add rules for user behaviour 2023-04-19 02:28:30 +01:00
rubenwardy
a97da15359 Don't consider neutral reviews to be positive 2023-04-15 20:06:24 +01:00
rubenwardy
c9e8df7f5b Simplify review rating 2023-04-15 04:02:09 +01:00
rubenwardy
1b1955f052 Fix review rating 2023-04-15 03:20:01 +01:00
rubenwardy
b1bd39c0fc Add ability to make neutral reviews 2023-04-15 02:46:35 +01:00
rubenwardy
1235bc14db Fix account deactivation 2023-04-03 20:43:26 +01:00
Fábio Rodrigues Ribeiro
5cbfe4cda5 Translated using Weblate (Portuguese (Brazil))
Currently translated at 5.3% (44 of 815 strings)

Co-authored-by: Fábio Rodrigues Ribeiro <farribeiro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pt_BR/
Translation: Minetest/ContentDB
2023-04-02 19:57:03 +02:00
Giov4
4f38b77107 Translated using Weblate (Italian)
Currently translated at 100.0% (815 of 815 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-04-02 19:57:03 +02:00
rubenwardy
634fafc880 Enable Ukrainian 2023-03-29 16:15:23 +01:00
Giov4
671b975a68 Translated using Weblate (Italian)
Currently translated at 99.3% (810 of 815 strings)

Co-authored-by: Giov4 <brancacciogiovanni1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2023-03-29 17:06:21 +02:00
Денис Савченко
f51541ffe3 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (815 of 815 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: Денис Савченко <denissavchenko0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Minetest/ContentDB
2023-03-29 17:06:21 +02:00
Muhammad Rifqi Priyo Susanto
034a024cb2 Translated using Weblate (Indonesian)
Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-03-29 17:06:21 +02:00
rubenwardy
766765b1f8 Fix crash on unimplemented is_
Whilst is_ is documented, it appears to be
unimplemented for certain objects.
2023-03-29 10:58:09 +01:00
rubenwardy
e7f5f7055d Remove accidental about link 2023-03-27 10:32:52 +01:00
rubenwardy
fd06d86062 Rename report link 2023-03-24 17:56:02 +00:00
ROllerozxa
a8ed6b5b44 Translated using Weblate (Swedish)
Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: ROllerozxa <rollerozxa@voxelmanip.se>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-03-19 00:48:39 +01:00
Translator
096efb8fa4 Translated using Weblate (French)
Currently translated at 98.7% (805 of 815 strings)

Co-authored-by: Translator <kvb@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-03-19 00:48:39 +01:00
ROllerozxa
ec6fc5236b Translated using Weblate (Swedish)
Currently translated at 98.2% (801 of 815 strings)

Co-authored-by: ROllerozxa <temporaryemail4meh+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-03-19 00:48:39 +01:00
rubenwardy
fb13272e6c Fix crash due to relations not supporting is_ 2023-03-18 23:20:09 +00:00
rubenwardy
7eca06a097 Fix web hooks updating deleted packages 2023-03-18 14:22:39 +00:00
Julien Maulny
32f353af8f Translated using Weblate (French)
Currently translated at 98.0% (799 of 815 strings)

Co-authored-by: Julien Maulny <julien.maulny@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-03-17 15:26:26 +01:00
Rodion Borisov
739e0eb316 Translated using Weblate (Russian)
Currently translated at 94.3% (769 of 815 strings)

Co-authored-by: Rodion Borisov <vintprox@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-03-17 15:26:26 +01:00
Wuzzy
13624a7a97 Translated using Weblate (German)
Currently translated at 100.0% (815 of 815 strings)

Translated using Weblate (German)

Currently translated at 99.1% (808 of 815 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2023-03-17 15:26:25 +01:00
Muhammad Rifqi Priyo Susanto
a2760da676 Translated using Weblate (Indonesian)
Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-03-17 15:26:25 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
9873e40076 Translated using Weblate (Malay)
Currently translated at 100.0% (815 of 815 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (815 of 815 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-03-17 15:26:24 +01:00
rubenwardy
a2c096a6e6 Update translations 2023-03-09 18:41:22 +00:00
Артём Котлубай
865eb0112d Translated using Weblate (Russian)
Currently translated at 96.1% (768 of 799 strings)

Co-authored-by: Артём Котлубай <artemkotlubai@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
Arsenicus
6ed7061bce Translated using Weblate (Russian)
Currently translated at 96.1% (768 of 799 strings)

Co-authored-by: Arsenicus <divided0303@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
Wuzzy
7636748289 Translated using Weblate (German)
Currently translated at 100.0% (799 of 799 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
chocomint
fdd9609557 Translated using Weblate (Spanish)
Currently translated at 63.8% (510 of 799 strings)

Co-authored-by: chocomint <silentxe1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
unacceptium core
40c3daf563 Translated using Weblate (Hungarian)
Currently translated at 38.1% (305 of 799 strings)

Co-authored-by: unacceptium core <kolonics20132a@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
Артём Котлубай
baf6a8e418 Translated using Weblate (Russian)
Currently translated at 95.8% (766 of 799 strings)

Co-authored-by: Артём Котлубай <artemkotlubai@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
rubenwardy
49551a8a70 Translated using Weblate (Slovak)
Currently translated at 100.0% (799 of 799 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sk/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
Pexauteau Santander
4839703389 Translated using Weblate (Slovak)
Currently translated at 100.0% (799 of 799 strings)

Translated using Weblate (Slovak)

Currently translated at 99.7% (797 of 799 strings)

Translated using Weblate (Slovak)

Currently translated at 99.7% (797 of 799 strings)

Co-authored-by: Pexauteau Santander <pexauteau@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sk/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
Jakub Z
564eb4a85f Translated using Weblate (Polish)
Currently translated at 100.0% (799 of 799 strings)

Co-authored-by: Jakub Z <mrkubax10@onet.pl>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
ROllerozxa
6fe7df2233 Translated using Weblate (Swedish)
Currently translated at 100.0% (799 of 799 strings)

Co-authored-by: ROllerozxa <temporaryemail4meh+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-03-09 18:35:34 +00:00
rubenwardy
027a6a79bc Add description to donation page 2023-03-09 18:23:35 +00:00
rubenwardy
07f5d2e0d5 Add per-package donate URLs 2023-03-05 18:17:03 +00:00
rubenwardy
08054e4969 Add page to find packages asking for donations 2023-03-05 17:33:09 +00:00
Nicolae Crefelean
4e154644ee Show the image caption on screenshot hover (#423) 2023-03-05 17:33:09 +00:00
rubenwardy
e0f9623670 Fix wrong comment being used for reviews
Fixes #417
2023-01-28 16:39:53 +00:00
rubenwardy
37b200295c Fix crash on empty display name 2023-01-21 19:03:43 +00:00
rubenwardy
88022667ce Remove incorrect check on package provides 2023-01-14 23:54:45 +00:00
rubenwardy
c927a87db3 Fix long audit descriptions being lost 2023-01-13 23:49:06 +00:00
rubenwardy
157f418855 Fix game support not being in tabs for txp 2023-01-12 21:10:58 +00:00
rubenwardy
42b9986fc8 Enable game support for texture packs 2023-01-12 19:48:18 +00:00
rubenwardy
605015f812 Update blocked site message 2023-01-12 16:43:10 +00:00
Kristian
cc1eec93d5 Added translation using Weblate (Danish)
Co-authored-by: Kristian <macrofag@protonmail.com>
2023-01-12 13:55:19 +00:00
Jakub Z
32354483fc Translated using Weblate (Polish)
Currently translated at 93.2% (745 of 799 strings)

Co-authored-by: Jakub Z <mrkubax10@onet.pl>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2023-01-12 13:55:19 +00:00
Gao Tiesuan
46d4ca6b0f Translated using Weblate (Chinese (Simplified))
Currently translated at 96.8% (774 of 799 strings)

Co-authored-by: Gao Tiesuan <yepifoas@666email.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2023-01-12 13:55:19 +00:00
AFCMS
ddac098704 Translated using Weblate (French)
Currently translated at 100.0% (799 of 799 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-01-12 13:55:19 +00:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
6d50546eba Translated using Weblate (Malay)
Currently translated at 100.0% (799 of 799 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-01-12 13:55:19 +00:00
rubenwardy
14d29a54e1 Update AI section in copyright guide 2023-01-09 00:21:43 +00:00
rubenwardy
be8de45714 Fix game support crash due to incorrect key 2023-01-08 12:23:33 +00:00
rubenwardy
0a6e3237b1 Fix untranslatable strings 2023-01-08 12:23:33 +00:00
rubenwardy
008e6ba2e6 Add help page about copyright (#415) 2023-01-05 13:08:55 +00:00
rubenwardy
46b804834a Fix embed being required in webhook 2023-01-05 09:43:27 +00:00
rubenwardy
540e24e8f9 Add Discord embed to package webhooks 2023-01-04 20:58:32 +00:00
AFCMS
4c98063d6a Fix invalid API route in docs for .cdb.json schema (#416) 2023-01-03 12:43:18 +00:00
rubenwardy
72b4029ed3 Add ability to block domains 2023-01-03 12:17:25 +00:00
AFCMS
13dcd373f2 Add API endpoint for .cdb.json JSON Schema (#402)
Fixes #393
2023-01-02 19:26:10 +00:00
rubenwardy
65e8929689 Fix game support errors not showing to users
Fixes #394
2023-01-02 19:19:33 +00:00
rubenwardy
31a748b0b3 Fix deleted packaging being show on todo page
Fixes #401
2023-01-02 19:16:51 +00:00
rubenwardy
38baea3dcf Fix wrong scheme used when Git cloning
Fixes #408
2023-01-02 19:14:33 +00:00
rubenwardy
ad0e958736 Fix mention of content flags in non-free help 2023-01-02 17:41:10 +00:00
rubenwardy
c1600b90a6 Loop chart colors 2023-01-02 17:34:34 +00:00
rubenwardy
72d999e759 Remove login button from gone page when already logged in
Fixes #414
2023-01-02 17:25:08 +00:00
rubenwardy
a7bbb45fc2 Use 403 status code for unpublished pages 2023-01-02 17:20:08 +00:00
rubenwardy
34bbb281e0 Update translations 2023-01-02 16:19:42 +00:00
Артём Котлубай
4b61657602 Translated using Weblate (Russian)
Currently translated at 97.4% (768 of 788 strings)

Co-authored-by: Артём Котлубай <artemkotlubai@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
Arsenicus
db7278664f Translated using Weblate (Russian)
Currently translated at 97.4% (768 of 788 strings)

Co-authored-by: Arsenicus <divided0303@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
Worriz BANG-E 谨京
cd4fa81260 Translated using Weblate (Chinese (Simplified))
Currently translated at 94.0% (741 of 788 strings)

Co-authored-by: Worriz BANG-E 谨京 <3263125443@qq.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
CouldBeMathijs
68fbbfa8d5 Translated using Weblate (Dutch)
Currently translated at 60.4% (476 of 788 strings)

Co-authored-by: CouldBeMathijs <mathijs.pittoors@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nl/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
Fábio Rodrigues Ribeiro
a5c0a48d2b Translated using Weblate (Portuguese (Brazil))
Currently translated at 2.5% (20 of 788 strings)

Co-authored-by: Fábio Rodrigues Ribeiro <farribeiro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pt_BR/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
Wuzzy
89f08d4217 Translated using Weblate (German)
Currently translated at 100.0% (788 of 788 strings)

Translated using Weblate (German)

Currently translated at 94.6% (746 of 788 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
Eduardo Lima
ba881ec2e1 Translated using Weblate (Portuguese (Brazil))
Currently translated at 2.5% (20 of 788 strings)

Co-authored-by: Eduardo Lima <edu200399lim@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pt_BR/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
ROllerozxa
717255bf50 Translated using Weblate (Swedish)
Currently translated at 100.0% (788 of 788 strings)

Co-authored-by: ROllerozxa <temporaryemail4meh+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2023-01-02 17:18:34 +01:00
Muhammad Rifqi Priyo Susanto
090883cb61 Translated using Weblate (Indonesian)
Currently translated at 100.0% (788 of 788 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2023-01-02 17:18:33 +01:00
rubenwardy
b102c41008 Translated using Weblate (French)
Currently translated at 93.7% (739 of 788 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2023-01-02 17:18:33 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
a0d0eedbb3 Translated using Weblate (Malay)
Currently translated at 100.0% (788 of 788 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2023-01-02 17:18:33 +01:00
rubenwardy
16abb636c5 Show message on draft packages rather than 404 2023-01-02 16:17:28 +00:00
rubenwardy
a7e6f19ae6 Move package approval section above header
In an attempt to make it more obvious
2023-01-02 16:14:20 +00:00
rubenwardy
18f70738d0 Prevent reviewing unapproved packages 2023-01-02 15:51:19 +00:00
wsor4035
d3bdf4cf03 Remove white background from avatars
Fixes #387
2023-01-01 22:48:10 +00:00
rubenwardy
5e425cd29c Validate mod directory casing 2023-01-01 22:45:10 +00:00
rubenwardy
048b02db1d Update copyright year 2023-01-01 21:09:59 +00:00
rubenwardy
8930418d53 Update gone page message 2023-01-01 20:42:40 +00:00
rubenwardy
8ef737b16c Fix being able to see unpublished packages 2023-01-01 20:39:35 +00:00
wsor4035
01344256a9 Fix license editor crash (#410) 2022-12-26 19:01:34 +00:00
rubenwardy
5940919fae Add docs for game Package Query arg 2022-12-20 13:48:42 +00:00
rubenwardy
c4ccd82f63 Remove password suggestions
It was based on 5 words from a 2048 word list, which is only the same as a 11 character password
2022-12-15 16:04:02 +00:00
rubenwardy
a669131178 Update translations 2022-12-09 19:07:30 +00:00
runs
1a859cf341 Translated using Weblate (Spanish)
Currently translated at 67.6% (505 of 746 strings)

Co-authored-by: runs <runspect@yahoo.es>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-12-09 20:05:46 +01:00
Gao Tiesuan
067e0dba91 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.5% (743 of 746 strings)

Co-authored-by: Gao Tiesuan <yepifoas@666email.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-12-09 20:05:46 +01:00
Muhammad Rifqi Priyo Susanto
d5413cc751 Translated using Weblate (Indonesian)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-12-09 20:05:46 +01:00
Темак
cd7cdbcf72 Translated using Weblate (Russian)
Currently translated at 97.3% (726 of 746 strings)

Co-authored-by: Темак <artemkotlubai@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-12-09 20:05:46 +01:00
Nyuh Nyash
ae15c5ebe6 Translated using Weblate (Russian)
Currently translated at 97.3% (726 of 746 strings)

Co-authored-by: Nyuh Nyash <egor.kuryasev@ya.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-12-09 20:05:46 +01:00
rubenwardy
a7b6778f64 Remove gamejam promotion 2022-12-04 15:02:49 +00:00
rubenwardy
7558346071 Don't allow users to recreate removed packages 2022-11-23 19:13:03 +00:00
rubenwardy
f986caf18b Allow new members to create API tokens 2022-11-22 21:56:19 +00:00
rubenwardy
ba7ed40d6a Fix crash in upgrade_new_members task 2022-11-22 21:55:40 +00:00
rubenwardy
4435679737 Update text on claim page 2022-11-22 21:52:02 +00:00
rubenwardy
34aaa45c92 Fix failing test 2022-11-18 21:37:59 +00:00
rubenwardy
a81d289bc8 Add caching headers to API 2022-11-18 21:36:15 +00:00
rubenwardy
db8574ffe3 Homepage: Preload review information in queries 2022-11-18 21:15:46 +00:00
rubenwardy
b72244398b Homepage: Preload package authors and screenshots in queries 2022-11-18 20:29:52 +00:00
rubenwardy
01bc519b86 Add API to get all package stats 2022-11-15 01:51:21 +00:00
rubenwardy
292b4f5483 Statistics: Rotate annotation and add shadow 2022-11-11 01:18:31 +00:00
rubenwardy
6212b95a4d Statistics: Add label for Nov5 YouTube event 2022-11-11 01:05:27 +00:00
rubenwardy
6916b0612f Add redirect to current_user's stats 2022-11-09 20:52:31 +00:00
rubenwardy
1f40a5bf8b Add user info API 2022-11-09 20:47:19 +00:00
rubenwardy
b370b3bab2 Add statistics link to user dropdown in navbar 2022-11-09 20:09:40 +00:00
rubenwardy
9f375c6235 Fix crash on user stats page with no packages 2022-11-09 20:08:50 +00:00
rubenwardy
c9b5e3374c Remove limit on packages in chart 2022-11-09 19:46:42 +00:00
rubenwardy
31aef061fb Add downloads by package graph 2022-11-09 19:45:25 +00:00
rubenwardy
f7742d47ff Add package dropdown to statistics pages 2022-11-09 18:58:58 +00:00
rubenwardy
724b80e91e Add user statistics pages 2022-11-09 18:47:44 +00:00
rubenwardy
82cd0aefdf Attempt to fix chart stretching 2022-11-09 18:46:42 +00:00
rubenwardy
e15a3c682f Statistics: Fix issue with missing days
Fixes #397
2022-11-09 18:46:11 +00:00
rubenwardy
852e6ab5a0 Fix crash in phpbbparser 2022-11-09 17:41:32 +00:00
rubenwardy
20bf16abbf Statistics: Add .csv download 2022-11-08 18:47:28 +00:00
rubenwardy
5fc603682d Statistics: Add weekly and monthly total/average 2022-11-08 18:47:03 +00:00
rubenwardy
977fc1ce96 Move statistics link on package page 2022-11-07 01:41:13 +00:00
rubenwardy
f547fd258d Fix squashed charts 2022-11-07 00:53:30 +00:00
rubenwardy
b0cece3f5f Add empty view to stats page 2022-11-06 19:47:42 +00:00
rubenwardy
53601b77c8 Adjust score given on downloads 2022-11-06 19:44:36 +00:00
rubenwardy
0cb220acff Use UTC dates for stats 2022-11-06 19:12:43 +00:00
rubenwardy
aa2996f92e Fix blocked JS by renaming _stats.js to _charts.js 2022-11-06 19:00:34 +00:00
rubenwardy
69662eeafc Stats: Improve page 2022-11-06 18:51:33 +00:00
rubenwardy
4387e71417 Add statistics page 2022-11-06 18:02:54 +00:00
rubenwardy
5c0480b39d Add script to import stats from nginx logs 2022-11-06 16:54:06 +00:00
rubenwardy
e1f4787fb9 Add package stats endpoint 2022-11-06 10:32:46 +00:00
rubenwardy
de70b21e55 Fix PackageDailyStats field names 2022-11-05 23:22:21 +00:00
rubenwardy
d11d638144 Add per-package download tracking 2022-11-05 22:43:57 +00:00
Kisjuhász Attila
02ef7e09e4 Translated using Weblate (Hungarian)
Currently translated at 27.4% (205 of 746 strings)

Co-authored-by: Kisjuhász Attila <kj.attil@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Minetest/ContentDB
2022-11-01 12:22:13 +01:00
rubenwardy
f010a12ded Improve package search title generation 2022-10-28 16:21:31 +01:00
rubenwardy
e50f7094f8 Replace game jam banner 2022-10-26 09:43:23 +01:00
rubenwardy
ac3047f124 Add game jam 2022 ad 2022-10-25 17:24:43 +01:00
rubenwardy
294037ec70 Use level 4 thumbnail for cover image 2022-10-25 02:00:12 +01:00
rubenwardy
5a506ef557 Add level 4 thumbnails 2022-10-25 01:49:06 +01:00
rubenwardy
da3af2c22f Only allow editors to access user comments page 2022-10-24 14:43:39 +01:00
rubenwardy
1ef71b7a59 Add robots.txt to git 2022-10-19 15:08:27 +01:00
rubenwardy
51d2b82acf Add page to list mods that don't support current version 2022-10-15 12:53:17 +01:00
rubenwardy
5f42e35231 Fix crash on sign up 2022-10-14 12:52:29 +01:00
rubenwardy
e2af9893ce Fix failing unit test 2022-10-13 19:27:18 +01:00
rubenwardy
cb443a2d15 Add game.conf example to package config help 2022-10-13 19:23:45 +01:00
rubenwardy
a7f2cc5d2b Disallow usernames only containing "." 2022-10-13 19:23:18 +01:00
rubenwardy
905185812b Fix broken search input-group 2022-10-13 19:21:16 +01:00
rubenwardy
5baa06d8c3 Use icon for search button rather than text 2022-10-13 17:21:31 +01:00
rubenwardy
cbd430841c Move Threads to footer from navbar 2022-10-13 16:58:12 +01:00
rubenwardy
bfe32d8fc8 Enable Italian and Vietnamese 2022-10-13 16:56:59 +01:00
Темак
a2509df38b Translated using Weblate (Russian)
Currently translated at 97.3% (726 of 746 strings)

Co-authored-by: Темак <artemkotlubai@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-10-13 17:38:51 +02:00
Nikita Epifanov
a0a4dc2cfa Translated using Weblate (Russian)
Currently translated at 97.3% (726 of 746 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-10-13 17:38:51 +02:00
Yic95
3fe4eae4c0 Translated using Weblate (Chinese (Traditional))
Currently translated at 13.0% (97 of 746 strings)

Co-authored-by: Yic95 <0Luke.Luke0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hant/
Translation: Minetest/ContentDB
2022-10-13 17:38:51 +02:00
Victor K
783611350c Translated using Weblate (Russian)
Currently translated at 96.7% (722 of 746 strings)

Co-authored-by: Victor K <ktrace@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-10-13 17:38:50 +02:00
Matheus Bastos
18daff762f Translated using Weblate (Portuguese (Brazil))
Currently translated at 1.4% (11 of 746 strings)

Added translation using Weblate (Portuguese (Brazil))

Co-authored-by: Matheus Bastos <matheusmcy1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pt_BR/
Translation: Minetest/ContentDB
2022-10-13 17:38:50 +02:00
Văn Chí
662aed13ad Translated using Weblate (Vietnamese)
Currently translated at 93.6% (699 of 746 strings)

Added translation using Weblate (Vietnamese)

Co-authored-by: Văn Chí <chiv8331@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/vi/
Translation: Minetest/ContentDB
2022-10-13 17:38:50 +02:00
Muhammad Rifqi Priyo Susanto
55748a24b1 Translated using Weblate (Indonesian)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-10-13 17:38:49 +02:00
BRN Systems
61128fd054 Translated using Weblate (Slovak)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: BRN Systems <brnsystems123@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sk/
Translation: Minetest/ContentDB
2022-10-13 17:38:49 +02:00
Andrei Stepanov
febe66089c Translated using Weblate (Russian)
Currently translated at 96.6% (721 of 746 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-10-13 17:38:48 +02:00
Andrés Morgensen
aae512469f Translated using Weblate (Spanish)
Currently translated at 67.0% (500 of 746 strings)

Co-authored-by: Andrés Morgensen <expressindia_aeromax@slmail.me>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-10-13 17:38:48 +02:00
Wuzzy
b75f321094 Translated using Weblate (German)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-10-13 17:38:48 +02:00
Fjuro
613394c342 Translated using Weblate (Czech)
Currently translated at 6.7% (50 of 746 strings)

Added translation using Weblate (Czech)

Co-authored-by: Fjuro <fjuro@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/cs/
Translation: Minetest/ContentDB
2022-10-13 17:38:47 +02:00
Valentino
ca1f935b18 Translated using Weblate (Spanish)
Currently translated at 65.2% (487 of 746 strings)

Co-authored-by: Valentino <phamtomwhite@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-10-13 17:38:47 +02:00
Lemente
5083dbf543 Translated using Weblate (French)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Lemente <crafted.by.lemente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-10-13 17:38:47 +02:00
ROllerozxa
66bbb92c1f Translated using Weblate (Swedish)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: ROllerozxa <temporaryemail4meh+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2022-10-13 17:38:46 +02:00
Niklp
2ee720485f Translated using Weblate (German)
Currently translated at 97.3% (726 of 746 strings)

Co-authored-by: Niklp <ngs.nik@outlook.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-10-13 17:38:46 +02:00
Farooq Karimi Zadeh
3f9d9c5d65 Translated using Weblate (Persian)
Currently translated at 11.2% (84 of 746 strings)

Translated using Weblate (Persian)

Currently translated at 8.9% (67 of 746 strings)

Added translation using Weblate (Persian)

Co-authored-by: Farooq Karimi Zadeh <fkz@riseup.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fa/
Translation: Minetest/ContentDB
2022-10-13 17:38:46 +02:00
Cow Boy
5b7a19ff2c Translated using Weblate (Latvian)
Currently translated at 35.7% (267 of 746 strings)

Added translation using Weblate (Latvian)

Co-authored-by: Cow Boy <cowboylv@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/lv/
Translation: Minetest/ContentDB
2022-10-13 17:38:45 +02:00
Tom Schmelzer
3646a44f93 Translated using Weblate (German)
Currently translated at 96.9% (723 of 746 strings)

Co-authored-by: Tom Schmelzer <tom.schmelzer@web.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-10-13 17:38:45 +02:00
Темак
e7335b514a Translated using Weblate (Russian)
Currently translated at 95.8% (715 of 746 strings)

Co-authored-by: Темак <artemkotlubai@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-10-13 17:38:44 +02:00
Gao Tiesuan
89dd4f8d08 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (746 of 746 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (746 of 746 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (746 of 746 strings)

Translated using Weblate (Esperanto)

Currently translated at 3.7% (28 of 746 strings)

Added translation using Weblate (Esperanto)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Gao Tiesuan <yepifoas@666email.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/eo/
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-10-13 17:38:44 +02:00
Pietro Cappuccino
e2fd2fe78b Translated using Weblate (Italian)
Currently translated at 100.0% (746 of 746 strings)

Added translation using Weblate (Italian)

Co-authored-by: Pietro Cappuccino <p.cappuccino@tiscali.it>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/it/
Translation: Minetest/ContentDB
2022-10-13 17:38:43 +02:00
Jakub Z
49aeede0f6 Translated using Weblate (Polish)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Jakub Z <mrkubax10@onet.pl>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2022-10-13 17:38:43 +02:00
Miniontoby
6511c358c8 Translated using Weblate (Dutch)
Currently translated at 13.6% (102 of 746 strings)

Co-authored-by: Miniontoby <tobias.gaarenstroom@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nl/
Translation: Minetest/ContentDB
2022-10-13 17:38:43 +02:00
Maxime Leroy
4b5156f168 Translated using Weblate (French)
Currently translated at 99.7% (744 of 746 strings)

Translated using Weblate (French)

Currently translated at 99.3% (741 of 746 strings)

Co-authored-by: Maxime Leroy <lisacintosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-10-13 17:38:42 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
9b3ca4644a Translated using Weblate (Malay)
Currently translated at 100.0% (746 of 746 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-10-13 17:38:42 +02:00
qwerty123a2
6edc4645d2 Makes it clear that an error occurred. (#390)
Co-authored-by: rubenwardy <rw@rubenwardy.com>
2022-09-25 15:35:23 +01:00
rubenwardy
90710cc8b9 Remove full description from search index 2022-09-14 22:12:43 +01:00
rubenwardy
7bfb183578 Fix broken metapackages -> modnames redirect 2022-09-01 23:14:56 +01:00
rubenwardy
906ec3885a Rename "Metapackage" to "Mod Name" 2022-09-01 22:56:49 +01:00
rubenwardy
f649fa57e6 Move mods above games in metapackage page 2022-09-01 22:30:43 +01:00
rubenwardy
b4208f2dda Reintroduce New Member rank
Fixes #183
2022-08-23 02:31:17 +01:00
rubenwardy
1d36f7d12b Update package config doc 2022-08-19 00:06:54 +01:00
rubenwardy
631ef2b10a Add migration to fix search weights 2022-08-18 16:44:40 +01:00
rubenwardy
bae1df2e8d Rename fileUpload -> file_upload 2022-08-09 17:51:17 +01:00
rubenwardy
0b92d43871 Add missing screenshots page to editor console 2022-08-09 13:48:49 +01:00
rubenwardy
9b7f1e6e88 Add missing screenshots item to user todo 2022-08-09 13:39:58 +01:00
wsor4035
c0447cdcd2 Add Editor crash course (#385)
Co-authored-by: rubenwardy <rw@rubenwardy.com>
2022-08-01 13:02:16 +01:00
rubenwardy
3be937c503 Remove zipgrep min length 2022-07-25 18:05:14 +01:00
rubenwardy
bc4e83d76a Add noindex to report page when given a URL 2022-07-19 23:29:56 +01:00
rubenwardy
20411e6f81 Make reporting the report page a 404 2022-07-19 23:20:13 +01:00
rubenwardy
56298ed57f Add graceful error message for invalid json files 2022-06-25 15:36:59 +01:00
dependabot[bot]
ec8dcf5960 Bump pillow from 9.0.0 to 9.0.1 (#373)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.0.0 to 9.0.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/9.0.0...9.0.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-25 15:31:19 +01:00
rubenwardy
294a968c9f Fix crash when null is used for an array in .cdb.json
Fixes #298
2022-06-25 15:27:40 +01:00
rubenwardy
78717b5eea Move game support updateAll to celery task 2022-06-25 04:47:38 +01:00
rubenwardy
e86eb6a4b8 Update game support blacklist 2022-06-25 04:39:07 +01:00
rubenwardy
a8de369edf Disallow "_game" as a package name 2022-06-25 03:44:25 +01:00
rubenwardy
3b140df508 Improve bot message friendliness 2022-06-25 03:30:12 +01:00
rubenwardy
cef8985d38 Make editor GUI override author game support 2022-06-25 03:20:27 +01:00
rubenwardy
4fdfc49429 Add mod.conf example to supported games page 2022-06-25 03:13:36 +01:00
rubenwardy
bfbab7ae9e Prevent disabling game detection without manually specifying games 2022-06-25 02:51:29 +01:00
rubenwardy
e091bd6fb0 Use db.session for GameSupportResolver in postReleaseCheckUpdate 2022-06-25 02:41:50 +01:00
rubenwardy
4a82172e07 Fix detached instance error in game support 2022-06-25 02:39:36 +01:00
rubenwardy
d9e65f7c3a Add option to disable game support detection 2022-06-25 02:27:51 +01:00
rubenwardy
42841896d1 Add ability for editors to set game support 2022-06-25 01:17:20 +01:00
rubenwardy
7f00b77db3 Add helpful page for game support 2022-06-25 00:32:32 +01:00
rubenwardy
d6887d7b46 Add support for un/"supported_games" in mod.conf 2022-06-24 23:56:27 +01:00
rubenwardy
f22911b4a0 Fix and reenable game support 2022-06-24 23:18:16 +01:00
ROllerozxa
4adb209894 Fix Swedish language code (#380) 2022-06-20 15:38:49 +01:00
rubenwardy
fa55f1d03b Report: Fix URL 2022-06-13 17:12:30 +01:00
rubenwardy
f511771fd4 Remove unused field 2022-06-13 17:11:50 +01:00
rubenwardy
5bd6ab7611 Disable reports from anonymous users 2022-06-13 17:10:14 +01:00
rubenwardy
f5643173a8 Enable Swedish 2022-06-05 18:17:52 +01:00
rubenwardy
8f3ebd182c Update translations 2022-06-05 18:10:53 +01:00
Miniontoby
8235d8390d Translated using Weblate (Dutch)
Currently translated at 14.2% (105 of 735 strings)

Added translation using Weblate (Dutch)

Co-authored-by: Miniontoby <tobias.gaarenstroom@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nl/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Wuzzy
878441406d Translated using Weblate (German)
Currently translated at 100.0% (735 of 735 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Jakub Z
9b0868c255 Translated using Weblate (Polish)
Currently translated at 100.0% (735 of 735 strings)

Co-authored-by: Jakub Z <mrkubax10@onet.pl>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Covarubio
21615ad35c Translated using Weblate (Russian)
Currently translated at 95.9% (705 of 735 strings)

Co-authored-by: Covarubio <6amffsl@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
GT-610
de38bc1557 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (735 of 735 strings)

Co-authored-by: GT-610 <myddz1005@163.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Linerly
2d1411e785 Translated using Weblate (Indonesian)
Currently translated at 100.0% (735 of 735 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Muhammad Rifqi Priyo Susanto
abe44d02fb Translated using Weblate (Indonesian)
Currently translated at 99.8% (734 of 735 strings)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Raquel Fariña Agra
b844c1f8d9 Translated using Weblate (Galician)
Currently translated at 5.9% (44 of 735 strings)

Added translation using Weblate (Galician)

Co-authored-by: Raquel Fariña Agra <raquelagra1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/gl/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
Maxime Leroy
5952b2a34a Translated using Weblate (French)
Currently translated at 99.5% (732 of 735 strings)

Co-authored-by: Maxime Leroy <lisacintosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
3raven
b373cfed96 Translated using Weblate (French)
Currently translated at 99.5% (732 of 735 strings)

Co-authored-by: 3raven <elise_declerck@laposte.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-06-05 19:09:10 +02:00
ROllerozxa
4e03636588 Translated using Weblate (Swedish)
Currently translated at 100.0% (735 of 735 strings)

Translated using Weblate (Swedish)

Currently translated at 83.4% (613 of 735 strings)

Co-authored-by: ROllerozxa <temporaryemail4meh+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2022-06-05 19:09:09 +02:00
Yic95
651174bcb8 Translated using Weblate (Chinese (Traditional))
Currently translated at 8.2% (61 of 735 strings)

Co-authored-by: Yic95 <0Luke.Luke0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hant/
Translation: Minetest/ContentDB
2022-06-05 19:09:09 +02:00
AFCMS
b1f6f1ea99 Translated using Weblate (French)
Currently translated at 98.7% (726 of 735 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-06-05 19:09:09 +02:00
Gao Tiesuan
e91860cadf Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (735 of 735 strings)

Co-authored-by: Gao Tiesuan <yepifoas@666email.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-06-05 19:09:09 +02:00
rubenwardy
6375cf7ae8 Allow modpacks with only a single mod 2022-06-05 17:59:25 +01:00
rubenwardy
2a82c08d8b Update wording on screenshot policy 2022-06-05 01:44:44 +01:00
rubenwardy
0a89849157 Improve username error messages and duplicate messages 2022-06-05 01:00:45 +01:00
rubenwardy
adaf44bc2b Fix package deletion
Fixes #192
2022-06-01 17:30:28 +01:00
rubenwardy
8b5d767d3c Fix issues with deleted packages 2022-06-01 17:30:28 +01:00
rubenwardy
767bc9ef12 Add placeholder.png license 2022-05-20 18:05:45 +01:00
rubenwardy
dfc0af21ee Lower ratelimit 2022-05-09 12:40:01 +01:00
rubenwardy
cfd67dce33 Add ratelimit to package reviews 2022-05-08 16:01:13 +01:00
rubenwardy
0241c51f6f Fix thread template styling issue 2022-04-26 22:50:14 +01:00
rubenwardy
958020b19b Fix new thread private behaviour 2022-04-23 21:44:27 +01:00
rubenwardy
34d66a3d96 Update thread visibility guidance 2022-04-23 21:38:16 +01:00
rubenwardy
0689565ded Fix crash on mentioning user 2022-04-23 21:31:59 +01:00
rubenwardy
8fcbdd0666 Add mentioned users to thread when replying too 2022-04-23 21:20:36 +01:00
rubenwardy
c7d251b206 Add New Thread button to threads list 2022-04-23 21:17:03 +01:00
rubenwardy
f3ff44203c Add is_status_update to thread replies 2022-04-23 20:56:50 +01:00
rubenwardy
ee2311025c Allow watchers to see private threads, add list of users able to see thread 2022-04-23 20:42:58 +01:00
rubenwardy
b8e40b166d Fix comments being lost when two users try to open a package approval thread 2022-04-23 20:22:49 +01:00
rubenwardy
d7dd0274fa Fix long comments being swallowed 2022-04-23 20:05:19 +01:00
rubenwardy
b67e9a8130 Improve screenshots policy formatting 2022-04-23 14:59:54 +01:00
rubenwardy
794d113ce9 Fix list formatting in policy and guidance 2022-04-23 14:39:07 +01:00
rubenwardy
d8336989a8 Add policy on screenshots
Fixes #333
2022-04-23 14:29:28 +01:00
rubenwardy
64acf3047f Fix minor issues with zipgrep 2022-04-09 01:41:38 +01:00
rubenwardy
f137dfa978 Enable Polish, Chinese, and Slovak 2022-04-09 01:41:18 +01:00
ROllerozxa
cb25e5e6d8 Translated using Weblate (Swedish)
Currently translated at 81.7% (601 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
2022-04-06 10:07:02 +02:00
Linerly
acdeaf19cf Translated using Weblate (Indonesian)
Currently translated at 86.2% (634 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
2022-04-06 10:07:02 +02:00
Pexauteau Santander
d30b907a8a Translated using Weblate (Slovak)
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sk/
2022-04-06 10:07:02 +02:00
Pexauteau Santander
76fbe00361 Added translation using Weblate (Slovak) 2022-04-06 10:07:02 +02:00
Andrij Mizyk
6a0c48e3d6 Translated using Weblate (Ukrainian)
Currently translated at 32.2% (237 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
2022-04-06 10:07:02 +02:00
Gao Tiesuan
a0e016a9e5 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
2022-04-06 10:07:02 +02:00
DeadManWalking
15adae088c Translated using Weblate (Greek)
Currently translated at 27.2% (200 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/el/
2022-04-06 10:07:02 +02:00
Arsenicus
0e3ca147a2 Translated using Weblate (Russian)
Currently translated at 95.5% (702 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
2022-04-06 10:07:02 +02:00
Artur Adamczyk
89ca64a7a0 Translated using Weblate (Polish)
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
2022-04-06 10:07:02 +02:00
Jakub Z
7f71996e02 Translated using Weblate (Polish)
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
2022-04-06 10:07:02 +02:00
ROllerozxa
530b5a1c00 Translated using Weblate (Swedish)
Currently translated at 70.4% (518 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
2022-04-06 10:07:02 +02:00
Andrij Mizyk
2e2bf46553 Translated using Weblate (Ukrainian)
Currently translated at 31.1% (229 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
2022-04-06 10:07:02 +02:00
Andrij Mizyk
195f5c12c4 Translated using Weblate (Ukrainian)
Currently translated at 8.9% (66 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
2022-04-06 10:07:02 +02:00
Jakub Z
f6be8e3546 Translated using Weblate (Polish)
Currently translated at 92.6% (681 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
2022-04-06 10:07:02 +02:00
ROllerozxa
c452c5b528 Translated using Weblate (Swedish)
Currently translated at 64.3% (473 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
2022-04-06 10:07:02 +02:00
THANOS SIOURDAKIS
924cdc5d49 Translated using Weblate (Greek)
Currently translated at 25.7% (189 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/el/
2022-04-06 10:07:01 +02:00
Jakub Z
c77bceefa1 Translated using Weblate (Polish)
Currently translated at 89.7% (660 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
2022-04-06 10:07:01 +02:00
ROllerozxa
ab5c2bf384 Translated using Weblate (Swedish)
Currently translated at 40.6% (299 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
2022-04-06 10:07:01 +02:00
THANOS SIOURDAKIS
28b08a7138 Added translation using Weblate (Greek) 2022-04-06 10:07:01 +02:00
Jakub Z
601a38aec2 Translated using Weblate (Polish)
Currently translated at 59.4% (437 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
2022-04-06 10:07:01 +02:00
Jun Nogata
2e5bf618dc Translated using Weblate (Japanese)
Currently translated at 22.5% (166 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ja/
2022-04-06 10:07:01 +02:00
Jakub Z
c0fbf806de Translated using Weblate (Polish)
Currently translated at 52.3% (385 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
2022-04-06 10:07:01 +02:00
ROllerozxa
be73d1b48f Translated using Weblate (Swedish)
Currently translated at 22.5% (166 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
2022-04-06 10:07:01 +02:00
Jakub Z
6e98b55afb Added translation using Weblate (Polish) 2022-04-06 10:07:01 +02:00
ROllerozxa
d180e05117 Added translation using Weblate (Swedish) 2022-04-06 10:07:01 +02:00
MinecraftTAO
625e16d215 Translated using Weblate (Chinese (Simplified))
Currently translated at 56.1% (413 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
2022-04-06 10:07:01 +02:00
Minetest-j45
d2deb46110 Translated using Weblate (Spanish)
Currently translated at 67.0% (493 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
2022-04-06 10:07:01 +02:00
Gao Tiesuan
75b8d191ff Translated using Weblate (Chinese (Simplified))
Currently translated at 54.4% (400 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
2022-04-06 10:07:01 +02:00
debiankaios
f87c292b74 Translated using Weblate (German)
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-04-06 10:07:01 +02:00
Jun Nogata
a01cc55591 Translated using Weblate (Japanese)
Currently translated at 9.9% (73 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ja/
2022-04-06 10:07:01 +02:00
debiankaios
f32ba909b7 Translated using Weblate (German)
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-04-06 10:07:01 +02:00
neinwhal
936852cafb Translated using Weblate (Chinese (Simplified))
Currently translated at 54.5% (401 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
2022-04-06 10:07:01 +02:00
Nikita Epifanov
d6005f9543 Translated using Weblate (Russian)
Currently translated at 95.3% (701 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
2022-04-06 10:07:01 +02:00
Maxime Leroy
5418abd820 Translated using Weblate (French)
Currently translated at 98.7% (726 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
2022-04-06 10:07:01 +02:00
neinwhal
90bff5fd0b Translated using Weblate (Chinese (Simplified))
Currently translated at 40.2% (296 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
2022-04-06 10:07:01 +02:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
b68c9ff64f Translated using Weblate (Malay)
Currently translated at 100.0% (735 of 735 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
2022-04-06 10:07:01 +02:00
Y.W
9b9234929b Translated using Weblate (Chinese (Simplified))
Currently translated at 31.1% (229 of 735 strings)

Co-authored-by: Y.W <y5nw@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-04-06 10:07:01 +02:00
neinwhal
6d9f2e8b8c Translated using Weblate (Chinese (Simplified))
Currently translated at 31.1% (229 of 735 strings)

Co-authored-by: neinwhal <fishyWET@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-04-06 10:07:01 +02:00
rubenwardy
2a1672544f Disable game support in UI for now 2022-04-02 20:12:49 +01:00
rubenwardy
8f622ba5c9 Add ability to search for text in all packages 2022-04-02 20:09:59 +01:00
rubenwardy
e42f6b2cfa Add 500 page 2022-02-15 15:26:03 +00:00
rubenwardy
9200d7becd Add missing package attribution to remove() 2022-02-14 01:21:24 +00:00
rubenwardy
a70454cf1f Extend number of audit log entries shown on /todo/ 2022-02-14 01:19:32 +00:00
rubenwardy
07db1943fb Add audit log to editor todo 2022-02-14 01:18:03 +00:00
rubenwardy
3d35f6507a Add temp banning and ban messages 2022-02-13 10:41:02 +00:00
rubenwardy
7a650eb1e4 Fix unfulfilled dependency checker 2022-02-13 09:53:12 +00:00
rubenwardy
d7c765c972 Disable game support updating 2022-02-13 09:13:53 +00:00
rubenwardy
173261a69f Emails: Fix crash due to missing connection 2022-02-13 09:03:50 +00:00
rubenwardy
5c5608680b Add validation for modpack names with one mod 2022-02-12 18:25:46 +00:00
rubenwardy
09eea443cf Update translations 2022-02-12 14:58:49 +00:00
Wuzzy
d471720541 Translated using Weblate (German)
Currently translated at 100.0% (727 of 727 strings)

Co-authored-by: Wuzzy <almikes@aol.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-02-12 15:57:06 +01:00
Maxime Leroy
17270000eb Translated using Weblate (French)
Currently translated at 98.7% (718 of 727 strings)

Co-authored-by: Maxime Leroy <lisacintosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-02-12 15:57:06 +01:00
AFCMS
154cc97603 Translated using Weblate (French)
Currently translated at 98.7% (718 of 727 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-02-12 15:57:06 +01:00
gemmaro
3b6f243940 Translated using Weblate (Japanese)
Currently translated at 6.7% (49 of 727 strings)

Added translation using Weblate (Japanese)

Co-authored-by: gemmaro <gemmaro.dev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ja/
Translation: Minetest/ContentDB
2022-02-12 15:57:06 +01:00
Maxime Leroy
0a149ed440 Translated using Weblate (French)
Currently translated at 97.2% (707 of 727 strings)

Co-authored-by: Maxime Leroy <lisacintosh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-02-12 15:57:06 +01:00
rubenwardy
595b86df6c Add Precedence: Bulk header to bulk emails 2022-02-12 14:13:57 +00:00
rubenwardy
770d17b42a Use persistent SMTP connection for bulk emails, add List-Unsubscribe header 2022-02-12 14:06:04 +00:00
rubenwardy
8ad066409c Fix notification digest issue 2022-02-11 17:17:11 +00:00
rubenwardy
4ac8949c3a Disable Celery concurrency, to see if it fixes the session issue 2022-02-10 19:24:37 +00:00
rubenwardy
83b2cf48d4 Fix audit crash 2022-02-10 02:20:12 +00:00
rubenwardy
2bbb117eac Small fixes 2022-02-09 19:14:08 +00:00
rubenwardy
f61112a8d7 Add ability for moderators to convert reviews into threads 2022-02-09 12:47:36 +00:00
rubenwardy
3566b030c5 Attempt to fix package session issues 2022-02-08 10:40:20 +00:00
rubenwardy
2d54fe4ed7 Fix issues with Package sets by adding a PackageSet class 2022-02-07 18:10:43 +00:00
rubenwardy
7fdd2cc7c9 Fix small typos in dev_intro 2022-02-06 23:08:58 +00:00
rubenwardy
81a85cbbe5 Update dev intro 2022-02-06 22:58:13 +00:00
rubenwardy
4902436b6b Add start of developer's intro 2022-02-06 22:07:22 +00:00
rubenwardy
b82bcb0af9 Disable 'Submit for Approval' when release is broken 2022-02-04 13:36:26 +00:00
rubenwardy
eeea5d004a Fix "specific" typo 2022-02-03 17:04:09 +00:00
rubenwardy
97ee0a9f85 Fix crash on game support update 2022-02-03 17:02:01 +00:00
rubenwardy
958f92fd63 Add single API to upload cover image 2022-02-02 01:29:14 +00:00
rubenwardy
dfef268b05 Fix docs on cover-image 2022-02-02 01:21:33 +00:00
rubenwardy
e7d2f09eb4 Add cover_image to screenshot response in API 2022-02-02 01:20:11 +00:00
rubenwardy
5bb9012655 Add game support to API 2022-02-02 01:11:44 +00:00
rubenwardy
a291b2cd6f Add cover_image API
Fixes #360
2022-02-02 01:08:01 +00:00
rubenwardy
ead077fb92 Metapackages: Split up "provided" into multiple subheadings 2022-02-02 00:56:56 +00:00
rubenwardy
1c9d6ac865 Rename "Supported Games" to "Compatible Games" 2022-02-02 00:29:16 +00:00
rubenwardy
d098ee9dff Run game support update_all on unapproved mods too 2022-02-02 00:25:59 +00:00
rubenwardy
b8d95dd222 Add disclaimer to Supported Games section on package pages 2022-02-01 21:54:11 +00:00
rubenwardy
7c93db95a3 Add community hub to list game content 2022-02-01 21:22:28 +00:00
rubenwardy
d529634b7f Add game support detection
Part of #232
2022-02-01 20:56:43 +00:00
Y.W
765b5603c1 Translated using Weblate (Chinese (Simplified))
Currently translated at 28.1% (205 of 727 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 28.1% (205 of 727 strings)

Co-authored-by: Y.W <y5nw@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-02-01 15:55:32 +01:00
Gao Tiesuan
eec39a3fc5 Translated using Weblate (Chinese (Simplified))
Currently translated at 28.1% (205 of 727 strings)

Co-authored-by: Gao Tiesuan <yepifoas@666email.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-02-01 15:55:32 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
72f66530aa Translated using Weblate (Malay)
Currently translated at 100.0% (727 of 727 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-02-01 15:55:32 +01:00
Nikita Epifanov
99ee1cfc7e Translated using Weblate (Russian)
Currently translated at 95.4% (694 of 727 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-02-01 15:55:32 +01:00
rubenwardy
f8e82b63e3 Revert "Limit visibility of unapproved packages to maintainers and approvers" and "Fix 404 on packages when not logged in"
This reverts commit 85a178d90e.
This reverts commit 727db52c19.
2022-02-01 14:54:09 +00:00
rubenwardy
afdf06b3f6 Remove confusing min/max version text 2022-01-30 19:27:21 +00:00
rubenwardy
d21a86587f Fix content_flags documentation 2022-01-30 19:24:33 +00:00
rubenwardy
38071165d1 Add welcome dialog API 2022-01-30 03:35:32 +00:00
rubenwardy
1cfc152d3b Fix crash on needs tags in user todo 2022-01-29 20:23:15 +00:00
rubenwardy
2db2f61992 Enable Russian language 2022-01-29 20:23:00 +00:00
Balázs Kovács
4543f6ca39 Translated using Weblate (Hungarian)
Currently translated at 18.9% (138 of 727 strings)

Co-authored-by: Balázs Kovács <kovacs.balazs.ktk@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
Nikita Epifanov
f8d518300d Translated using Weblate (Russian)
Currently translated at 95.4% (694 of 727 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
Andrij Mizyk
347e214944 Translated using Weblate (Ukrainian)
Currently translated at 6.1% (45 of 727 strings)

Added translation using Weblate (Ukrainian)

Co-authored-by: Andrij Mizyk <andmizyk@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
Mikitko
99b4d8e084 Translated using Weblate (Russian)
Currently translated at 93.9% (683 of 727 strings)

Co-authored-by: Mikitko <rudzik8@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
Nikita Epifanov
313cab6b2d Translated using Weblate (Russian)
Currently translated at 93.8% (682 of 727 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
debiankaios
494559cfd7 Translated using Weblate (German)
Currently translated at 100.0% (727 of 727 strings)

Translated using Weblate (German)

Currently translated at 97.7% (711 of 727 strings)

Co-authored-by: debiankaios <info@debiankaios.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
e3326aa0f1 Translated using Weblate (Malay)
Currently translated at 100.0% (727 of 727 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-29 20:51:41 +01:00
rubenwardy
bdd3ab4360 Add is_protected and views to Tags API 2022-01-29 19:26:55 +00:00
rubenwardy
4f9ec2e8a4 Fix attempting to set protected tag in API dropping other tags 2022-01-29 19:25:02 +00:00
rubenwardy
14fd30c4f4 Fix attempt to call module list 2022-01-27 19:20:45 +00:00
rubenwardy
a7103b5b35 Update Redis 2022-01-27 18:59:37 +00:00
rubenwardy
f6ce676e7e Add migration to fix fulltext search 2022-01-27 18:54:04 +00:00
rubenwardy
c2fbf7603a Update to Python 3.10 2022-01-27 18:44:00 +00:00
rubenwardy
c3a4ea239c Update dependencies 2022-01-27 18:21:47 +00:00
rubenwardy
e2708933d3 Clean up admin blueprint 2022-01-26 19:12:48 +00:00
rubenwardy
cb2d9d4b07 Add note about bug reports to report page 2022-01-26 18:16:47 +00:00
rubenwardy
1ba70226b8 Update translations 2022-01-26 03:09:18 +00:00
rubenwardy
d08710684d Add screenshot resolution checking 2022-01-26 03:08:00 +00:00
rubenwardy
625e4cf9ee Allow removing video_url 2022-01-25 23:32:51 +00:00
rubenwardy
c8b310ebdb Fix 2022-01-25 23:28:38 +00:00
rubenwardy
d971dd6700 Update translations 2022-01-25 22:37:51 +00:00
rubenwardy
e20863a7e1 Support links to video hosts other than YouTube 2022-01-25 22:14:06 +00:00
rubenwardy
8f2a87e5ed Harden video_embed.js, store URL in data-src 2022-01-25 21:52:46 +00:00
rubenwardy
ae88360e20 Fix unsubscribe crash 2022-01-25 21:38:02 +00:00
rubenwardy
7d97c2a27b Fix notification digest crash 2022-01-25 21:37:54 +00:00
rubenwardy
02b7d55c2d Add remind_video_url() admin action 2022-01-25 21:37:35 +00:00
rubenwardy
55b5893cce Update translations 2022-01-25 21:04:39 +00:00
rubenwardy
1018e1c29c Add support for YouTube video embeds
Fixes #75
2022-01-25 21:00:45 +00:00
rubenwardy
e5a4161e76 Fix crash due to typo whilst commiting 2022-01-25 18:09:32 +00:00
rubenwardy
a3f437e482 Redesign download button 2022-01-25 17:27:40 +00:00
rubenwardy
9fcbbdc472 Refactor get_locale() to be cleaner 2022-01-25 01:35:57 +00:00
rubenwardy
7aac597216 Change User.locale default to None 2022-01-25 01:33:13 +00:00
rubenwardy
95b3c66366 Copy locale to User model 2022-01-25 01:22:47 +00:00
rubenwardy
3b354de2fc Lower email sending rate limit again 2022-01-23 18:40:06 +00:00
rubenwardy
411392eb76 Lower email sending rate limit 2022-01-23 18:27:46 +00:00
rubenwardy
15c3e4edec Raise email sending rate limit 2022-01-23 18:25:58 +00:00
rubenwardy
fa0572ae44 Move preferred language/locale point in privacy policy 2022-01-23 18:10:25 +00:00
rubenwardy
ade75ace49 Update privacy policy
- Add preferred language
- Add admin bulk email sending
- Update location
2022-01-23 17:56:54 +00:00
Hugo Locurcio
56539bb369 Fix missing space before "and" in package list (#357) 2022-01-23 17:21:29 +00:00
Y.W
1c63bf0beb Translated using Weblate (Chinese (Simplified))
Currently translated at 21.4% (152 of 708 strings)

Co-authored-by: Y.W <y5nw@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-01-23 18:15:15 +01:00
pampogo kiraly
b10949d8cd Translated using Weblate (Hungarian)
Currently translated at 15.9% (113 of 708 strings)

Co-authored-by: pampogo kiraly <pampogo.kiraly@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Minetest/ContentDB
2022-01-23 18:15:15 +01:00
debiankaios
853cc3ff6e Translated using Weblate (German)
Currently translated at 100.0% (708 of 708 strings)

Co-authored-by: debiankaios <info@debiankaios.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-23 18:15:15 +01:00
rubenwardy
a0cc6eb997 Translated using Weblate (Spanish)
Currently translated at 64.4% (456 of 708 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-01-23 18:15:15 +01:00
J. Lavoie
8b18e6f86d Translated using Weblate (French)
Currently translated at 89.2% (632 of 708 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-23 18:15:15 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
68e4d98bc5 Translated using Weblate (Malay)
Currently translated at 100.0% (708 of 708 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-23 18:15:15 +01:00
rubenwardy
390bf7a657 Fix two crashes due to translations 2022-01-23 17:14:03 +00:00
rubenwardy
deb5c02ce6 Fix sending error email on email ratelimit 2022-01-22 22:11:36 +00:00
rubenwardy
004c5cd383 Allow translating emails
Fixes #350
2022-01-22 21:23:01 +00:00
rubenwardy
7b4254da58 Add locale to user model 2022-01-22 20:47:43 +00:00
rubenwardy
d4903f04f1 Update translations 2022-01-22 20:29:17 +00:00
debiankaios
f2b544ae68 Translated using Weblate (German)
Currently translated at 100.0% (697 of 697 strings)

Co-authored-by: debiankaios <info@debiankaios.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-22 21:28:02 +01:00
Lemente
ec91295677 Translated using Weblate (French)
Currently translated at 89.8% (626 of 697 strings)

Co-authored-by: Lemente <crafted.by.lemente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-22 21:28:02 +01:00
rubenwardy
4943fbd776 Translated using Weblate (Spanish)
Currently translated at 64.5% (450 of 697 strings)

Translated using Weblate (French)

Currently translated at 89.8% (626 of 697 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-22 21:28:01 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
2478df8c0d Translated using Weblate (Malay)
Currently translated at 100.0% (697 of 697 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-22 21:28:01 +01:00
rubenwardy
85a178d90e Fix 404 on packages when not logged in 2022-01-22 00:04:09 +00:00
rubenwardy
a48c0fb2b4 Fix unapproved content showing in "related"
Fixes #253
2022-01-21 22:49:12 +00:00
rubenwardy
3c944cbd72 Add moderator tools page
Fixes #341
2022-01-21 22:33:22 +00:00
rubenwardy
727db52c19 Limit visibility of unapproved packages to maintainers and approvers
Fixes #338
2022-01-21 21:48:15 +00:00
rubenwardy
80d534a53f Fix crash on invalid username in forums import 2022-01-21 21:20:04 +00:00
rubenwardy
fe2d08c395 Allow moderators to edit ThreadReplies 2022-01-21 14:30:12 +00:00
rubenwardy
97e2e1c16e Add abs_url_samesite 2022-01-21 14:23:27 +00:00
rubenwardy
a32b63f932 Use relative URLs in report, to ensure correct links 2022-01-21 14:17:50 +00:00
rubenwardy
e0421c1e57 Disable WIP markdown editor toolbar option 2022-01-21 13:59:13 +00:00
rubenwardy
f457f7f5d7 Fix accidental new line in thread <title> 2022-01-21 13:15:07 +00:00
rubenwardy
3ac2d937d7 Fix grammatically weird <title> on thread pages 2022-01-21 13:14:02 +00:00
rubenwardy
45eca10859 Fix "staff" typo 2022-01-21 02:53:46 +00:00
rubenwardy
38aa8fa03a Translated using Weblate (Malay)
Currently translated at 98.1% (684 of 697 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-21 03:52:57 +01:00
rubenwardy
11036b113b Update translations 2022-01-21 01:25:01 +00:00
Wuzzy
f5893676eb Translated using Weblate (German)
Currently translated at 100.0% (686 of 686 strings)

Co-authored-by: Wuzzy <almikes@aol.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-21 02:23:50 +01:00
Lemente
d7b5b1eedb Translated using Weblate (French)
Currently translated at 90.6% (622 of 686 strings)

Co-authored-by: Lemente <crafted.by.lemente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-21 02:23:50 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
e44ec8720d Translated using Weblate (Malay)
Currently translated at 100.0% (686 of 686 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-21 02:23:49 +01:00
Gao Tiesuan
6b592053f1 Translated using Weblate (Chinese (Simplified))
Currently translated at 13.7% (94 of 686 strings)

Added translation using Weblate (Chinese (Simplified))

Added translation using Weblate (Chinese (Literary))

Co-authored-by: Gao Tiesuan <yepifoas@666email.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hans/
Translation: Minetest/ContentDB
2022-01-21 02:23:49 +01:00
rubenwardy
ef28fa026e Fix crash due to misnamed path argument in abs_url_for 2022-01-20 23:40:47 +00:00
rubenwardy
e1a86f3be0 Remove report link on help pages 2022-01-20 23:37:17 +00:00
rubenwardy
7f5656df08 Add reporting system
Fixes #12
2022-01-20 23:30:56 +00:00
rubenwardy
a47e6e8998 Move /email_sent/ to flask endpoint, to allow translation 2022-01-20 21:55:16 +00:00
rubenwardy
b6fe0466ca Increase locale cookie expiry to 5 years 2022-01-20 20:27:38 +00:00
rubenwardy
9ea4ee3449 Fix crash due to mistake in tags.html 2022-01-20 14:49:46 +00:00
rubenwardy
d9a6127c35 Fix inconsistent capitalisation of GitHub and Javascript
Fixes #353
2022-01-20 01:29:02 +00:00
rubenwardy
3ad003140f Fix inconsistent naming of reviews vs approvals
Fixes #354
2022-01-20 01:18:13 +00:00
Wuzzy
d7152485bb Translated using Weblate (German)
Currently translated at 100.0% (684 of 684 strings)

Co-authored-by: Wuzzy <almikes@aol.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-20 01:48:59 +01:00
AFCMS
0f17dbc15d Translated using Weblate (French)
Currently translated at 85.9% (588 of 684 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-19 08:54:16 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
e1cc4bbdf0 Translated using Weblate (Malay)
Currently translated at 100.0% (684 of 684 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-19 08:54:15 +01:00
rubenwardy
a325d2c2cd Fix crash due to missing import in screenshots.py 2022-01-17 22:32:09 +00:00
rubenwardy
da1ae4c270 Upgrade PostgreSQL to v14 2022-01-17 18:15:47 +00:00
rubenwardy
9cc79d9fa5 Fix mention linkifying emails 2022-01-17 18:15:23 +00:00
rubenwardy
a09f11d110 Show profile picture on package page
Fixes #327
2022-01-17 18:15:08 +00:00
rubenwardy
6e93e6d777 Fix optional_depends not being validated 2022-01-17 15:34:41 +00:00
Joaquín Villalba
b05bd78e20 Translated using Weblate (Spanish)
Currently translated at 40.7% (279 of 684 strings)

Co-authored-by: Joaquín Villalba <joaco-mono@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
waxtatect
5a27e1a03b Translated using Weblate (French)
Currently translated at 85.3% (584 of 684 strings)

Co-authored-by: waxtatect <piero@live.ie>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
Lemente
0f3628f2a4 Translated using Weblate (French)
Currently translated at 85.3% (584 of 684 strings)

Co-authored-by: Lemente <crafted.by.lemente@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
Mehmet Ali
b3fcf4d1c2 Translated using Weblate (Turkish)
Currently translated at 3.3% (23 of 684 strings)

Added translation using Weblate (Turkish)

Co-authored-by: Mehmet Ali <2045uuttb@relay.firefox.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/tr/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
debiankaios
01a9afdd9d Translated using Weblate (German)
Currently translated at 100.0% (684 of 684 strings)

Co-authored-by: debiankaios <info@debiankaios.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
Yiu Man Ho
3ad1ebdb7b Translated using Weblate (Chinese (Traditional))
Currently translated at 7.8% (54 of 684 strings)

Added translation using Weblate (Chinese (Traditional))

Co-authored-by: Yiu Man Ho <yiufamily.hh@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/zh_Hant/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
903d567e3c Translated using Weblate (Malay)
Currently translated at 100.0% (684 of 684 strings)

Translated using Weblate (Malay)

Currently translated at 95.4% (653 of 684 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-17 16:22:48 +01:00
rubenwardy
6a4bf7129d Add user and package mentions 2022-01-17 15:06:03 +00:00
rubenwardy
e02c014890 Fix format instead of variable in gettext 2022-01-14 18:28:22 +00:00
rubenwardy
beb916d521 Fix some untranslatable text 2022-01-14 18:25:33 +00:00
rubenwardy
f3856b5db5 Improve donation panel on package pages 2022-01-14 17:52:33 +00:00
rubenwardy
8af2942097 Enable German translation 2022-01-14 15:28:46 +00:00
pampogo kiraly
dcfdf299e3 Translated using Weblate (Hungarian)
Currently translated at 17.5% (115 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
2022-01-13 23:35:33 +01:00
debiankaios
ca139bab54 Translated using Weblate (German)
Currently translated at 100.0% (656 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-13 23:35:32 +01:00
pampogo kiraly
80b63d3d24 Added translation using Weblate (Hungarian) 2022-01-13 21:25:46 +01:00
cx384
c550f2395f Translated using Weblate (German)
Currently translated at 99.8% (655 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-13 18:43:58 +01:00
debiankaios
f7040ecc8f Translated using Weblate (German)
Currently translated at 99.8% (655 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-13 18:43:54 +01:00
debiankaios
8bd0fe0662 Translated using Weblate (German)
Currently translated at 91.9% (603 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-13 18:31:11 +01:00
cx384
e5cb738252 Translated using Weblate (German)
Currently translated at 91.9% (603 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-13 18:31:08 +01:00
debiankaios
c016060553 Translated using Weblate (German)
Currently translated at 81.5% (535 of 656 strings)

Translated using Weblate (German)

Currently translated at 69.3% (455 of 656 strings)

Translated using Weblate (German)

Currently translated at 68.1% (447 of 656 strings)

Co-authored-by: debiankaios <info@debiankaios.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-13 18:05:16 +01:00
cx384
9e59be7d65 Translated using Weblate (German)
Currently translated at 81.5% (535 of 656 strings)

Translated using Weblate (German)

Currently translated at 69.3% (455 of 656 strings)

Translated using Weblate (German)

Currently translated at 68.1% (447 of 656 strings)

Translated using Weblate (German)

Currently translated at 65.0% (427 of 656 strings)

Co-authored-by: cx384 <muelladresse84@web.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-13 18:05:16 +01:00
Joaquín Villalba
5e2fc9155c Translated using Weblate (Spanish)
Currently translated at 34.7% (228 of 656 strings)

Co-authored-by: Joaquín Villalba <joaco-mono@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-01-13 18:05:15 +01:00
rubenwardy
543499560d Fix crash in package_approval.html translation 2022-01-13 03:09:22 +00:00
cx384
cff7964831 Translated using Weblate (German)
Currently translated at 59.2% (389 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-12 21:50:47 +01:00
Muhammad Rifqi Priyo Susanto
9ad8a7f420 Translated using Weblate (Indonesian)
Currently translated at 100.0% (656 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
2022-01-12 21:50:47 +01:00
Minetest-j45
2fab6cd6ae Translated using Weblate (Spanish)
Currently translated at 29.1% (191 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
2022-01-12 21:50:47 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
ef192dcaee Translated using Weblate (Malay)
Currently translated at 100.0% (656 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
2022-01-12 21:50:47 +01:00
Buckaroo Banzai
9d817c71e3 Translated using Weblate (German)
Currently translated at 56.0% (368 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-12 21:50:47 +01:00
debiankaios
0bee59d7c3 Translated using Weblate (German)
Currently translated at 56.0% (368 of 656 strings)

Translation: Minetest/ContentDB
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
2022-01-12 21:50:46 +01:00
activivan
76cd2a6786 Translated using Weblate (German)
Currently translated at 52.7% (346 of 656 strings)

Co-authored-by: activivan <activivan@mail.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
Buckaroo Banzai
04bba2e135 Translated using Weblate (German)
Currently translated at 52.7% (346 of 656 strings)

Co-authored-by: Buckaroo Banzai <postmaster@rudin.io>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
Linerly
dab25f6789 Translated using Weblate (Indonesian)
Currently translated at 100.0% (656 of 656 strings)

Co-authored-by: Linerly <linerly@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
Joaquín Villalba
c725451206 Translated using Weblate (Spanish)
Currently translated at 26.6% (175 of 656 strings)

Co-authored-by: Joaquín Villalba <joaco-mono@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
Imre Kristoffer Eilertsen
617c7900ff Translated using Weblate (Norwegian Bokmål)
Currently translated at 2.8% (19 of 656 strings)

Co-authored-by: Imre Kristoffer Eilertsen <imreeil42@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nb_NO/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
waxtatect
e8dea0d69d Translated using Weblate (French)
Currently translated at 68.2% (448 of 656 strings)

Co-authored-by: waxtatect <piero@live.ie>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
AFCMS
0fe71ec86f Translated using Weblate (French)
Currently translated at 68.2% (448 of 656 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
Mikitko
5ac69c5051 Translated using Weblate (Russian)
Currently translated at 28.9% (190 of 656 strings)

Added translation using Weblate (Russian)

Co-authored-by: Mikitko <rudzik8@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
debiankaios
0518aa8650 Translated using Weblate (German)
Currently translated at 40.2% (264 of 656 strings)

Added translation using Weblate (German)

Co-authored-by: debiankaios <info@debiankaios.de>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Minetest/ContentDB
2022-01-12 21:50:46 +01:00
rubenwardy
7ffecbb318 Fix key package API returning display_name rather than username 2022-01-12 20:50:11 +00:00
rubenwardy
e0a92c6455 Add /api/dependencies/ 2022-01-12 17:08:18 +00:00
rubenwardy
3af5fccd61 Fix crash due to missing request.endpoint 2022-01-12 16:47:39 +00:00
rubenwardy
fbadb05037 Fix crash due to missing view_args 2022-01-11 17:06:24 +00:00
rubenwardy
416daa868b Add hint to reason in package removal 2022-01-09 21:14:59 +00:00
rubenwardy
34ccd76b0c Add reason to package removal 2022-01-09 21:12:29 +00:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
db14b3f4ef Translated using Weblate (Malay)
Currently translated at 100.0% (656 of 656 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-09 02:01:55 +01:00
rubenwardy
ca0823c460 Fix optional dependencies being presolved 2022-01-08 22:29:02 +00:00
rubenwardy
33d9ab4b86 Fix duplicated messages in translation template 2022-01-08 20:15:10 +00:00
rubenwardy
ceed91b6d7 Translated using Weblate (Indonesian)
Currently translated at 99.0% (651 of 657 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-01-08 20:11:15 +00:00
Minetest-j45
aa8409b0be Translated using Weblate (Spanish)
Currently translated at 13.3% (88 of 657 strings)

Co-authored-by: Minetest-j45 <janscheresmonesma@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-01-08 20:11:07 +00:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
5f45c31240 Translated using Weblate (Malay)
Currently translated at 85.0% (559 of 657 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-08 20:10:52 +00:00
rubenwardy
71b4a0416f Fix remaining translation parameters 2022-01-08 17:57:05 +00:00
rubenwardy
00bb8a486d Fix translation parameters 2022-01-08 16:57:43 +00:00
rubenwardy
3a0a3c5325 Enable Indonesian 2022-01-08 16:40:51 +00:00
Allan Nordhøy
1e839f731a Translated using Weblate (Norwegian Bokmål)
Currently translated at 2.7% (18 of 657 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nb_NO/
Translation: Minetest/ContentDB
2022-01-08 17:39:21 +01:00
AFCMS
97ae05b864 Translated using Weblate (French)
Currently translated at 49.3% (324 of 657 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-08 17:39:20 +01:00
rubenwardy
48a8a45140 Translated using Weblate (Malay)
Currently translated at 66.3% (436 of 657 strings)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-08 17:39:20 +01:00
Muhammad Rifqi Priyo Susanto
88da170bb0 Translated using Weblate (Indonesian)
Currently translated at 98.9% (650 of 657 strings)

Translated using Weblate (Indonesian)

Currently translated at 57.2% (376 of 657 strings)

Added translation using Weblate (Indonesian)

Co-authored-by: Muhammad Rifqi Priyo Susanto <muhammadrifqipriyosusanto@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/id/
Translation: Minetest/ContentDB
2022-01-08 17:39:19 +01:00
rubenwardy
ec6f16c229 Fix crash when sending emails 2022-01-08 02:42:48 +00:00
rubenwardy
db4e3dabb7 Fix crash due to misspelled gettext arg 2022-01-08 02:35:17 +00:00
rubenwardy
b2a72da219 Enable Melay translation 2022-01-08 02:34:19 +00:00
rubenwardy
cf0a69a702 Update translation templates 2022-01-08 02:20:16 +00:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
572d6bd9ea Translated using Weblate (Malay)
Currently translated at 68.5% (435 of 635 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-08 03:19:22 +01:00
rubenwardy
574339f935 Fix crash in template 2022-01-08 01:49:13 +00:00
rubenwardy
baa8c871b0 Fix crash on unimported gettext 2022-01-08 01:47:25 +00:00
rubenwardy
b62bdb016a Fix placeholders in translations 2022-01-07 23:31:32 +00:00
rubenwardy
63c6ccfee9 Update translation templates 2022-01-07 23:27:54 +00:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
db24385f40 Translated using Weblate (Malay)
Currently translated at 11.3% (52 of 457 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-08 00:27:29 +01:00
rubenwardy
c5a6ae3035 Allow translating text in templates 2022-01-07 23:27:00 +00:00
rubenwardy
c8b0f9e6ce Allow translating text in blueprints 2022-01-07 22:11:12 +00:00
rubenwardy
bd59fa8ef3 Update translations 2022-01-07 21:59:18 +00:00
AFCMS
503ae701ae Translated using Weblate (French)
Currently translated at 80.8% (275 of 340 strings)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-07 22:58:51 +01:00
Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi
a7089b26e7 Translated using Weblate (Malay)
Currently translated at 5.5% (19 of 340 strings)

Co-authored-by: Yaya - Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi <translation@mnh48.moe>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-07 22:58:51 +01:00
rubenwardy
1b26acaaae Allow translating form labels 2022-01-07 21:55:33 +00:00
rubenwardy
dcd7e31738 Allow translating flash messages 2022-01-07 21:46:16 +00:00
rubenwardy
c4dd380218 Allow translating package form 2022-01-07 21:18:34 +00:00
rubenwardy
ad05ba1ee8 Fix search bad text bug 2022-01-07 21:15:48 +00:00
rubenwardy
a175162186 Remove flask-menu, make navbar translatable 2022-01-07 21:09:09 +00:00
rubenwardy
b40bc8c20d Tag index.html and base.html, test updating translation templates 2022-01-07 20:58:32 +00:00
rubenwardy
44b02cfb4e Add translation tagging call for help 2022-01-07 20:27:49 +00:00
rubenwardy
9de4ad5cb3 Fix crash 2022-01-07 19:46:49 +00:00
rubenwardy
482c9e5905 Enable translation support 2022-01-07 19:42:52 +00:00
Joaquín Villalba
eba1626f2e Translated using Weblate (Spanish)
Currently translated at 1.2% (4 of 321 strings)

Added translation using Weblate (Spanish)

Co-authored-by: Joaquín Villalba <joaco-mono@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/es/
Translation: Minetest/ContentDB
2022-01-06 23:17:40 +01:00
J. Lavoie
f5f6671d48 Translated using Weblate (French)
Currently translated at 72.8% (234 of 321 strings)

Translated using Weblate (French)

Currently translated at 69.4% (223 of 321 strings)

Translated using Weblate (French)

Currently translated at 64.4% (207 of 321 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-06 23:17:39 +01:00
Allan Nordhøy
868bbed290 Translated using Weblate (Norwegian Bokmål)
Currently translated at 4.6% (15 of 321 strings)

Added translation using Weblate (Norwegian Bokmål)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nb_NO/
Translation: Minetest/ContentDB
2022-01-06 23:17:39 +01:00
AFCMS
10846d481c Translated using Weblate (French)
Currently translated at 79.4% (255 of 321 strings)

Translated using Weblate (French)

Currently translated at 69.4% (223 of 321 strings)

Translated using Weblate (French)

Currently translated at 64.4% (207 of 321 strings)

Translated using Weblate (French)

Currently translated at 28.3% (91 of 321 strings)

Added translation using Weblate (French)

Co-authored-by: AFCMS <afcm.contact@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2022-01-06 23:17:38 +01:00
rubenwardy
757c1f8c45 Translated using Weblate (Malay)
Currently translated at 0.0% (0 of 321 strings)

Translated using Weblate (Malay)

Currently translated at 0.0% (0 of 321 strings)

Added translation using Weblate (Malay)

Co-authored-by: rubenwardy <rw@rubenwardy.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ms/
Translation: Minetest/ContentDB
2022-01-06 23:17:38 +01:00
rubenwardy
d8f164ffc1 Fix thread typo when sending webhook 2022-01-05 00:27:48 +00:00
rubenwardy
324cbe9efc Add translation template 2022-01-04 19:08:03 +00:00
rubenwardy
e4ea44aa5b Allow null dev_state 2022-01-04 13:08:53 +00:00
rubenwardy
122e1a4677 Rename replies to comments 2022-01-03 01:44:12 +00:00
rubenwardy
933d8ebfe7 Add all user replies page 2022-01-03 01:41:50 +00:00
rubenwardy
8f4e214c52 Add review votes page 2022-01-01 22:40:27 +00:00
rubenwardy
e346587111 Remove thumbs from helpful votes 2022-01-01 21:45:03 +00:00
rubenwardy
4dfb35a57b Remove gamejam banner 2022-01-01 02:27:49 +00:00
rubenwardy
d16666c0f8 Update homepage gamejam notice 2021-12-23 12:07:38 +00:00
rubenwardy
4d37f53a04 Fix crash due to null dev_state 2021-12-23 11:58:39 +00:00
rubenwardy
e3ed5fbc58 Show WIP packages in client, add missing keys to package 2021-12-23 11:56:03 +00:00
rubenwardy
2e7d4277e1 Allow editors to restore packages 2021-12-20 23:56:44 +00:00
rubenwardy
5932ac3c7c Clean up hide query argument in QueryBuilder 2021-12-20 21:24:06 +00:00
rubenwardy
5d32d7922f Add Maintenance State field
Fixes #160
2021-12-20 21:07:12 +00:00
rubenwardy
a800685947 Improve webhook error on create events 2021-12-12 17:20:23 +00:00
rubenwardy
7aca5a54dc Add more to reviews API response 2021-11-26 14:56:01 +00:00
rubenwardy
e1cd2ceb1d Improve reviews API docs 2021-11-26 14:42:21 +00:00
rubenwardy
c46cca519a Fix paginated API response key 2021-11-26 14:34:37 +00:00
rubenwardy
da41fb5738 Improve reviews API 2021-11-26 14:33:17 +00:00
rubenwardy
bd25a8d601 Fix limit arg in QueryBuilder 2021-11-25 15:51:41 +00:00
rubenwardy
c13b13268b Update topic queries API doc 2021-11-25 15:48:23 +00:00
rubenwardy
10cfbc6e45 Fix typo in URL in API docs 2021-11-25 11:13:58 +00:00
rubenwardy
6c99732673 Fix UI test assertion 2021-11-25 11:01:07 +00:00
rubenwardy
3c4085eb0b Use username in fallback when generating gravatar profile picture 2021-11-25 11:00:20 +00:00
rubenwardy
443dd9f18f Fix user count assertion in UI tests 2021-11-25 10:56:45 +00:00
rubenwardy
95c0fb8a70 Fix migration 2021-11-25 10:51:54 +00:00
rubenwardy
a04b2542b5 Add policy on Featured Packages (#323) 2021-11-25 10:49:42 +00:00
rubenwardy
49355f5db1 Improve API documentation 2021-11-25 10:49:31 +00:00
rubenwardy
41e0e65a6b Add more info to /api/homepage/ 2021-11-25 10:35:31 +00:00
rubenwardy
f714d809f8 Fix crash on /api/homepage/ due to invalid response returned 2021-11-25 10:28:15 +00:00
rubenwardy
cd39f7b2c6 Fix verification email not being sent by set password 2021-11-25 01:49:14 +00:00
rubenwardy
c4c8390ead Fix typo in API docs 2021-11-24 23:16:05 +00:00
dependabot[bot]
02311f190b Bump babel from 2.9.0 to 2.9.1 (#340)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-24 23:14:58 +00:00
rubenwardy
085c99272e Show gamejam banner to all users 2021-11-24 23:14:32 +00:00
rubenwardy
5fd1666a5d Prevent metrics roll over 2021-11-24 23:01:42 +00:00
rubenwardy
c0eb10521d Delete unconfirmed accounts after 12 hours 2021-11-24 22:54:35 +00:00
rubenwardy
bc371f1ef3 Delete inactive user accounts after 12 hours 2021-11-24 17:58:03 +00:00
rubenwardy
0486eb76c0 Add 12 hour expiry to email verification tokens 2021-11-24 17:41:39 +00:00
rubenwardy
3b5c9950de Add created_at to User and UserEmailVerification 2021-11-24 17:35:38 +00:00
rubenwardy
dd352faa31 Improve email verification help 2021-11-24 17:10:45 +00:00
rubenwardy
4f69dd8d32 Add API for reviews 2021-11-24 16:33:37 +00:00
rubenwardy
d0741fde6e Also allow Authorization header 2021-11-24 15:49:00 +00:00
rubenwardy
d485e686d9 Allow cross-origin requests to the API 2021-11-24 15:39:50 +00:00
rubenwardy
ae37a551e1 Improve gamejam image cropping 2021-11-23 01:16:59 +00:00
rubenwardy
afb2f9ec00 Add gamejam redirect 2021-11-23 01:05:43 +00:00
rubenwardy
21d5d9d47e Add gamejam.png 2021-11-23 00:59:43 +00:00
rubenwardy
20c93925a8 Add game jam ad 2021-11-23 00:58:53 +00:00
rubenwardy
e5ae41901c Add rules for requesting reviews 2021-11-21 03:44:44 +00:00
dependabot[bot]
d8b68136ef Bump pillow from 8.2.0 to 8.3.2 (#336)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-20 15:32:47 +01:00
rubenwardy
5319ea8771 Fix package form type/title/name field sizes 2021-09-08 16:54:06 +01:00
rubenwardy
43fcf5ee3b Add hint to readonly package name 2021-09-08 16:51:37 +01:00
rubenwardy
46a38753a9 Fix review votes not cascade deleting 2021-08-21 22:55:11 +01:00
rubenwardy
32372e8e86 Add support for strikethrough in markdown
Fixes #304
2021-08-21 05:40:20 +01:00
rubenwardy
c1edea4dc3 Allow removing review votes
Fixes #328
2021-08-19 13:45:32 +01:00
rubenwardy
86e1f57198 Hide review vote counts when 0 2021-08-18 22:49:44 +01:00
rubenwardy
fab814c46f Add review voting 2021-08-18 22:09:41 +01:00
rubenwardy
4a1f654798 Fix crash due to faulty game detection 2021-08-18 02:14:15 +01:00
rubenwardy
895a113478 Fallback to autogenerated Gravatars 2021-08-17 21:34:23 +01:00
rubenwardy
37a7dd28d6 Add optional Discord webhook support 2021-08-17 21:16:43 +01:00
rubenwardy
e5cc140d42 Add Approver rank 2021-08-16 18:57:05 +01:00
rubenwardy
59a5cf2df5 Add editor queue for packages with "Other" licenses
Fixes #283
2021-08-12 18:57:25 +01:00
rubenwardy
d6b1adf613 Disable package name validation on modpacks
Whilst modpacks providing an incorrect name in `modpack.conf` is wrong,
it doesn't actually matter as ContentDB will correct it after installation.
2021-08-08 20:43:57 +01:00
rubenwardy
562b0ceffe Allow admin to delete any user (except admins) 2021-08-04 21:50:35 +01:00
rubenwardy
6bbe2307e9 Add email notification prompt to post-login hook 2021-07-31 21:35:07 +01:00
rubenwardy
aae546a08e Import licenses from SPDX
Fixes #326
2021-07-31 21:03:45 +01:00
rubenwardy
2f2141f524 Allow editors to change maintainers
Fixes #325
2021-07-31 19:52:36 +01:00
rubenwardy
aee59626ee Add outdated packages notification 2021-07-30 19:50:52 +01:00
rubenwardy
825801b867 Refactor admin page 2021-07-30 19:43:02 +01:00
rubenwardy
447f3e2d5b Add modname uniqueness page 2021-07-29 19:34:47 +01:00
rubenwardy
ff846f4478 Fix bug with Editor screenshots 2021-07-28 01:53:44 +01:00
rubenwardy
c794de680b Profile medals: top package type icons, equal height 2021-07-26 00:02:55 +01:00
rubenwardy
034e5382ec Profile medals: refactor code 2021-07-25 23:41:34 +01:00
rubenwardy
e06ac1689c Profile medals: add progressbars, make top packages per type 2021-07-25 22:44:13 +01:00
rubenwardy
4de802c68d Add total downloads number on profiles with <50k 2021-07-25 20:26:01 +01:00
rubenwardy
33aedb233d Add maintained packages to user profile 2021-07-25 18:42:11 +01:00
rubenwardy
95bd1a50d9 Fix crash on user on None min_package_rank 2021-07-25 18:30:10 +01:00
rubenwardy
76675ad76b Add top packages badge to profile 2021-07-25 18:17:59 +01:00
rubenwardy
ac9b2207bf Fix profile badge icon use 2021-07-25 17:10:57 +01:00
rubenwardy
d7c83f58b9 Fix crash on profile with no pacakges 2021-07-25 17:01:21 +01:00
rubenwardy
d17bd5580e Fix badge hint 2021-07-25 16:36:39 +01:00
rubenwardy
94568c851a Add package downloads badge to profile page 2021-07-25 16:35:08 +01:00
rubenwardy
29bfc91683 Add top reviewer badge to profile page 2021-07-25 16:09:21 +01:00
rubenwardy
cb5fa4d6e7 Improve related packages on package page
Fixes #254
2021-07-25 03:54:08 +01:00
rubenwardy
fc7739be2c Add package name validation to postReleaseCheckUpdate
Fixes #186
2021-07-25 00:09:19 +01:00
rubenwardy
5a12b9e6c4 Fix crash in releases list due to missing current_user argument 2021-07-24 16:47:41 +01:00
rubenwardy
4e83adc032 Refactor package URL generation 2021-07-24 04:30:14 +01:00
rubenwardy
187202d363 Improve unified navigation further 2021-07-24 03:56:43 +01:00
rubenwardy
545968a71f Remove updateFromRelease feature 2021-07-24 03:09:33 +01:00
rubenwardy
f5aee035b3 Use vertical nav 2021-07-24 03:09:11 +01:00
rubenwardy
1389cf450c Unify package edit UI 2021-07-24 02:55:55 +01:00
rubenwardy
24e3b1505b Fix UI tests 2021-07-24 02:32:28 +01:00
rubenwardy
347f8e5a22 Add support for renaming users and package alias redirects
Fixes #270
2021-07-24 02:30:43 +01:00
rubenwardy
0614e6b28b Fix broken background for code blocks
Fixes #256
2021-07-24 01:14:28 +01:00
rubenwardy
823c06d3ea Prevent API from changing protected tags
Fixes #322
2021-07-24 00:43:55 +01:00
Warr1024
3049d17f5e Improve placeholder.png image
Closes #293
2021-07-22 18:46:14 +01:00
rubenwardy
e7818d7fb4 Tweak Featured styling, fix various issues 2021-07-22 18:33:24 +01:00
rubenwardy
7db6c6bba4 Fix missing state filter 2021-07-22 14:05:59 +01:00
rubenwardy
b87401a0c8 Fix missing mapPackages 2021-07-22 14:04:08 +01:00
rubenwardy
13b6ab04bb Bump CSS version 2021-07-22 13:52:59 +01:00
rubenwardy
148ece162c Add Featured packages and protected tags 2021-07-22 13:44:20 +01:00
rubenwardy
ce2bb3abad Fix Create Package not appearing for unprivileged users 2021-07-22 11:58:00 +01:00
rubenwardy
cfddf0ada3 Fix missing if-statement on screenshot approval badge 2021-07-22 11:54:18 +01:00
rubenwardy
54304cf3e0 Add Create Package button to profile page 2021-07-22 11:50:39 +01:00
rubenwardy
f1597622ea Add approval state labels to screenshots and package tiles 2021-07-22 11:43:43 +01:00
rubenwardy
8c44b08682 Remove flower 2021-07-21 03:50:43 +01:00
rubenwardy
6fa6203ce0 😢 2021-07-21 02:31:51 +01:00
rubenwardy
75c118c483 Prefetches dependencies of likely dependency candidates 2021-07-20 21:24:08 +01:00
rubenwardy
4238dbd412 Allow maintainers to delete their bot threads 2021-07-20 00:25:55 +01:00
rubenwardy
9a54ada0ec Don't mark package as changes needed when creating a review thread 2021-07-19 23:59:40 +01:00
rubenwardy
ce8ae30311 Add editor notification when user replies to ContentDB system user 2021-07-19 23:55:00 +01:00
rubenwardy
2f77a84ec5 Add notification when release creation fails 2021-07-19 23:49:29 +01:00
rubenwardy
f83605c35f Fix too long Draft/ChangesNeeded messages 2021-07-19 23:11:02 +01:00
rubenwardy
4c4bddeed6 Add post_login function, go to notifications page 2021-07-19 23:04:55 +01:00
rubenwardy
4523849641 Add Draft/ChangesNeeded notification admin action 2021-07-19 23:04:55 +01:00
dependabot[bot]
f49c60d7f6 Bump urllib3 from 1.26.4 to 1.26.5 (#303)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.4 to 1.26.5.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.4...1.26.5)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-18 18:05:16 +01:00
dependabot[bot]
2452fceeda Bump pillow from 8.1.1 to 8.2.0 (#305)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 8.1.1 to 8.2.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/master/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/8.1.1...8.2.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-18 18:05:04 +01:00
rubenwardy
fb2f71e1dc Revert "Remove error on game missing hard deps"
This reverts commit aff20f1a6d.
2021-07-18 17:59:35 +01:00
rubenwardy
52437d4e2e Fix images exceeding long desc bounds
Fixes #309
2021-07-18 16:49:28 +01:00
rubenwardy
72a95ecfca Fix error with submodule cloning
Call `git submodule update --init` manually rather than using GitPython's
submodule API, as that fails with the following repo as a submodule:
https://files.creativekara.fr/git/naturalslopeslib.git
2021-07-18 02:48:19 +01:00
Jordan Irwin
9e95b69c11 Fix badges not displaying when dash (-) in title (#308) 2021-06-23 23:57:26 +01:00
rubenwardy
231c2a3a1e Fix broken audit log in thread log 2021-06-14 17:40:38 +01:00
rubenwardy
7dbea9f042 Fix crash on packages with no releases 2021-05-08 00:30:57 +01:00
rubenwardy
9dfb95a524 Use secrets library to generate tokens 2021-05-06 14:51:22 +01:00
rubenwardy
e9161610c4 Fix bad download file names
Fixes #116
2021-05-04 22:06:31 +01:00
rubenwardy
f4792ac537 Fix get_latest_tag() crash on repos with dangling tags
Fixes #272
2021-05-04 01:36:54 +01:00
rubenwardy
588b03cf34 Add tests for login and register 2021-05-03 23:57:22 +01:00
rubenwardy
94bf83c611 Add tests for git utils 2021-05-03 22:22:17 +01:00
rubenwardy
4bb35953b1 Test dependencies API 2021-05-03 18:47:19 +01:00
rubenwardy
c6f3f61ff6 Fix build status badge 2021-05-03 18:43:11 +01:00
rubenwardy
d64463235c Add tests for package release filtering 2021-05-03 18:27:32 +01:00
rubenwardy
dcc34570d5 Use GitHub actions (#295) 2021-05-03 17:59:23 +01:00
rubenwardy
464c85295a Update FUNDING.yml 2021-05-03 17:28:57 +01:00
rubenwardy
95bdababb3 Create FUNDING.yml 2021-05-03 17:27:56 +01:00
rubenwardy
a33a4bd894 Fix editors not being able to approve releases 2021-04-28 23:18:16 +01:00
rubenwardy
c0719fdeaa Add simple captcha 2021-04-17 02:12:00 +01:00
dependabot[bot]
3612c1747e Bump lxml from 4.6.2 to 4.6.3
Bumps [lxml](https://github.com/lxml/lxml) from 4.6.2 to 4.6.3.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-4.6.2...lxml-4.6.3)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-10 16:33:35 +01:00
dependabot[bot]
a30b1bbf71 Bump pillow from 8.1.0 to 8.1.1
Bumps [pillow](https://github.com/python-pillow/Pillow) from 8.1.0 to 8.1.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/master/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/8.1.0...8.1.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-10 16:33:28 +01:00
dependabot[bot]
a0ace027d3 Bump urllib3 from 1.26.3 to 1.26.4
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.3 to 1.26.4.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.3...1.26.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-10 16:33:19 +01:00
rubenwardy
8dbd22f56c Add custom 404 page 2021-04-10 16:30:19 +01:00
rubenwardy
c2994a27fd Fix maintainers not being able to delete releases 2021-03-07 15:00:36 +00:00
rubenwardy
9cb9f8a4f6 Hotfix: Prevent webhooks from running on non-master/main branches 2021-03-07 14:48:04 +00:00
rubenwardy
4d2833de88 Add all releases API 2021-03-05 12:55:21 +00:00
rubenwardy
adcbf7455e Fix incorrect status code for Found 2021-03-02 22:37:37 +00:00
rubenwardy
df8ef542dd Disallow spaces in usernames 2021-03-02 22:36:21 +00:00
rubenwardy
c11e5c1f99 Revert "Fix Git clone error when checking out reference"
This reverts commit 63cfb5eac0.
2021-03-02 16:42:38 +00:00
rubenwardy
63cfb5eac0 Fix Git clone error when checking out reference 2021-03-01 18:10:46 +00:00
rubenwardy
6861524641 Improve release ratelimit message, increase limit 2021-03-01 18:10:33 +00:00
rubenwardy
032d8bf67b Fix minmum length on packages titles
Fixes #263
2021-02-28 21:32:28 +00:00
rubenwardy
47797f1fb1 Make it more clear that .conf will override release min/max vers 2021-02-28 21:30:54 +00:00
rubenwardy
92764465e0 Disable code highlighting for non-annotated code fences
Fixes #279
2021-02-28 21:24:57 +00:00
rubenwardy
04e108c31e Fix API crash due to missing upper() in enum coercion 2021-02-28 16:04:40 +00:00
rubenwardy
aead579f0b Use pen icon instead of edit 2021-02-28 05:19:57 +00:00
rubenwardy
f9089319d3 Improve package page sidebar 2021-02-28 05:19:57 +00:00
rubenwardy
ff2f7caee1 Add support for README files when importing meta
Fixes #87
2021-02-28 02:36:31 +00:00
rubenwardy
da81df535a API Screenshots: Fix crash on not a number 2021-02-28 01:14:43 +00:00
rubenwardy
7078ed3ac3 Fix duplicate name checking in register form 2021-02-27 19:11:53 +00:00
rubenwardy
da6b4b210f Allow specifying display name on register 2021-02-27 19:03:52 +00:00
rubenwardy
04f659bc2b API: Add support for specifying commit for zip releases 2021-02-27 18:31:56 +00:00
rubenwardy
8ef74deec1 Fix duplicate check for changing display names 2021-02-25 23:31:29 +00:00
rubenwardy
7e20a09499 Increase severity of display name change audit log 2021-02-25 23:29:27 +00:00
rubenwardy
dea5a52c86 Allow users to change their display name
Fixes #269
2021-02-25 23:25:33 +00:00
rubenwardy
96b5b4ea5b Add link to monitor and API 2021-02-25 23:03:32 +00:00
rubenwardy
aec346e2d4 Fix names not being included in topic query 2021-02-25 14:11:10 +00:00
rubenwardy
b41e4b50d9 Fix comment box padding 2021-02-23 00:46:16 +00:00
rubenwardy
3c095544d0 Enable word break in markdown content 2021-02-23 00:08:36 +00:00
rubenwardy
77dcb85912 Add username validation to signup page 2021-02-22 23:45:20 +00:00
rubenwardy
3ed73c4145 Fix crash on login with redirect 2021-02-11 23:26:23 +00:00
rubenwardy
c37f589765 Add package audit page 2021-02-08 00:47:34 +00:00
rubenwardy
7ff92bc7c1 Add ability to filter by no tags on package tags page 2021-02-05 17:03:50 +00:00
rubenwardy
3839dfbf90 Fix tag selector dropdown style 2021-02-05 16:42:48 +00:00
rubenwardy
0ff4f40652 Fix screenshot reordering 2021-02-05 16:05:20 +00:00
rubenwardy
2797792322 Add search box and edit tags button to Package Tags page 2021-02-05 16:01:12 +00:00
rubenwardy
3ce653ba74 Update documentation 2021-02-05 15:44:00 +00:00
rubenwardy
0918b8b676 Update front-end dependencies 2021-02-05 14:21:34 +00:00
414 changed files with 155208 additions and 14896 deletions

View File

@@ -3,3 +3,4 @@ data*
uploads
*.pyc
__pycache__
env

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
patreon: rubenwardy
custom: [ "https://rubenwardy.com/donate/" ]

21
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Copy config
run: cp utils/ci/* .
- name: Build the Docker image
run: docker-compose build
- name: Start Docker
run: docker-compose up -d
- name: Run migrations
run: ./utils/run_migrations.sh
- name: Run tests
run: ./utils/tests_cov.sh
- name: Stop Docker
run: docker-compose down

5
.gitignore vendored
View File

@@ -11,6 +11,7 @@ app/public/thumbnails
celerybeat-schedule
/data
.idea
*.mo
# Created by https://www.gitignore.io/api/linux,macos,python,windows
@@ -105,10 +106,6 @@ coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Flask stuff:
instance/
.webassets-cache

View File

@@ -1,22 +0,0 @@
image: docker/compose
services:
- docker:dind
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- /var/lib/docker
# build:
# stage: build
# script:
# - cp utils/gitlabci/* .
# - docker-compose build
UI_Test:
stage: test
script:
- cp utils/gitlabci/* .
- docker-compose up -d
- ./utils/run_migrations.sh
- ./utils/tests_cov.sh
- docker-compose down

View File

@@ -1,4 +1,4 @@
FROM python:3.6
FROM python:3.10.11
RUN groupadd -g 5123 cdb && \
useradd -r -u 5123 -g cdb cdb
@@ -16,7 +16,9 @@ COPY utils utils
COPY config.cfg config.cfg
COPY migrations migrations
COPY app app
COPY translations translations
RUN pybabel compile -d translations
RUN chown -R cdb:cdb /home/cdb
USER cdb

View File

@@ -1,10 +1,16 @@
# Content Database
[![Build status](https://gitlab.com/minetest/contentdb/badges/master/pipeline.svg)](https://gitlab.com/minetest/contentdb/pipelines)
# ContentDB
![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
Content database for Minetest mods, games, and more.\
A content database for Minetest mods, games, and more.\
Developed by rubenwardy, license AGPLv3.0+.
See [Getting Started](docs/getting_started.md).
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
## Credits
* `app/public/static/placeholder.png`: erlehmann, Warr1024. License: CC BY-SA 3.0
## How-tos
@@ -30,7 +36,7 @@ See [Getting Started](docs/getting_started.md).
* (optional) Install the [Docker extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker)
* Install the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
* Click no to installing pylint (we don't want it to be installed outside of a virtual env)
* Click no to installing pylint (we don't want it to be installed outside a virtual env)
* Set up a virtual env
* Replace `psycopg2` with `psycopg2_binary` in requirements.txt (because postgresql won't be installed on the system)
* `python3 -m venv env`

View File

@@ -14,53 +14,70 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import os
import redis
from flask import *
from flask_gravatar import Gravatar
import flask_menu as menu
from flask_mail import Mail
from flask_github import GitHub
from flask_wtf.csrf import CSRFProtect
from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response
from flask_babel import Babel, gettext
from flask_flatpages import FlatPages
from flask_babel import Babel
from flask_github import GitHub
from flask_gravatar import Gravatar
from flask_login import logout_user, current_user, LoginManager
import os, redis
from flask_mail import Mail
from flask_wtf.csrf import CSRFProtect
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
app = Flask(__name__, static_folder="public/static")
app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md"
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = ["fenced_code", "tables", "codehilite", 'toc']
app.config["FLATPAGES_EXTENSION_CONFIG"] = {
"fenced_code": {},
"tables": {},
"codehilite": {
"linenums": "True"
}
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
app.config["LANGUAGES"] = {
"en": "English",
"de": "Deutsch",
"es": "Español",
"fr": "Français",
"id": "Bahasa Indonesia",
"it": "Italiano",
"ms": "Bahasa Melayu",
"pl": "Język Polski",
"ru": "русский язык",
"sk": "Slovenčina",
"sv": "Svenska",
"tr": "Türkçe",
"uk": "Українська",
"vi": "tiếng Việt",
"zh_Hans": "汉语",
}
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
r = redis.Redis.from_url(app.config["REDIS_URL"])
redis_client = redis.Redis.from_url(app.config["REDIS_URL"])
menu.Menu(app=app)
github = GitHub(app)
csrf = CSRFProtect(app)
mail = Mail(app)
pages = FlatPages(app)
babel = Babel(app)
babel = Babel()
gravatar = Gravatar(app,
size=58,
size=64,
rating="g",
default="mp",
default="retro",
force_default=False,
force_lower=False,
use_ssl=True,
base_url=None)
init_markdown(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "users.login"
from .sass import sass
from .sass import init_app as sass
sass(app)
@@ -69,15 +86,9 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
app.logger.addHandler(build_handler(app))
from app.utils.markdown import init_app
init_app(app)
# @babel.localeselector
# def get_locale():
# return request.accept_languages.best_match(app.config["LANGUAGES"].keys())
from . import models, template_filters
@login_manager.user_loader
def load_user(user_id):
return models.User.query.filter_by(username=user_id).first()
@@ -86,31 +97,105 @@ def load_user(user_id):
from .blueprints import create_blueprints
create_blueprints(app)
@app.route("/uploads/<path:path>")
def send_upload(path):
return send_from_directory(app.config["UPLOAD_DIR"], path)
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { "path": "help" })
@app.route("/<path:path>/")
def flatpage(path):
page = pages.get_or_404(path)
template = page.meta.get("template", "flatpage.html")
return render_template(template, page=page)
@app.before_request
def check_for_ban():
if current_user.is_authenticated:
if current_user.rank == models.UserRank.BANNED:
flash("You have been banned.", "danger")
if current_user.ban and current_user.ban.has_expired:
models.db.session.delete(current_user.ban)
if current_user.rank == models.UserRank.BANNED:
current_user.rank = models.UserRank.MEMBER
models.db.session.commit()
elif current_user.ban or current_user.rank == models.UserRank.BANNED:
if current_user.ban:
flash(gettext("Banned:") + " " + current_user.ban.message, "danger")
else:
flash(gettext("You have been banned."), "danger")
logout_user()
return redirect(url_for("users.login"))
elif current_user.rank == models.UserRank.NOT_JOINED:
current_user.rank = models.UserRank.MEMBER
current_user.rank = models.UserRank.NEW_MEMBER
models.db.session.commit()
from .utils import clearNotifications
from .utils import clear_notifications, is_safe_url, create_session
@app.before_request
def check_for_notifications():
if current_user.is_authenticated:
clearNotifications(request.path)
clear_notifications(request.path)
@app.errorhandler(404)
def page_not_found(e):
return render_template("404.html"), 404
@app.errorhandler(500)
def server_error(e):
return render_template("500.html"), 500
def get_locale():
if not request:
return None
locales = app.config["LANGUAGES"].keys()
if current_user.is_authenticated and current_user.locale in locales:
return current_user.locale
locale = request.cookies.get("locale")
if locale not in locales:
locale = request.accept_languages.best_match(locales)
if locale and current_user.is_authenticated:
with create_session() as new_session:
new_session.query(models.User) \
.filter(models.User.username == current_user.username) \
.update({"locale": locale})
new_session.commit()
return locale
babel.init_app(app, locale_selector=get_locale)
@app.route("/set-locale/", methods=["POST"])
@csrf.exempt
def set_locale():
locale = request.form.get("locale")
if locale not in app.config["LANGUAGES"].keys():
flash("Unknown locale {}".format(locale), "danger")
locale = None
next_url = request.form.get("r")
if next_url and is_safe_url(next_url):
resp = make_response(redirect(next_url))
else:
resp = make_response(redirect(url_for("homepage.home")))
if locale:
expire_date = datetime.datetime.now()
expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("locale", locale, expires=expire_date)
if current_user.is_authenticated:
current_user.locale = locale
models.db.session.commit()
return resp

View File

@@ -1,4 +1,22 @@
import os, importlib
# ContentDB
# Copyright (C) 2018-21 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import importlib
import os
def create_blueprints(app):
dir = os.path.dirname(os.path.realpath(__file__))

View File

@@ -0,0 +1,349 @@
# ContentDB
# Copyright (C) 2018-21 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from typing import List
import requests
from celery import group, uuid
from flask import redirect, url_for, flash, current_app
from sqlalchemy import or_, and_
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
NotificationType, PackageUpdateConfig, License, UserRank, PackageType
from app.tasks.emails import send_pending_digests
from app.tasks.forumtasks import import_topic_list, check_all_forum_accounts
from app.tasks.importtasks import import_repo_screenshot, check_zip_release, check_for_updates, update_all_game_support
from app.utils import add_notification, get_system_user
actions = {}
def action(title: str):
def func(f):
name = f.__name__
actions[name] = {
"title": title,
"func": f,
}
return f
return func
@action("Delete stuck releases")
def del_stuck_releases():
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
db.session.commit()
return redirect(url_for("admin.admin_page"))
@action("Import forum topic list")
def import_topic_list():
task = import_topic_list.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
@action("Check all forum accounts")
def check_all_forum_accounts():
task = check_all_forum_accounts.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
@action("Delete unused uploads")
def clean_uploads():
upload_dir = current_app.config['UPLOAD_DIR']
(_, _, filenames) = next(os.walk(upload_dir))
existing_uploads = set(filenames)
if len(existing_uploads) != 0:
def get_filenames_from_column(column):
results = db.session.query(column).filter(column.isnot(None), column != "").all()
return set([os.path.basename(x[0]) for x in results])
release_urls = get_filenames_from_column(PackageRelease.url)
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
pp_urls = get_filenames_from_column(User.profile_pic)
db_urls = release_urls.union(screenshot_urls).union(pp_urls)
unreachable = existing_uploads.difference(db_urls)
import sys
print("On Disk: ", existing_uploads, file=sys.stderr)
print("In DB: ", db_urls, file=sys.stderr)
print("Unreachable: ", unreachable, file=sys.stderr)
for filename in unreachable:
os.remove(os.path.join(upload_dir, filename))
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
else:
flash("No downloads to create", "danger")
return redirect(url_for("admin.admin_page"))
@action("Delete unused mod names")
def del_mod_names():
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
count = query.count()
query.delete(synchronize_session=False)
db.session.commit()
flash("Deleted " + str(count) + " unused mod names", "success")
return redirect(url_for("admin.admin_page"))
@action("Run update configs")
def run_update_config():
check_for_updates.delay()
flash("Started update configs", "success")
return redirect(url_for("admin.admin_page"))
def _package_list(packages: List[str]):
# Who needs translations?
if len(packages) >= 3:
packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
packages_list = ", ".join(packages)
else:
packages_list = " and ".join(packages)
return packages_list
@action("Send WIP package notification")
def remind_wip():
users = User.query.filter(User.packages.any(or_(
Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
Package.author_id == user.id,
or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
havent = "haven't" if len(packages) > 1 else "hasn't"
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"Did you forget? {packages_list} {havent} been submitted for review yet",
url_for('todo.view_user', username=user.username))
db.session.commit()
@action("Send outdated package notification")
def remind_outdated():
users = User.query.filter(User.maintained_packages.any(
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))))
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
Package.maintainers.contains(user),
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"The following packages may be outdated: {packages_list}",
url_for('todo.view_user', username=user.username))
db.session.commit()
@action("Import licenses from SPDX")
def import_licenses():
renames = {
"GPLv2": "GPL-2.0-only",
"GPLv3": "GPL-3.0-only",
"AGPLv2": "AGPL-2.0-only",
"AGPLv3": "AGPL-3.0-only",
"LGPLv2.1": "LGPL-2.1-only",
"LGPLv3": "LGPL-3.0-only",
"Apache 2.0": "Apache-2.0",
"BSD 2-Clause / FreeBSD": "BSD-2-Clause-FreeBSD",
"BSD 3-Clause": "BSD-3-Clause",
"CC0": "CC0-1.0",
"CC BY 3.0": "CC-BY-3.0",
"CC BY 4.0": "CC-BY-4.0",
"CC BY-NC-SA 3.0": "CC-BY-NC-SA-3.0",
"CC BY-SA 3.0": "CC-BY-SA-3.0",
"CC BY-SA 4.0": "CC-BY-SA-4.0",
"NPOSLv3": "NPOSL-3.0",
"MPL 2.0": "MPL-2.0",
"EUPLv1.2": "EUPL-1.2",
"SIL Open Font License v1.1": "OFL-1.1",
}
for old_name, new_name in renames.items():
License.query.filter_by(name=old_name).update({ "name": new_name })
r = requests.get(
"https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json")
licenses = r.json()["licenses"]
existing_licenses = {}
for license_data in License.query.all():
assert license_data.name not in renames.keys()
existing_licenses[license_data.name.lower()] = license_data
for license_data in licenses:
obj = existing_licenses.get(license_data["licenseId"].lower())
if obj:
obj.url = license_data["reference"]
elif license_data.get("isOsiApproved") and license_data.get("isFsfLibre") and not license_data["isDeprecatedLicenseId"]:
obj = License(license_data["licenseId"], True, license_data["reference"])
db.session.add(obj)
db.session.commit()
@action("Delete inactive users")
def delete_inactive_users():
users = User.query.filter(User.is_active == False, ~User.packages.any(), ~User.forum_topics.any(),
User.rank == UserRank.NOT_JOINED).all()
for user in users:
db.session.delete(user)
db.session.commit()
@action("Send Video URL notification")
def remind_video_url():
users = User.query.filter(User.maintained_packages.any(
and_(Package.video_url == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED)))
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
or_(Package.author == user, Package.maintainers.contains(user)),
Package.video_url == None,
Package.type == PackageType.GAME,
Package.state == PackageState.APPROVED) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"You should add a video to {packages_list}",
url_for('users.profile', username=user.username))
db.session.commit()
@action("Send missing game support notifications")
def remind_missing_game_support():
users = User.query.filter(
User.maintained_packages.any(and_(
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False))).all()
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
Package.maintainers.contains(user),
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"You need to confirm whether the following packages support all games: {packages_list}",
url_for('todo.all_game_support', username=user.username))
db.session.commit()
@action("Detect game support")
def detect_game_support():
task_id = uuid()
update_all_game_support.apply_async((), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("Send pending notif digests")
def do_send_pending_digests():
send_pending_digests.delay()
@action("DANGER: Delete removed packages")
def del_removed_packages():
query = Package.query.filter_by(state=PackageState.DELETED)
count = query.count()
for pkg in query.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.commit()
flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page"))
@action("DANGER: Check all releases (postReleaseCheckUpdate)")
def check_releases():
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = []
for release in releases:
tasks.append(check_zip_release.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("DANGER: Check latest release of all packages (postReleaseCheckUpdate)")
def reimport_packages():
tasks = []
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
release = package.releases.first()
if release:
tasks.append(check_zip_release.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("DANGER: Import screenshots from Git")
def import_screenshots():
packages = Package.query \
.filter(Package.state != PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
.filter(PackageScreenshot.id == None) \
.all()
for package in packages:
import_repo_screenshot.delay(package.id)
return redirect(url_for("admin.admin_page"))

View File

@@ -14,21 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from celery import group
from flask import *
from flask import redirect, render_template, url_for, request, flash
from flask_login import current_user, login_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import InputRequired, Length
from app.models import *
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
from app.utils import rank_required, addAuditLog, addNotification
from wtforms import StringField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length, Optional
from app.utils import rank_required, add_audit_log, add_notification, get_system_user, nonempty_or_none
from . import bp
from .actions import actions
from app.models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType, PackageAlias
@bp.route("/admin/", methods=["GET", "POST"])
@@ -36,159 +30,17 @@ from . import bp
def admin_page():
if request.method == "POST":
action = request.form["action"]
if action == "delstuckreleases":
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action == "checkreleases":
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = []
for release in releases:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
elif action == "reimportpackages":
tasks = []
for package in Package.query.filter(Package.state!=PackageState.DELETED).all():
release = package.releases.first()
if release:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
elif action == "importmodlist":
task = importTopicList.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
elif action == "checkusers":
task = checkAllForumAccounts.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
elif action == "importscreenshots":
packages = Package.query \
.filter(Package.state!=PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
.filter(PackageScreenshot.id==None) \
.all()
for package in packages:
importRepoScreenshot.delay(package.id)
return redirect(url_for("admin.admin_page"))
elif action == "restore":
package = Package.query.get(request.form["package"])
if package is None:
flash("Unknown package", "danger")
else:
package.state = PackageState.READY_FOR_REVIEW
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action == "recalcscores":
for p in Package.query.all():
p.recalcScore()
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action == "cleanuploads":
upload_dir = app.config['UPLOAD_DIR']
(_, _, filenames) = next(os.walk(upload_dir))
existing_uploads = set(filenames)
if len(existing_uploads) != 0:
def getURLsFromDB(column):
results = db.session.query(column).filter(column != None, column != "").all()
return set([os.path.basename(x[0]) for x in results])
release_urls = getURLsFromDB(PackageRelease.url)
screenshot_urls = getURLsFromDB(PackageScreenshot.url)
db_urls = release_urls.union(screenshot_urls)
unreachable = existing_uploads.difference(db_urls)
import sys
print("On Disk: ", existing_uploads, file=sys.stderr)
print("In DB: ", db_urls, file=sys.stderr)
print("Unreachable: ", unreachable, file=sys.stderr)
for filename in unreachable:
os.remove(os.path.join(upload_dir, filename))
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
else:
flash("No downloads to create", "danger")
return redirect(url_for("admin.admin_page"))
elif action == "delmetapackages":
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
count = query.count()
query.delete(synchronize_session=False)
db.session.commit()
flash("Deleted " + str(count) + " unused meta packages", "success")
return redirect(url_for("admin.admin_page"))
elif action == "delremovedpackages":
query = Package.query.filter_by(state=PackageState.DELETED)
count = query.count()
for pkg in query.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.commit()
flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page"))
elif action == "addupdateconfig":
added = 0
for pkg in Package.query.filter(Package.repo != None, Package.releases.any(), Package.update_config == None).all():
pkg.update_config = PackageUpdateConfig()
pkg.update_config.auto_created = True
release: PackageRelease = pkg.releases.first()
if release and release.commit_hash:
pkg.update_config.last_commit = release.commit_hash
db.session.add(pkg.update_config)
added += 1
db.session.commit()
flash("Added {} update configs".format(added), "success")
return redirect(url_for("admin.admin_page"))
elif action == "runupdateconfig":
check_for_updates.delay()
flash("Started update configs", "success")
return redirect(url_for("admin.admin_page"))
if action in actions:
ret = actions[action]["func"]()
if ret:
return ret
else:
flash("Unknown action: " + action, "danger")
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).all()
return render_template("admin/list.html", deleted_packages=deleted_packages)
deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
class SwitchUserForm(FlaskForm):
username = StringField("Username")
@@ -208,14 +60,13 @@ def switch_user():
else:
flash("Unable to login as user", "danger")
# Process GET or invalid POST
return render_template("admin/switch_user.html", form=form)
class SendNotificationForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 300)])
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
title = StringField("Title", [InputRequired(), Length(1, 300)])
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
submit = SubmitField("Send")
@@ -224,13 +75,107 @@ class SendNotificationForm(FlaskForm):
def send_bulk_notification():
form = SendNotificationForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk notification", None, None, form.title.data)
add_audit_log(AuditSeverity.MODERATION, current_user,
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
addNotification(users, current_user, NotificationType.OTHER, form.title.data, form.url.data, None)
add_notification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
db.session.commit()
return redirect(url_for("admin.admin_page"))
return render_template("admin/send_bulk_notification.html", form=form)
@bp.route("/admin/restore/", methods=["GET", "POST"])
@rank_required(UserRank.EDITOR)
def restore():
if request.method == "POST":
target = request.form["submit"]
if "Review" in target:
target = PackageState.READY_FOR_REVIEW
elif "Changes" in target:
target = PackageState.CHANGES_NEEDED
else:
target = PackageState.WIP
package = Package.query.get(request.form["package"])
if package is None:
flash("Unknown package", "danger")
else:
package.state = target
add_audit_log(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
package.get_url("packages.view"), package)
db.session.commit()
return redirect(package.get_url("packages.view"))
deleted_packages = Package.query \
.filter(Package.state == PackageState.DELETED) \
.join(Package.author) \
.order_by(db.asc(User.username), db.asc(Package.name)) \
.all()
return render_template("admin/restore.html", deleted_packages=deleted_packages)
class TransferPackageForm(FlaskForm):
old_username = StringField("Old Username", [InputRequired()])
new_username = StringField("New Username", [InputRequired()])
package = StringField("Package", [Optional()])
remove_maintainer = BooleanField("Remove current owner from maintainers")
submit = SubmitField("Transfer")
def perform_transfer(form: TransferPackageForm):
query = Package.query.filter(Package.author.has(username=form.old_username.data))
if nonempty_or_none(form.package.data):
query = query.filter_by(name=form.package.data)
packages = query.all()
if len(packages) == 0:
flash("Unable to find package(s)", "danger")
return
new_user = User.query.filter_by(username=form.new_username.data).first()
if new_user is None:
flash("Unable to find new user", "danger")
return
names = [x.name for x in packages]
already_existing = Package.query.filter(Package.author_id == new_user.id, Package.name.in_(names)).all()
if len(already_existing) > 0:
existing_names = [x.name for x in already_existing]
flash("Unable to transfer packages as names exist at destination: " + ", ".join(existing_names), "danger")
return
for package in packages:
if form.remove_maintainer.data:
package.maintainers.remove(package.author)
package.author = new_user
package.maintainers.append(new_user)
package.aliases.append(PackageAlias(form.old_username.data, package.name))
add_audit_log(AuditSeverity.MODERATION, current_user,
f"Transferred {form.old_username.data}/{package.name} to {form.new_username.data}",
package.get_url("packages.view"), package)
db.session.commit()
flash("Transferred " + ", ".join([x.name for x in packages]), "success")
return redirect(url_for("admin.transfer"))
@bp.route("/admin/transfer/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def transfer():
form = TransferPackageForm(formdata=request.form)
if form.validate_on_submit():
ret = perform_transfer(form)
if ret is not None:
return ret
# Process GET or invalid POST
return render_template("admin/transfer.html", form=form)

View File

@@ -15,7 +15,9 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, request, abort
from app.models import db, AuditLogEntry, UserRank, User
from flask_login import current_user, login_required
from app.models import db, AuditLogEntry, UserRank, User, Permission
from app.utils import rank_required, get_int_or_abort
from . import bp
@@ -35,12 +37,15 @@ def audit():
abort(404)
query = query.filter_by(causer=user)
pagination = query.paginate(page, num, True)
pagination = query.paginate(page=page, per_page=num)
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
@bp.route("/admin/audit/<int:id>/")
@rank_required(UserRank.MODERATOR)
def audit_view(id):
entry = AuditLogEntry.query.get(id)
@bp.route("/admin/audit/<int:id_>/")
@login_required
def audit_view(id_):
entry: AuditLogEntry = AuditLogEntry.query.get_or_404(id_)
if not entry.check_perm(current_user, Permission.VIEW_AUDIT_DESCRIPTION):
abort(403)
return render_template("admin/audit_view.html", entry=entry)

View File

@@ -14,18 +14,17 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import request, abort, url_for, redirect, render_template, flash
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import TextAreaField, SubmitField, StringField
from wtforms.validators import InputRequired, Length
from app.utils.markdown import render_markdown
from app.models import *
from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog
from app.markdown import render_markdown
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
from app.utils import rank_required, add_audit_log
from . import bp
from app.models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm):
@@ -50,12 +49,12 @@ def send_single_email():
form = SendEmailForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
add_audit_log(AuditSeverity.MODERATION, current_user,
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
text = form.text.data
html = render_markdown(text)
task = send_user_email.delay(user.email, form.subject.data, text, html)
task = send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
return redirect(url_for("tasks.check", id=task.id, r=next_url))
return render_template("admin/send_email.html", form=form, user=user)
@@ -66,13 +65,12 @@ def send_single_email():
def send_bulk_email():
form = SendEmailForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk email", None, None, form.text.data)
add_audit_log(AuditSeverity.MODERATION, current_user,
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
text = form.text.data
html = render_markdown(text)
for user in User.query.filter(User.email != None).all():
send_user_email.delay(user.email, form.subject.data, text, html)
task_send_bulk.delay(form.subject.data, text, html)
return redirect(url_for("admin.admin_page"))

View File

@@ -15,14 +15,15 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import redirect, render_template, abort, url_for, request, flash
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, BooleanField, SubmitField, URLField
from wtforms.validators import InputRequired, Length, Optional
from app.models import *
from app.utils import rank_required
from app.utils import rank_required, nonempty_or_none, add_audit_log
from . import bp
from app.models import UserRank, License, db, AuditSeverity
@bp.route("/licenses/")
@@ -30,10 +31,13 @@ from . import bp
def license_list():
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
class LicenseForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3,100)])
is_foss = BooleanField("Is FOSS")
submit = SubmitField("Save")
name = StringField("Name", [InputRequired(), Length(3, 100)])
is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional()], filters=[nonempty_or_none])
submit = SubmitField("Save")
@bp.route("/licenses/new/", methods=["GET", "POST"])
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
@@ -53,9 +57,15 @@ def create_edit_license(name=None):
license = License(form.name.data)
db.session.add(license)
flash("Created license " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created license {license.name}",
url_for("admin.license_list"))
else:
flash("Updated license " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited license {license.name}",
url_for("admin.license_list"))
form.populate_obj(license)
db.session.commit()
return redirect(url_for("admin.license_list"))

View File

@@ -15,14 +15,15 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import redirect, render_template, abort, url_for, request
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.models import *
from . import bp
from app.models import Permission, Tag, db, AuditSeverity
from app.utils import add_audit_log
@bp.route("/tags/")
@@ -40,11 +41,14 @@ def tag_list():
return render_template("admin/tags/list.html", tags=query.all())
class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
"Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
@bp.route("/tags/new/", methods=["GET", "POST"])
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
@@ -56,17 +60,24 @@ def create_edit_tag(name=None):
if tag is None:
abort(404)
if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
if not Permission.check_perm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
abort(403)
form = TagForm(formdata=request.form, obj=tag)
form = TagForm(obj=tag)
if form.validate_on_submit():
if tag is None:
tag = Tag(form.title.data)
tag.description = form.description.data
db.session.add(tag)
add_audit_log(AuditSeverity.EDITOR, current_user, f"Created tag {tag.name}",
url_for("admin.create_edit_tag", name=tag.name))
else:
form.populate_obj(tag)
add_audit_log(AuditSeverity.EDITOR, current_user, f"Edited tag {tag.name}",
url_for("admin.create_edit_tag", name=tag.name))
db.session.commit()
if Permission.EDIT_TAGS.check(current_user):

View File

@@ -15,25 +15,29 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import redirect, render_template, abort, url_for, request, flash
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import InputRequired, Length
from app.models import *
from app.utils import rank_required
from app.utils import rank_required, add_audit_log
from . import bp
from app.models import UserRank, MinetestRelease, db, AuditSeverity
@bp.route("/versions/")
@rank_required(UserRank.MODERATOR)
def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
return render_template("admin/versions/list.html",
versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3,100)])
name = StringField("Name", [InputRequired(), Length(3, 100)])
protocol = IntegerField("Protocol")
submit = SubmitField("Save")
submit = SubmitField("Save")
@bp.route("/versions/new/", methods=["GET", "POST"])
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
@@ -51,9 +55,15 @@ def create_edit_version(name=None):
version = MinetestRelease(form.name.data)
db.session.add(version)
flash("Created version " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created version {version.name}",
url_for("admin.license_list"))
else:
flash("Updated version " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited version {version.name}",
url_for("admin.version_list"))
form.populate_obj(version)
db.session.commit()
return redirect(url_for("admin.version_list"))

View File

@@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import redirect, render_template, abort, url_for, request
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.models import *
from app.utils import rank_required
from . import bp
from app.models import UserRank, ContentWarning, db
@bp.route("/admin/warnings/")
@@ -30,11 +30,14 @@ from . import bp
def warning_list():
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
class WarningForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
name = StringField("Name", [Optional(), Length(1, 20),
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])

View File

@@ -14,44 +14,109 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import request, jsonify, current_app, abort
import math
from functools import wraps
from typing import List
import flask_sqlalchemy
from flask import request, jsonify, current_app, Response
from flask_login import current_user, login_required
from sqlalchemy import and_, or_
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func
from app import csrf
from app.utils.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats
from app.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
PackageAlias
from app.querybuilder import QueryBuilder
from app.utils import is_package_page
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date
from . import bp
from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
api_order_screenshots, api_edit_package, api_set_cover_image
from app.utils.minetest_hypertext import html_to_minetest
def cors_allowed(f):
@wraps(f)
def inner(*args, **kwargs):
res: Response = f(*args, **kwargs)
res.headers["Access-Control-Allow-Origin"] = "*"
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return res
return inner
def cached(max_age: int):
def decorator(f):
@wraps(f)
def inner(*args, **kwargs):
res: Response = f(*args, **kwargs)
res.cache_control.max_age = max_age
return res
return inner
return decorator
@bp.route("/api/packages/")
@cors_allowed
@cached(300)
def packages():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
qb = QueryBuilder(request.args)
query = qb.build_package_query()
if request.args.get("fmt") == "keys":
return jsonify([package.getAsDictionaryKey() for package in query.all()])
return jsonify([pkg.as_key_dict() for pkg in query.all()])
pkgs = qb.convertToDictionary(query.all())
pkgs = qb.convert_to_dictionary(query.all())
if "engine_version" in request.args or "protocol_version" in request.args:
pkgs = [package for package in pkgs if package.get("release")]
pkgs = [pkg for pkg in pkgs if pkg.get("release")]
# Promote featured packages
if "sort" not in request.args and \
"order" not in request.args and \
"q" not in request.args and \
"limit" not in request.args:
featured_lut = set()
featured = qb.convert_to_dictionary(query.filter(
Package.collections.any(and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))).all())
for pkg in featured:
featured_lut.add(f"{pkg['author']}/{pkg['name']}")
pkg["short_description"] = "Featured. " + pkg["short_description"]
not_featured = [pkg for pkg in pkgs if f"{pkg['author']}/{pkg['name']}" not in featured_lut]
pkgs = featured + not_featured
return jsonify(pkgs)
@bp.route("/api/packages/<author>/<name>/")
@is_package_page
def package(package):
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@cors_allowed
def package_view(package):
return jsonify(package.as_dict(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/hypertext/")
@is_package_page
@cors_allowed
def package_hypertext(package):
formspec_version = request.args["formspec_version"]
include_images = is_yes(request.args.get("include_images", "true"))
html = render_markdown(package.desc)
return jsonify(html_to_minetest(html, formspec_version, include_images))
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def edit_package(token, package):
if not token:
error(401, "Authentication needed")
@@ -59,13 +124,16 @@ def edit_package(token, package):
return api_edit_package(token, package, request.json)
def resolve_package_deps(out, package, only_hard):
id = package.getId()
if id in out:
def resolve_package_deps(out, package, only_hard, depth=1):
id_ = package.get_id()
if id_ in out:
return
ret = []
out[id] = ret
out[id_] = ret
if package.type != PackageType.MOD:
return
for dep in package.dependencies:
if only_hard and dep.optional:
@@ -73,13 +141,18 @@ def resolve_package_deps(out, package, only_hard):
if dep.package:
name = dep.package.name
fulfilled_by = [ dep.package.getId() ]
resolve_package_deps(out, dep.package, only_hard)
fulfilled_by = [ dep.package.get_id() ]
resolve_package_deps(out, dep.package, only_hard, depth)
elif dep.meta_package:
name = dep.meta_package.name
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
# TODO: resolve most likely candidate
fulfilled_by = [ pkg.get_id() for pkg in dep.meta_package.packages if pkg.state == PackageState.APPROVED]
if depth == 1 and not dep.optional:
most_likely = next((pkg for pkg in dep.meta_package.packages \
if pkg.type == PackageType.MOD and pkg.state == PackageState.APPROVED), None)
if most_likely:
resolve_package_deps(out, most_likely, only_hard, depth + 1)
else:
raise Exception("Malformed dependency")
@@ -93,6 +166,7 @@ def resolve_package_deps(out, package, only_hard):
@bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page
@cors_allowed
def package_dependencies(package):
only_hard = request.args.get("only_hard")
@@ -103,10 +177,11 @@ def package_dependencies(package):
@bp.route("/api/topics/")
@cors_allowed
def topics():
qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True)
return jsonify([t.getAsDictionary() for t in query.all()])
qb = QueryBuilder(request.args)
query = qb.build_topic_query(show_added=True)
return jsonify([t.as_dict() for t in query.all()])
@bp.route("/api/topic_discard/", methods=["POST"])
@@ -115,20 +190,21 @@ def topic_set_discard():
tid = request.args.get("tid")
discard = request.args.get("discard")
if tid is None or discard is None:
abort(400)
error(400, "Missing topic ID or discard bool")
topic = ForumTopic.query.get(tid)
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
abort(403)
if not topic.check_perm(current_user, Permission.TOPIC_DISCARD):
error(403, "Permission denied, need: TOPIC_DISCARD")
topic.discarded = discard == "true"
db.session.commit()
return jsonify(topic.getAsDictionary())
return jsonify(topic.as_dict())
@bp.route("/api/whoami/")
@is_api_authd
@cors_allowed
def whoami(token):
if token is None:
return jsonify({ "is_authenticated": False, "username": None })
@@ -136,30 +212,73 @@ def whoami(token):
return jsonify({ "is_authenticated": True, "username": token.owner.username })
@bp.route("/api/delete-token/", methods=["DELETE"])
@csrf.exempt
@is_api_authd
@cors_allowed
def api_delete_token(token):
if token is None:
error(404, "Token not found")
db.session.delete(token)
db.session.commit()
return jsonify({"success": True})
@bp.route("/api/markdown/", methods=["POST"])
@csrf.exempt
def markdown():
return render_markdown(request.data.decode("utf-8"))
@bp.route("/api/releases/")
@cors_allowed
def list_all_releases():
query = PackageRelease.query.filter_by(approved=True) \
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
.order_by(db.desc(PackageRelease.releaseDate))
if "author" in request.args:
author = User.query.filter_by(username=request.args["author"]).first()
if author is None:
error(404, "Author not found")
query = query.filter(PackageRelease.package.has(author=author))
if "maintainer" in request.args:
maintainer = User.query.filter_by(username=request.args["maintainer"]).first()
if maintainer is None:
error(404, "Maintainer not found")
query = query.join(Package)
query = query.filter(Package.maintainers.contains(maintainer))
return jsonify([ rel.as_long_dict() for rel in query.limit(30).all() ])
@bp.route("/api/packages/<author>/<name>/releases/")
@is_package_page
@cors_allowed
def list_releases(package):
return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
return jsonify([ rel.as_dict() for rel in package.releases.all() ])
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def create_release(token, package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
error(403, "You do not have the permission to approve releases")
data = request.json or request.form
if request.headers.get("Content-Type") == "application/json":
data = request.json
else:
data = request.form
if "title" not in data:
error(400, "Title is required in the POST data")
@@ -175,7 +294,9 @@ def create_release(token, package):
if file is None:
error(400, "Missing 'file' in multipart body")
return api_create_zip_release(token, package, data["title"], file)
commit_hash = data.get("commit")
return api_create_zip_release(token, package, data["title"], file, None, None, "API", commit_hash)
else:
error(400, "Unknown release-creation method. Specify the method or provide a file.")
@@ -183,18 +304,20 @@ def create_release(token, package):
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
@is_package_page
def release(package: Package, id: int):
@cors_allowed
def release_view(package: Package, id: int):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
error(404, "Release not found")
return jsonify(release.getAsDictionary())
return jsonify(release.as_dict())
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def delete_release(token: APIToken, package: Package, id: int):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
@@ -203,10 +326,10 @@ def delete_release(token: APIToken, package: Package, id: int):
if not token:
error(401, "Authentication needed")
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
if not release.checkPerm(token.owner, Permission.DELETE_RELEASE):
if not release.check_perm(token.owner, Permission.DELETE_RELEASE):
error(403, "Unable to delete the release, make sure there's a newer release available")
db.session.delete(release)
@@ -217,20 +340,22 @@ def delete_release(token: APIToken, package: Package, id: int):
@bp.route("/api/packages/<author>/<name>/screenshots/")
@is_package_page
@cors_allowed
def list_screenshots(package):
screenshots = package.screenshots.all()
return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
return jsonify([ss.as_dict(current_app.config["BASE_URL"]) for ss in screenshots])
@bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def create_screenshot(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to create screenshots")
data = request.form
@@ -241,23 +366,25 @@ def create_screenshot(token: APIToken, package: Package):
if file is None:
error(400, "Missing 'file' in multipart body")
return api_create_screenshot(token, package, data["title"], file)
return api_create_screenshot(token, package, data["title"], file, is_yes(data.get("is_cover_image")))
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@is_package_page
@cors_allowed
def screenshot(package, id):
ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package:
error(404, "Screenshot not found")
return jsonify(ss.getAsDictionary(current_app.config["BASE_URL"]))
return jsonify(ss.as_dict(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def delete_screenshot(token: APIToken, package: Package, id: int):
ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package:
@@ -266,10 +393,10 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to delete screenshots")
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
if package.cover_image == ss:
@@ -286,14 +413,15 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def order_screenshots(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to delete screenshots")
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots")
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
json = request.json
@@ -303,37 +431,134 @@ def order_screenshots(token: APIToken, package: Package):
return api_order_screenshots(token, package, request.json)
@bp.route("/api/scores/")
def package_scores():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def set_cover_image(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
pkgs = [package.getScoreDict() for package in query.all()]
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots")
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
json = request.json
if json is None or not isinstance(json, dict) or "cover_image" not in json:
error(400, "Expected body to be an object with cover_image as a key")
return api_set_cover_image(token, package, request.json["cover_image"])
@bp.route("/api/packages/<author>/<name>/reviews/")
@is_package_page
@cors_allowed
def list_reviews(package):
reviews = package.reviews
return jsonify([review.as_dict() for review in reviews])
@bp.route("/api/reviews/")
@cors_allowed
def list_all_reviews():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 200)
query = PackageReview.query
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
if "for_user" in request.args:
query = query.filter(PackageReview.package.has(Package.author.has(username=request.args["for_user"])))
if "author" in request.args:
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
if "is_positive" in request.args:
if is_yes(request.args.get("is_positive")):
query = query.filter(PackageReview.rating > 3)
else:
query = query.filter(PackageReview.rating <= 3)
q = request.args.get("q")
if q:
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
query = query.order_by(db.desc(PackageReview.created_at))
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
return jsonify({
"page": pagination.page,
"per_page": pagination.per_page,
"page_count": math.ceil(pagination.total / pagination.per_page),
"total": pagination.total,
"urls": {
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
},
"items": [review.as_dict(True) for review in pagination.items],
})
@bp.route("/api/packages/<author>/<name>/stats/")
@is_package_page
@cors_allowed
@cached(300)
def package_stats(package: Package):
start = get_request_date("start")
end = get_request_date("end")
return jsonify(get_package_stats(package, start, end))
@bp.route("/api/package_stats/")
@cors_allowed
@cached(900)
def all_package_stats():
return jsonify(get_all_package_stats())
@bp.route("/api/scores/")
@cors_allowed
@cached(300)
def package_scores():
qb = QueryBuilder(request.args)
query = qb.build_package_query()
pkgs = [package.as_score_dict() for package in query.all()]
return jsonify(pkgs)
@bp.route("/api/tags/")
@cors_allowed
def tags():
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
return jsonify([tag.as_dict() for tag in Tag.query.all() ])
@bp.route("/api/content_warnings/")
@cors_allowed
def content_warnings():
return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
return jsonify([warning.as_dict() for warning in ContentWarning.query.all() ])
@bp.route("/api/licenses/")
@cors_allowed
def licenses():
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
for license in License.query.order_by(db.asc(License.name)).all() ])
all_licenses = License.query.order_by(db.asc(License.name)).all()
return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses])
@bp.route("/api/homepage/")
@cors_allowed
def homepage():
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
spotlight = query.filter(
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \
.order_by(func.random()).limit(6).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
@@ -349,22 +574,327 @@ def homepage():
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
def mapPackages(packages):
return [pkg.getAsDictionaryKey() for pkg in packages]
def map_packages(packages: List[Package]):
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
return {
return jsonify({
"count": count,
"downloads": downloads,
"new": mapPackages(new),
"updated": mapPackages(updated),
"pop_mod": mapPackages(pop_mod),
"pop_txp": mapPackages(pop_txp),
"pop_game": mapPackages(pop_gam),
"high_reviewed": mapPackages(high_reviewed)
}
"spotlight": map_packages(spotlight),
"new": map_packages(new),
"updated": map_packages(updated),
"pop_mod": map_packages(pop_mod),
"pop_txp": map_packages(pop_txp),
"pop_game": map_packages(pop_gam),
"high_reviewed": map_packages(high_reviewed)
})
@bp.route("/api/welcome/v1/")
@cors_allowed
def welcome_v1():
featured = Package.query \
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
Package.collections.any(
and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))) \
.order_by(func.random()) \
.limit(5).all()
def map_packages(packages: List[Package]):
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({
"featured": map_packages(featured),
})
@bp.route("/api/minetest_versions/")
@cors_allowed
def versions():
return jsonify([rel.getAsDictionary() \
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
protocol_version = request.args.get("protocol_version")
engine_version = request.args.get("engine_version")
if protocol_version or engine_version:
rel = MinetestRelease.get(engine_version, get_int_or_abort(protocol_version))
if rel is None:
error(404, "No releases found")
return jsonify(rel.as_dict())
return jsonify([rel.as_dict() \
for rel in MinetestRelease.query.all() if rel.get_actual() is not None])
@bp.route("/api/dependencies/")
@cors_allowed
def all_deps():
qb = QueryBuilder(request.args)
query = qb.build_package_query()
def format_pkg(pkg: Package):
return {
"type": pkg.type.to_name(),
"author": pkg.author.username,
"name": pkg.name,
"provides": [x.name for x in pkg.provides],
"depends": [str(x) for x in pkg.dependencies if not x.optional],
"optional_depends": [str(x) for x in pkg.dependencies if x.optional],
}
page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
return jsonify({
"page": pagination.page,
"per_page": pagination.per_page,
"page_count": math.ceil(pagination.total / pagination.per_page),
"total": pagination.total,
"urls": {
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
},
"items": [format_pkg(pkg) for pkg in pagination.items],
})
@bp.route("/api/users/<username>/")
@cors_allowed
def user_view(username: str):
user = User.query.filter_by(username=username).first()
if user is None:
error(404, "User not found")
return jsonify(user.get_dict())
@bp.route("/api/users/<username>/stats/")
@cors_allowed
def user_stats(username: str):
user = User.query.filter_by(username=username).first()
if user is None:
error(404, "User not found")
start = get_request_date("start")
end = get_request_date("end")
return jsonify(get_package_stats_for_user(user, start, end))
@bp.route("/api/cdb_schema/")
@cors_allowed
def json_schema():
tags = Tag.query.all()
warnings = ContentWarning.query.all()
licenses = License.query.order_by(db.asc(License.name)).all()
return jsonify({
"title": "CDB Config",
"description": "Package Configuration",
"type": "object",
"$defs": {
"license": {
"enum": [license.name for license in licenses],
"enumDescriptions": [license.is_foss and "FOSS" or "NON-FOSS" for license in licenses]
},
},
"properties": {
"type": {
"description": "Package Type",
"enum": ["MOD", "GAME", "TXP"],
"enumDescriptions": ["Mod", "Game", "Texture Pack"]
},
"title": {
"description": "Human-readable title",
"type": "string"
},
"name": {
"description": "Technical name (needs permission if already approved).",
"type": "string",
"pattern": "^[a-z_]+$"
},
"short_description": {
"description": "Package Short Description",
"type": ["string", "null"]
},
"dev_state": {
"description": "Development State",
"enum": [
"WIP",
"BETA",
"ACTIVELY_DEVELOPED",
"MAINTENANCE_ONLY",
"AS_IS",
"DEPRECATED",
"LOOKING_FOR_MAINTAINER"
]
},
"tags": {
"description": "Package Tags",
"type": "array",
"items": {
"enum": [tag.name for tag in tags],
"enumDescriptions": [tag.title for tag in tags]
},
"uniqueItems": True,
},
"content_warnings": {
"description": "Package Content Warnings",
"type": "array",
"items": {
"enum": [warning.name for warning in warnings],
"enumDescriptions": [warning.title for warning in warnings]
},
"uniqueItems": True,
},
"license": {
"description": "Package License",
"$ref": "#/$defs/license"
},
"media_license": {
"description": "Package Media License",
"$ref": "#/$defs/license"
},
"long_description": {
"description": "Package Long Description",
"type": ["string", "null"]
},
"repo": {
"description": "Git Repository URL",
"type": "string",
"format": "uri"
},
"website": {
"description": "Website URL",
"type": ["string", "null"],
"format": "uri"
},
"issue_tracker": {
"description": "Issue Tracker URL",
"type": ["string", "null"],
"format": "uri"
},
"forums": {
"description": "Forum Topic ID",
"type": ["integer", "null"],
"minimum": 0
},
"video_url": {
"description": "URL to a Video",
"type": ["string", "null"],
"format": "uri"
},
"donate_url": {
"description": "URL to a donation page",
"type": ["string", "null"],
"format": "uri"
},
},
})
@bp.route("/api/hypertext/", methods=["POST"])
@csrf.exempt
@cors_allowed
def hypertext():
formspec_version = request.args["formspec_version"]
include_images = is_yes(request.args.get("include_images", "true"))
html = request.data.decode("utf-8")
if request.content_type == "text/markdown":
html = render_markdown(html)
return jsonify(html_to_minetest(html, formspec_version, include_images))
@bp.route("/api/collections/")
@cors_allowed
def collection_list():
if "author" in request.args:
user = User.query.filter_by(username=request.args["author"]).one_or_404()
query = user.collections
else:
query = Collection.query.order_by(db.asc(Collection.title))
if "package" in request.args:
id_ = request.args["package"]
package = Package.get_by_key(id_)
if package is None:
error(404, f"Package {id_} not found")
query = query.filter(Collection.packages.contains(package))
collections = [x.as_short_dict() for x in query.all() if not x.private]
return jsonify(collections)
@bp.route("/api/collections/<author>/<name>/")
@cors_allowed
def collection_view(author, name):
collection = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404()
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
error(404, "Collection not found")
items = collection.items
if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
ret = collection.as_dict()
ret["items"] = [x.as_dict() for x in items]
return jsonify(ret)
@bp.route("/api/updates/")
def updates():
protocol_version = get_int_or_abort(request.args.get("protocol_version"))
minetest_version = request.args.get("engine_version")
if protocol_version or minetest_version:
version = MinetestRelease.get(minetest_version, protocol_version)
else:
version = None
# Subquery to get the latest release for each package
latest_release_query = (db.session.query(
PackageRelease.package_id,
func.max(PackageRelease.id).label('max_release_id'))
.select_from(PackageRelease)
.filter(PackageRelease.approved == True))
if version:
latest_release_query = (latest_release_query
.filter(or_(PackageRelease.min_rel_id == None,
PackageRelease.min_rel_id <= version.id))
.filter(or_(PackageRelease.max_rel_id == None,
PackageRelease.max_rel_id >= version.id)))
latest_release_subquery = (
latest_release_query
.group_by(PackageRelease.package_id)
.subquery()
)
# Get package id and latest release
query = (db.session.query(User.username, Package.name, latest_release_subquery.c.max_release_id)
.select_from(Package)
.join(User, Package.author)
.join(latest_release_subquery, Package.id == latest_release_subquery.c.package_id)
.filter(Package.state == PackageState.APPROVED)
.all())
ret = {}
for author_username, package_name, release_id in query:
ret[f"{author_username}/{package_name}"] = release_id
# Get aliases
aliases = (db.session.query(PackageAlias.author, PackageAlias.name, User.username, Package.name)
.select_from(PackageAlias)
.join(Package, PackageAlias.package)
.join(User, Package.author)
.filter(Package.state == PackageState.APPROVED)
.all())
for old_author, old_name, new_author, new_name in aliases:
new_release = ret.get(f"{new_author}/{new_name}")
if new_release is not None:
ret[f"{old_author}/{old_name}"] = new_release
return jsonify(ret)

View File

@@ -19,13 +19,14 @@ from flask import jsonify, abort, make_response, url_for, current_app
from app.logic.packages import do_edit_package
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
def error(code: int, msg: str):
abort(make_response(jsonify({ "success": False, "error": msg }), code))
# Catches LogicErrors and aborts with JSON error
def guard(f):
def ret(*args, **kwargs):
@@ -39,7 +40,7 @@ def guard(f):
def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
@@ -49,42 +50,42 @@ def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: s
return jsonify({
"success": True,
"task": url_for("tasks.check", id=rel.task_id),
"release": rel.getAsDictionary()
"release": rel.as_dict()
})
def api_create_zip_release(token: APIToken, package: Package, title: str, file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
if not token.canOperateOnPackage(package):
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash: str = None):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
rel = guard(do_create_zip_release)(token.owner, package, title, file, min_v, max_v, reason)
rel = guard(do_create_zip_release)(token.owner, package, title, file, min_v, max_v, reason, commit_hash)
return jsonify({
"success": True,
"task": url_for("tasks.check", id=rel.task_id),
"release": rel.getAsDictionary()
"release": rel.as_dict()
})
def api_create_screenshot(token: APIToken, package: Package, title: str, file, reason="API"):
if not token.canOperateOnPackage(package):
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, reason)
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
return jsonify({
"success": True,
"screenshot": ss.getAsDictionary()
"screenshot": ss.as_dict()
})
def api_order_screenshots(token: APIToken, package: Package, order: [any]):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
guard(do_order_screenshots)(token.owner, package, order)
@@ -94,15 +95,26 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]):
})
def api_set_cover_image(token: APIToken, package: Package, cover_image):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
guard(do_set_cover_image)(token.owner, package, cover_image)
return jsonify({
"success": True
})
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
package = guard(do_edit_package)(token.owner, package, False, data, reason)
package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
return jsonify({
"success": True,
"package": package.getAsDictionary(current_app.config["BASE_URL"])
"package": package.as_dict(current_app.config["BASE_URL"])
})

View File

@@ -16,23 +16,24 @@
from flask import render_template, redirect, request, session, url_for, abort
from flask_babel import lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from wtforms import StringField, SubmitField
from wtforms.validators import InputRequired, Length
from wtforms_sqlalchemy.fields import QuerySelectField
from app.models import db, User, APIToken, Package, Permission
from app.utils import randomString
from app.models import db, User, APIToken, Permission
from app.utils import random_string
from . import bp
from ..users.settings import get_setting_tabs
class CreateAPIToken(FlaskForm):
name = StringField("Name", [InputRequired(), Length(1, 30)])
package = QuerySelectField("Limit to package", allow_blank=True,
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
package = QuerySelectField(lazy_gettext("Limit to package"), allow_blank=True,
get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/user/tokens/")
@@ -48,7 +49,7 @@ def list_tokens(username):
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
abort(403)
return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens")
@@ -62,7 +63,7 @@ def create_edit_token(username, id=None):
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
@@ -71,10 +72,8 @@ def create_edit_token(username, id=None):
access_token = None
if not is_new:
token = APIToken.query.get(id)
if token is None:
if token is None or token.owner != user:
abort(404)
elif token.owner != user:
abort(403)
access_token = session.pop("token_" + str(token.id), None)
@@ -84,12 +83,12 @@ def create_edit_token(username, id=None):
if form.validate_on_submit():
if is_new:
token = APIToken()
db.session.add(token)
token.owner = user
token.access_token = randomString(32)
token.access_token = random_string(32)
form.populate_obj(token)
db.session.add(token)
db.session.commit() # save
db.session.commit()
if is_new:
# Store token so it can be shown in the edit page
@@ -107,7 +106,7 @@ def reset_token(username, id):
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
abort(403)
token = APIToken.query.get(id)
@@ -116,7 +115,7 @@ def reset_token(username, id):
elif token.owner != user:
abort(403)
token.access_token = randomString(32)
token.access_token = random_string(32)
db.session.commit() # save
@@ -133,11 +132,9 @@ def delete_token(username, id):
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
if not user.check_perm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
token = APIToken.query.get(id)
if token is None:
abort(404)

View File

@@ -0,0 +1,377 @@
# ContentDB
# Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import re
import typing
from flask import Blueprint, request, redirect, render_template, flash, abort, url_for
from flask_babel import lazy_gettext, gettext
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField, SubmitField, FieldList, HiddenField, TextAreaField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.models import Collection, db, Package, Permission, CollectionPackage, User, UserRank, AuditSeverity
from app.utils import nonempty_or_none
from app.utils.models import is_package_page, add_audit_log, create_session
bp = Blueprint("collections", __name__)
regex_invalid_chars = re.compile("[^a-z0-9_]")
@bp.route("/collections/")
@bp.route("/collections/<author>/")
def list_all(author=None):
if author:
user = User.query.filter_by(username=author).one_or_404()
query = user.collections
else:
user = None
query = Collection.query.filter(Collection.items.any()).order_by(db.asc(Collection.title))
if "package" in request.args:
package = Package.get_by_key(request.args["package"])
if package is None:
abort(404)
query = query.filter(Collection.packages.contains(package))
collections = [x for x in query.all() if x.check_perm(current_user, Permission.VIEW_COLLECTION)]
return render_template("collections/list.html",
user=user, collections=collections,
noindex=len(collections) == 0)
@bp.route("/collections/<author>/<name>/")
def view(author, name):
collection = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404()
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
abort(404)
items = collection.items
if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
return render_template("collections/view.html", collection=collection, items=items)
class CollectionForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
name = StringField("URL", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
"Lower case letters (a-z), digits (0-9), and underscores (_) only")])
short_description = StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 200)])
long_description = TextAreaField(lazy_gettext("Page Content"), [Optional()], filters=[nonempty_or_none])
private = BooleanField(lazy_gettext("Private"))
descriptions = FieldList(
StringField(lazy_gettext("Short Description"), [Optional(), Length(0, 500)], filters=[nonempty_or_none]),
min_entries=0)
package_ids = FieldList(HiddenField(), min_entries=0)
package_removed = FieldList(HiddenField(), min_entries=0)
order = HiddenField()
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/collections/new/", methods=["GET", "POST"])
@bp.route("/collections/<author>/<name>/edit/", methods=["GET", "POST"])
@login_required
def create_edit(author=None, name=None):
collection: typing.Optional[Collection] = None
if author is not None and name is not None:
collection = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404()
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
abort(403)
elif "author" in request.args:
author = request.args["author"]
if author != current_user.username and not current_user.rank.at_least(UserRank.EDITOR):
abort(403)
if author is None:
author = current_user
else:
author = User.query.filter_by(username=author).one()
form = CollectionForm(formdata=request.form, obj=collection)
initial_packages = []
if "package" in request.args:
for package_id in request.args.getlist("package"):
package = Package.get_by_key(package_id)
if package:
initial_packages.append(package)
if request.method == "GET":
# HACK: fix bug in wtforms
form.private.data = collection.private if collection else False
if collection:
for item in collection.items:
form.descriptions.append_entry(item.description)
form.package_ids.append_entry(item.package.get_id())
form.package_removed.append_entry("0")
else:
form.name = None
if form.validate_on_submit():
ret = handle_create_edit(collection, form, initial_packages, author)
if ret:
return ret
return render_template("collections/create_edit.html",
collection=collection, form=form)
def handle_create_edit(collection: Collection, form: CollectionForm,
initial_packages: typing.List[Package], author: User):
severity = AuditSeverity.NORMAL if author == current_user else AuditSeverity.EDITOR
name = form.name.data if collection else regex_invalid_chars.sub("", form.title.data.lower().replace(" ", "_"))
if collection is None or name != collection.name:
if Collection.query \
.filter(Collection.name == name, Collection.author == author) \
.count() > 0:
flash(gettext("A collection with a similar title already exists"), "danger")
return
if Package.query \
.filter(Package.name == name, Package.author == author) \
.count() > 0:
flash(gettext("Unable to create collection as a package with that name already exists"), "danger")
return
if collection is None:
collection = Collection()
collection.author = author
form.populate_obj(collection)
collection.name = name
db.session.add(collection)
for package in initial_packages:
link = CollectionPackage()
link.package = package
link.collection = collection
link.order = len(collection.items)
db.session.add(link)
add_audit_log(severity, current_user,
f"Created collection {collection.author.username}/{collection.name}",
collection.get_url("collections.view"), None)
else:
form.populate_obj(collection)
collection.name = name
link_lookup = {}
for link in collection.items:
link_lookup[link.package.get_id()] = link
for i, package_id in enumerate(form.package_ids):
link = link_lookup.get(package_id.data)
to_delete = form.package_removed[i].data == "1"
if link is None:
if to_delete:
continue
package = Package.get_by_key(package_id.data)
if package is None:
abort(400)
link = CollectionPackage()
link.package = package
link.collection = collection
link.description = form.descriptions[i].data
link_lookup[link.package.get_id()] = link
db.session.add(link)
elif to_delete:
db.session.delete(link)
else:
link.description = form.descriptions[i].data
for i, package_id in enumerate(form.order.data.split(",")):
if package_id != "":
link_lookup[package_id].order = i + 1
add_audit_log(severity, current_user,
f"Edited collection {collection.author.username}/{collection.name}",
collection.get_url("collections.view"), None)
db.session.commit()
return redirect(collection.get_url("collections.view"))
@bp.route("/collections/<author>/<name>/delete/", methods=["GET", "POST"])
@login_required
def delete(author, name):
collection = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404()
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
abort(403)
if request.method == "POST":
add_audit_log(AuditSeverity.NORMAL, current_user,
f"Deleted collection {collection.author.username}/{collection.name}",
collection.get_url("collections.view"), None)
db.session.delete(collection)
db.session.commit()
return redirect(url_for("collections.list_all", author=author))
return render_template("collections/delete.html", collection=collection)
def toggle_package(collection: Collection, package: Package):
severity = AuditSeverity.NORMAL if collection.author == current_user else AuditSeverity.EDITOR
author = User.query.get(collection.author_id) if collection.author is None else collection.author
if package in collection.packages:
CollectionPackage.query \
.filter(CollectionPackage.collection == collection, CollectionPackage.package == package) \
.delete(synchronize_session=False)
add_audit_log(severity, current_user,
f"Removed {package.get_id()} from collection {author.username}/{collection.name}",
collection.get_url("collections.view"), None)
db.session.commit()
return False
else:
link = CollectionPackage()
link.package = package
link.collection = collection
link.order = len(collection.items)
db.session.add(link)
add_audit_log(severity, current_user,
f"Added {package.get_id()} to collection {author.username}/{collection.name}",
collection.get_url("collections.view"), None)
db.session.commit()
return True
def get_or_create_favorites(session):
collection = Collection.query.filter(Collection.name == "favorites", Collection.author == current_user).first()
if collection is None:
is_new = True
collection = Collection()
collection.title = "Favorites"
collection.name = "favorites"
collection.short_description = "My favorites"
collection.author_id = current_user.id
session.add(collection)
else:
is_new = False
return collection, is_new
@bp.route("/packages/<author>/<name>/add-to/", methods=["GET", "POST"])
@is_package_page
@login_required
def package_add(package):
with create_session() as new_session:
collection, is_new = get_or_create_favorites(new_session)
if is_new:
new_session.commit()
if request.method == "POST":
collection_id = request.form["collection"]
collection = Collection.query.get(collection_id)
if collection is None:
abort(404)
if not collection.check_perm(current_user, Permission.EDIT_COLLECTION):
abort(403)
if toggle_package(collection, package):
flash(gettext("Added package to collection"), "success")
else:
flash(gettext("Removed package from collection"), "success")
return redirect(package.get_url("collections.package_add"))
collections = current_user.collections.all()
if current_user.rank.at_least(UserRank.EDITOR) and current_user.username != "ContentDB":
collections.extend(Collection.query.filter(Collection.author.has(username="ContentDB")).all())
return render_template("collections/package_add_to.html", package=package, collections=collections)
@bp.route("/packages/<author>/<name>/favorite/", methods=["POST"])
@is_package_page
@login_required
def package_toggle_favorite(package):
collection, _is_new = get_or_create_favorites(db.session)
if toggle_package(collection, package):
msg = gettext("Added package to favorites collection")
if not collection.private:
msg += " " + gettext("(Public, change from Profile > My Collections)")
flash(msg, "success")
else:
flash(gettext("Removed package from favorites collection"), "success")
return redirect(package.get_url("packages.view"))
@bp.route("/collections/<author>/<name>/clone/", methods=["POST"])
@login_required
def clone(author, name):
old_collection: typing.Optional[Collection] = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404()
index = 0
new_name = name
new_title = old_collection.title
while True:
if Collection.query \
.filter(Collection.name == new_name, Collection.author == current_user) \
.count() == 0:
break
index += 1
new_name = f"{name}_{index}"
new_title = f"{old_collection.title} ({index})"
collection = Collection()
collection.title = new_title
collection.author = current_user
collection.short_description = old_collection.short_description
collection.name = new_name
collection.private = True
db.session.add(collection)
for item in old_collection.items:
new_item = CollectionPackage()
new_item.package = item.package
new_item.collection = collection
new_item.description = item.description
new_item.order = item.order
db.session.add(new_item)
add_audit_log(AuditSeverity.NORMAL, current_user,
f"Created collection {collection.name} from {old_collection.author.username}/{old_collection.name} ",
collection.get_url("collections.view"), None)
db.session.commit()
return redirect(collection.get_url("collections.view"))

View File

@@ -0,0 +1,49 @@
# ContentDB
# Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template
from flask_login import current_user
from sqlalchemy import or_, and_
from app.models import User, Package, PackageState, db, License, PackageReview, Collection
bp = Blueprint("donate", __name__)
@bp.route("/donate/")
def donate():
reviewed_packages = None
if current_user.is_authenticated:
reviewed_packages = Package.query.filter(
Package.state == PackageState.APPROVED,
or_(Package.reviews.any(and_(PackageReview.author_id == current_user.id, PackageReview.rating >= 3)),
Package.collections.any(and_(Collection.author_id == current_user.id, Collection.name == "favorites"))),
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
).order_by(db.asc(Package.title)).all()
query = Package.query.filter(
Package.license.has(License.is_foss == True),
Package.media_license.has(License.is_foss == True),
Package.state == PackageState.APPROVED,
or_(Package.donate_url.isnot(None), Package.author.has(User.donate_url.isnot(None)))
).order_by(db.desc(Package.score))
packages_count = query.count()
top_packages = query.limit(40).all()
return render_template("donate/index.html",
reviewed_packages=reviewed_packages, top_packages=top_packages, packages_count=packages_count)

View File

@@ -14,7 +14,8 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint
from flask import Blueprint, abort
from flask_babel import gettext
bp = Blueprint("github", __name__)
@@ -22,14 +23,20 @@ from flask import redirect, url_for, request, flash, jsonify, current_app
from flask_login import current_user
from sqlalchemy import func, or_, and_
from app import github, csrf
from app.models import db, User, APIToken, Package, Permission, AuditSeverity
from app.utils import abs_url_for, addAuditLog, login_user_set_active
from app.models import db, User, APIToken, Package, Permission, AuditSeverity, PackageState
from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url
from app.blueprints.api.support import error, api_create_vcs_release
import hmac, requests
@bp.route("/github/start/")
def start():
return github.authorize("", redirect_uri=abs_url_for("github.callback"))
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return github.authorize("", redirect_uri=abs_url_for("github.callback", next=next))
@bp.route("/github/view/")
def view_permissions():
@@ -37,20 +44,28 @@ def view_permissions():
current_app.config["GITHUB_CLIENT_ID"]
return redirect(url)
@bp.route("/github/callback/")
@github.authorized_handler
def callback(oauth_token):
next_url = request.args.get("next")
if oauth_token is None:
flash("Authorization failed [err=gh-oauth-login-failed]", "danger")
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login"))
# Get Github username
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
redirect_to = next
if redirect_to is None:
redirect_to = url_for("homepage.home")
# Get GitGub username
url = "https://api.github.com/user"
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
username = r.json()["login"]
# Get user by github username
# Get user by GitHub username
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
# If logged in, connect
@@ -58,30 +73,28 @@ def callback(oauth_token):
if userByGithub is None:
current_user.github_username = username
db.session.commit()
flash("Linked github to account", "success")
return redirect(url_for("homepage.home"))
flash(gettext("Linked GitHub to account"), "success")
return redirect(redirect_to)
else:
flash("Github account is already associated with another user", "danger")
return redirect(url_for("homepage.home"))
flash(gettext("GitHub account is already associated with another user"), "danger")
return redirect(redirect_to)
# If not logged in, log in
else:
if userByGithub is None:
flash("Unable to find an account for that Github user", "danger")
flash(gettext("Unable to find an account for that GitHub user"), "danger")
return redirect(url_for("users.claim_forums"))
elif login_user_set_active(userByGithub, remember=True):
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
if not current_user.password:
return redirect(next_url or url_for("users.set_password", optional=True))
else:
return redirect(next_url or url_for("homepage.home"))
else:
flash("Authorization failed [err=gh-login-failed]", "danger")
ret = login_user_set_active(userByGithub, next, remember=True)
if ret is None:
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
return redirect(url_for("users.login"))
add_audit_log(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
return ret
@bp.route("/github/webhook/", methods=["POST"])
@csrf.exempt
@@ -90,7 +103,8 @@ def webhook():
# Get package
github_url = "github.com/" + json["repository"]["full_name"]
package = Package.query.filter(Package.repo.ilike("%{}%".format(github_url))).first()
package = Package.query.filter(
Package.repo.ilike("%{}%".format(github_url)), Package.state != PackageState.DELETED).first()
if package is None:
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
@@ -123,7 +137,7 @@ def webhook():
if actual_token is None:
return error(403, "Invalid authentication, couldn't validate API token")
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
if not package.check_perm(actual_token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
#
@@ -134,13 +148,27 @@ def webhook():
if event == "push":
ref = json["after"]
title = json["head_commit"]["message"].partition("\n")[0]
elif event == "create" and json["ref_type"] == "tag":
branch = json["ref"].replace("refs/heads/", "")
if branch not in [ "master", "main" ]:
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
elif event == "create":
ref_type = json.get("ref_type")
if ref_type != "tag":
return jsonify({
"success": False,
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
})
ref = json["ref"]
title = ref
elif event == "ping":
return jsonify({ "success": True, "message": "Ping successful" })
else:
return error(400, "Unsupported event. Only 'push', `create:tag`, and 'ping' are supported.")
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release

View File

@@ -14,12 +14,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, request
from flask import Blueprint, request, jsonify
bp = Blueprint("gitlab", __name__)
from app import csrf
from app.models import Package, APIToken, Permission
from app.models import Package, APIToken, Permission, PackageState
from app.blueprints.api.support import error, api_create_vcs_release
@@ -28,7 +28,8 @@ def webhook_impl():
# Get package
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
package = Package.query.filter(
Package.repo.ilike("%{}%".format(gitlab_url)), Package.state != PackageState.DELETED).first()
if package is None:
return error(400,
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
@@ -42,7 +43,7 @@ def webhook_impl():
if token is None:
return error(403, "Invalid authentication")
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
#
@@ -53,11 +54,18 @@ def webhook_impl():
if event == "push":
ref = json["after"]
title = ref[:5]
branch = json["ref"].replace("refs/heads/", "")
if branch not in ["master", "main"]:
return jsonify({"success": False,
"message": "Webhook ignored, as it's not on the master/main branch"})
elif event == "tag_push":
ref = json["ref"]
title = ref.replace("refs/tags/", "")
else:
return error(400, "Unsupported event. Only 'push' and 'tag_push' are supported.")
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release

View File

@@ -1,43 +1,86 @@
from flask import Blueprint, render_template
# ContentDB
# Copyright (C) 2018-23 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template, redirect
from sqlalchemy import and_
from app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag, \
Collection
bp = Blueprint("homepage", __name__)
from app.models import *
import flask_menu as menu
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, subqueryload
from sqlalchemy.sql.expression import func
PKGS_PER_ROW = 4
@bp.route("/gamejam/")
def gamejam():
return redirect("https://forum.minetest.net/viewtopic.php?t=28802")
@bp.route("/")
@menu.register_menu(bp, ".", "Home")
def home():
def join(query):
def package_load(query):
return query.options(
joinedload(Package.author),
subqueryload(Package.main_screenshot),
subqueryload(Package.cover_image),
joinedload(Package.license),
joinedload(Package.media_license))
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
def review_load(query):
return query.options(
joinedload(PackageReview.author),
joinedload(PackageReview.thread).subqueryload(Thread.first_reply),
joinedload(PackageReview.package).joinedload(Package.author).load_only(User.username, User.display_name),
joinedload(PackageReview.package).load_only(Package.title, Package.name).subqueryload(Package.main_screenshot))
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
.filter(Package.reviews.any()).limit(4).all()
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter_by(state=PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all()
updated = updated[:4]
spotlight_pkgs = query.filter(
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \
.order_by(func.random()).limit(6).all()
reviews = PackageReview.query.filter_by(recommends=True).order_by(db.desc(PackageReview.created_at)).limit(5).all()
new = package_load(query.order_by(db.desc(Package.approved_at))).limit(PKGS_PER_ROW).all()
pop_mod = package_load(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
pop_gam = package_load(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
pop_txp = package_load(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(2*PKGS_PER_ROW).all()
high_reviewed = package_load(query.order_by(db.desc(Package.score - Package.score_downloads))) \
.filter(Package.reviews.any()).limit(PKGS_PER_ROW).all()
updated = package_load(db.session.query(Package).select_from(PackageRelease).join(Package)
.filter_by(state=PackageState.APPROVED)
.order_by(db.desc(PackageRelease.releaseDate))
.limit(20)).all()
updated = updated[:PKGS_PER_ROW]
reviews = review_load(PackageReview.query.filter(PackageReview.rating > 3)
.order_by(db.desc(PackageReview.created_at))).limit(5).all()
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
.select_from(Tag).outerjoin(Tags).join(Package).filter(Package.state == PackageState.APPROVED)\
.group_by(Tag.id).order_by(db.asc(Tag.title)).all()
return render_template("index.html", count=count, downloads=downloads, tags=tags,
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews)
return render_template("index.html", count=count, downloads=downloads, tags=tags, spotlight_pkgs=spotlight_pkgs,
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed,
reviews=reviews)

View File

@@ -17,10 +17,12 @@
from flask import Blueprint, make_response
from sqlalchemy.sql.expression import func
from app.models import Package, db, User, UserRank, PackageState
from app.models import Package, db, User, UserRank, PackageState, PackageReview, ThreadReply, Collection
from app.rediscache import get_key
bp = Blueprint("metrics", __name__)
def generate_metrics(full=False):
def write_single_stat(name, help, type, value):
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
@@ -31,7 +33,6 @@ def generate_metrics(full=False):
pieces = [key + "=" + str(val) for key, val in labels.items()]
return ",".join(pieces)
def write_array_stat(name, help, type, data):
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
.format(name=name, help=help, type=type)
@@ -48,11 +49,18 @@ def generate_metrics(full=False):
packages = Package.query.filter_by(state=PackageState.APPROVED).count()
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
reviews = PackageReview.query.count()
comments = ThreadReply.query.count()
collections = Collection.query.count()
ret = ""
ret += write_single_stat("contentdb_packages", "Total packages", "counter", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "counter", users)
ret += write_single_stat("contentdb_downloads", "Total downloads", "counter", downloads)
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
ret += write_single_stat("contentdb_emails", "Number of emails sent", "counter", int(get_key("emails_sent", "0")))
ret += write_single_stat("contentdb_reviews", "Number of reviews", "gauge", reviews)
ret += write_single_stat("contentdb_comments", "Number of comments", "gauge", comments)
ret += write_single_stat("contentdb_collections", "Number of collections", "gauge", collections)
if full:
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \
@@ -67,6 +75,7 @@ def generate_metrics(full=False):
return ret
@bp.route("/metrics")
def metrics():
response = make_response(generate_metrics(), 200)

View File

@@ -14,27 +14,31 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import Blueprint, redirect, render_template, abort
from sqlalchemy import func
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic
bp = Blueprint("metapackages", __name__)
bp = Blueprint("modnames", __name__)
@bp.route("/metapackages/")
@bp.route("/metapackages/<path:path>")
def mp_redirect(path):
return redirect("/modnames/" + path)
@bp.route("/modnames/")
def list_all():
mpackages = db.session.query(MetaPackage, func.count(Package.id)) \
modnames = db.session.query(MetaPackage, func.count(Package.id)) \
.select_from(MetaPackage).outerjoin(MetaPackage.packages) \
.order_by(db.asc(MetaPackage.name)) \
.group_by(MetaPackage.id).all()
return render_template("metapackages/list.html", mpackages=mpackages)
return render_template("modnames/list.html", modnames=modnames)
@bp.route("/metapackages/<name>/")
@bp.route("/modnames/<name>/")
def view(name):
mpackage = MetaPackage.query.filter_by(name=name).first()
if mpackage is None:
modname = MetaPackage.query.filter_by(name=name).first()
if modname is None:
abort(404)
dependers = db.session.query(Package) \
@@ -53,13 +57,12 @@ def view(name):
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
.all()
similar_topics = None
if mpackage.packages.filter_by(state=PackageState.APPROVED).count() == 0:
similar_topics = ForumTopic.query \
.filter_by(name=name) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
similar_topics = ForumTopic.query \
.filter_by(name=name) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
return render_template("metapackages/view.html", mpackage=mpackage,
return render_template("modnames/view.html", modname=modname,
dependers=dependers, optional_dependers=optional_dependers,
similar_topics=similar_topics)

View File

@@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user, login_required
from sqlalchemy import or_, desc

View File

@@ -0,0 +1,295 @@
# ContentDB
# Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import urllib.parse as urlparse
from typing import Optional
from urllib.parse import urlencode
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, abort, make_response, flash
from flask_babel import lazy_gettext, gettext
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, URLField
from wtforms.validators import InputRequired, Length
from app import csrf
from app.blueprints.users.settings import get_setting_tabs
from app.models import db, OAuthClient, User, Permission, APIToken, AuditSeverity, UserRank
from app.utils import random_string, add_audit_log
bp = Blueprint("oauth", __name__)
def build_redirect_url(url: str, code: str, state: Optional[str]):
params = {"code": code}
if state is not None:
params["state"] = state
url_parts = list(urlparse.urlparse(url))
query = dict(urlparse.parse_qsl(url_parts[4]))
query.update(params)
url_parts[4] = urlencode(query)
return urlparse.urlunparse(url_parts)
@bp.route("/oauth/authorize/", methods=["GET", "POST"])
@login_required
def oauth_start():
response_type = request.args.get("response_type", "code")
if response_type != "code":
return "Unsupported response_type, only code is supported", 400
client_id = request.args.get("client_id")
if client_id is None:
return "Missing client_id", 400
redirect_uri = request.args.get("redirect_uri")
if redirect_uri is None:
return "Missing redirect_uri", 400
client = OAuthClient.query.get_or_404(client_id)
if client.redirect_url != redirect_uri:
return "redirect_uri does not match client", 400
if not client.approved and client.owner != current_user:
abort(404)
valid_scopes = {"user:email", "package", "package:release", "package:screenshot"}
scope = request.args.get("scope", "")
scopes = [x.strip() for x in scope.split(",")]
scopes = set([x for x in scopes if x != ""])
unknown_scopes = scopes - valid_scopes
if unknown_scopes:
return f"Unknown scopes: {', '.join(unknown_scopes)}", 400
state = request.args.get("state")
token = APIToken.query.filter(APIToken.client == client, APIToken.owner == current_user).first()
if token and not (scopes - token.get_scopes()):
token.access_token = random_string(32)
token.auth_code = random_string(32)
db.session.commit()
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
if request.method == "POST":
action = request.form["action"]
if action == "cancel":
return redirect(client.redirect_url)
elif action == "authorize":
if token is None:
token = APIToken()
token.name = f"Token for {client.title} by {client.owner.username}"
token.owner = current_user
token.client = client
token.access_token = random_string(32)
assert client is not None
token.auth_code = random_string(32)
db.session.add(token)
token.set_scopes(scopes)
add_audit_log(AuditSeverity.USER, current_user,
f"Granted \"{scope}\" to OAuth2 application \"{client.title}\" by {client.owner.username} [{client_id}] ",
url_for("users.profile", username=current_user.username))
db.session.commit()
return redirect(build_redirect_url(client.redirect_url, token.auth_code, state))
scopes_info = []
if not scopes:
scopes_info.append({
"icon": "globe-europe",
"title": "Public data only",
"description": "Read-only access to your public data",
})
if "user:email" in scopes:
scopes_info.append({
"icon": "user",
"title": gettext("Personal data"),
"description": gettext("Email address (read-only)"),
})
if ("package" in scopes or
"package:release" in scopes or
"package:screenshot" in scopes):
if "package" in scopes:
msg = gettext("Ability to edit packages and their releases, screenshots, and related data")
elif "package:release" in scopes and "package:screenshot" in scopes:
msg = gettext("Ability to create and edit releases and screenshots")
elif "package:release" in scopes:
msg = gettext("Ability to create and edit releases")
elif "package:screenshot" in scopes:
msg = gettext("Ability to create and edit screenshots")
else:
assert False, "This should never happen"
scopes_info.append({
"icon": "pen",
"title": gettext("Packages"),
"description": msg,
})
return render_template("oauth/authorize.html", client=client, scopes=scopes_info)
def error(code: int, msg: str):
abort(make_response(jsonify({"success": False, "error": msg}), code))
@bp.route("/oauth/token/", methods=["POST"])
@csrf.exempt
def oauth_grant():
form = request.form
grant_type = request.args.get("grant_type", "authorization_code")
if grant_type != "authorization_code":
error(400, "Unsupported grant_type, only authorization_code is supported")
client_id = form.get("client_id")
if client_id is None:
error(400, "Missing client_id")
client_secret = form.get("client_secret")
if client_secret is None:
error(400, "Missing client_secret")
code = form.get("code")
if code is None:
error(400, "Missing code")
client = OAuthClient.query.filter_by(id=client_id, secret=client_secret).first()
if client is None:
error(400, "client_id and/or client_secret is incorrect")
token = APIToken.query.filter_by(auth_code=code).first()
if token is None or token.client != client:
error(400, "Incorrect code. It may have already been redeemed")
token.auth_code = None
db.session.commit()
return jsonify({
"access_token": token.access_token,
"token_type": "Bearer",
})
@bp.route("/user/apps/")
@login_required
def list_clients_redirect():
return redirect(url_for("oauth.list_clients", username=current_user.username))
@bp.route("/users/<username>/apps/")
@login_required
def list_clients(username):
user = User.query.filter_by(username=username).first_or_404()
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
abort(403)
return render_template("oauth/list_clients.html", user=user, tabs=get_setting_tabs(user), current_tab="oauth_clients")
class OAuthClientForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(5, 30)])
redirect_url = URLField(lazy_gettext("Redirect URL"), [InputRequired(), Length(5, 123)])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/users/<username>/apps/new/", methods=["GET", "POST"])
@bp.route("/users/<username>/apps/<id_>/edit/", methods=["GET", "POST"])
@login_required
def create_edit_client(username, id_=None):
user = User.query.filter_by(username=username).first_or_404()
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
abort(403)
is_new = id_ is None
client = None
if id_ is not None:
client = OAuthClient.query.get_or_404(id_)
if client.owner != user:
abort(404)
form = OAuthClientForm(formdata=request.form, obj=client)
if form.validate_on_submit():
if is_new:
client = OAuthClient()
db.session.add(client)
client.owner = user
client.id = random_string(24)
client.secret = random_string(32)
client.approved = current_user.rank.at_least(UserRank.EDITOR)
form.populate_obj(client)
verb = "Created" if is_new else "Edited"
add_audit_log(AuditSeverity.NORMAL, current_user,
f"{verb} OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
db.session.commit()
return redirect(url_for("oauth.create_edit_client", username=username, id_=client.id))
return render_template("oauth/create_edit.html", user=user, form=form, client=client)
@bp.route("/users/<username>/apps/<id_>/delete/", methods=["POST"])
@login_required
def delete_client(username, id_):
user = User.query.filter_by(username=username).first_or_404()
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
abort(403)
client = OAuthClient.query.get(id_)
if client is None or client.owner != user:
abort(404)
add_audit_log(AuditSeverity.NORMAL, current_user,
f"Deleted OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
url_for("users.profile", username=current_user.username))
db.session.delete(client)
db.session.commit()
return redirect(url_for("oauth.list_clients", username=username))
@bp.route("/users/<username>/apps/<id_>/revoke-all/", methods=["POST"])
@login_required
def revoke_all(username, id_):
user = User.query.filter_by(username=username).first_or_404()
if not user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
abort(403)
client = OAuthClient.query.get(id_)
if client is None or client.owner != user:
abort(404)
add_audit_log(AuditSeverity.NORMAL, current_user,
f"Revoked all user tokens for OAuth2 application {client.title} by {client.owner.username} [{client.id}]",
url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))
client.tokens = []
db.session.commit()
flash(gettext("Revoked all user tokens"), "success")
return redirect(url_for("oauth.create_edit_client", username=client.owner.username, id_=client.id))

View File

@@ -15,7 +15,68 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint
from flask_babel import gettext
from app.models import User, Package, Permission, PackageType
bp = Blueprint("packages", __name__)
from . import packages, screenshots, releases, reviews
def get_package_tabs(user: User, package: Package):
if package is None or not package.check_perm(user, Permission.EDIT_PACKAGE):
return []
retval = [
{
"id": "edit",
"title": gettext("Edit Details"),
"url": package.get_url("packages.create_edit")
},
{
"id": "releases",
"title": gettext("Releases"),
"url": package.get_url("packages.list_releases")
},
{
"id": "screenshots",
"title": gettext("Screenshots"),
"url": package.get_url("packages.screenshots")
},
{
"id": "maintainers",
"title": gettext("Maintainers"),
"url": package.get_url("packages.edit_maintainers")
},
{
"id": "audit",
"title": gettext("Audit Log"),
"url": package.get_url("packages.audit")
},
{
"id": "stats",
"title": gettext("Statistics"),
"url": package.get_url("packages.statistics")
},
{
"id": "share",
"title": gettext("Share and Badges"),
"url": package.get_url("packages.share")
},
{
"id": "remove",
"title": gettext("Remove / Unpublish"),
"url": package.get_url("packages.remove")
}
]
if package.type == PackageType.MOD or package.type == PackageType.TXP:
retval.insert(1, {
"id": "game_support",
"title": gettext("Supported Games"),
"url": package.get_url("packages.game_support")
})
return retval
from . import packages, screenshots, releases, reviews, game_hub

View File

@@ -0,0 +1,53 @@
# ContentDB
# Copyright (C) 2022 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, abort
from sqlalchemy.orm import joinedload
from . import bp
from app.utils import is_package_page
from app.models import Package, PackageType, PackageState, db, PackageRelease
@bp.route("/packages/<author>/<name>/hub/")
@is_package_page
def game_hub(package: Package):
if package.type != PackageType.GAME:
abort(404)
def join(query):
return query.options(
joinedload(Package.license),
joinedload(Package.media_license))
query = Package.query.filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED)
count = query.count()
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
.filter(Package.reviews.any()).limit(4).all()
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter(Package.supported_games.any(game=package, supports=True), Package.state==PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all()
updated = updated[:4]
return render_template("packages/game_hub.html", package=package, count=count,
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp,
high_reviewed=high_reviewed)

View File

@@ -14,37 +14,43 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import typing
from urllib.parse import quote as urlescape
import flask_menu as menu
from celery import uuid
from flask import render_template, flash
from flask import render_template, make_response, request, redirect, flash, url_for, abort
from flask_babel import gettext, lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_login import login_required
from sqlalchemy import or_, func
from jinja2.utils import markupsafe
from sqlalchemy import func, or_, and_
from sqlalchemy.orm import joinedload, subqueryload
from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.validators import *
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, URL, NumberRange, ValidationError
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from app.logic.LogicError import LogicError
from app.logic.packages import do_edit_package
from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_key
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease
from app.utils import *
from . import bp
from ...logic.LogicError import LogicError
from ...logic.packages import do_edit_package
from app.tasks.importtasks import import_repo_screenshot, check_zip_release
from app.tasks.webhooktasks import post_discord_webhook
from app.logic.game_support import GameSupportResolver
from . import bp, get_package_tabs
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
PackageDailyStats, Collection
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1', 'lucky': '1' })
@bp.route("/packages/")
def list_all():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
query = qb.build_package_query()
title = qb.title
query = query.options(
@@ -70,15 +76,15 @@ def list_all():
if qb.lucky:
package = query.first()
if package:
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
topic = qb.buildTopicQuery().first()
topic = qb.build_topic_query().first()
if qb.search and topic:
return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = query.paginate(page, num, True)
query = query.paginate(page=page, per_page=num)
search = request.args.get("q")
type_name = request.args.get("type")
@@ -94,54 +100,73 @@ def list_all():
topics = None
if qb.search and not query.has_next:
qb.show_discarded = True
topics = qb.buildTopicQuery().all()
topics = qb.build_topic_query().all()
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
.select_from(Tag).join(Tags).join(Package).group_by(Tag.id).order_by(db.asc(Tag.title))
tags = qb.filterPackageQuery(tags_query).all()
.select_from(Tag).join(Tags).join(Package).filter(Package.state==PackageState.APPROVED) \
.group_by(Tag.id).order_by(db.asc(Tag.title))
tags = qb.filter_package_query(tags_query).all()
selected_tags = set(qb.tags)
return render_template("packages/list.html",
title=title, packages=query.items, pagination=query,
query_hint=title, packages=query.items, pagination=query,
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
authors=authors, packages_count=query.total, topics=topics)
authors=authors, packages_count=query.total, topics=topics, noindex=qb.noindex)
def getReleases(package):
if package.checkPerm(current_user, Permission.MAKE_RELEASE):
def get_releases(package):
if package.check_perm(current_user, Permission.MAKE_RELEASE):
return package.releases.limit(5)
else:
return package.releases.filter_by(approved=True).limit(5)
@bp.route("/packages/<author>/")
def user_redirect(author):
return redirect(url_for("users.profile", username=author))
@bp.route("/packages/<author>/<name>/")
@is_package_page
def view(package):
alternatives = None
if package.type == PackageType.MOD:
alternatives = Package.query \
.filter_by(name=package.name, type=PackageType.MOD) \
.filter(Package.id != package.id, Package.state!=PackageState.DELETED) \
.order_by(db.desc(Package.score)) \
.all()
if not package.check_perm(current_user, Permission.VIEW_PACKAGE):
return render_template("packages/gone.html", package=package), 403
show_similar = not package.approved and (
current_user in package.maintainers or
package.check_perm(current_user, Permission.APPROVE_NEW))
show_similar_topics = current_user == package.author or \
package.checkPerm(current_user, Permission.APPROVE_NEW)
conflicting_modnames = None
if show_similar and package.type != PackageType.TXP:
conflicting_modnames = db.session.query(MetaPackage.name) \
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED))) \
.all()
similar_topics = None if not show_similar_topics else \
ForumTopic.query \
.filter_by(name=package.name) \
conflicting_modnames += db.session.query(ForumTopic.name) \
.filter(ForumTopic.name.in_([ mp.name for mp in package.provides ])) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
releases = getReleases(package)
conflicting_modnames = set([x[0] for x in conflicting_modnames])
packages_uses = None
if package.type == PackageType.MOD:
packages_uses = Package.query.filter(
Package.type == PackageType.MOD,
Package.id != package.id,
Package.state == PackageState.APPROVED,
Package.dependencies.any(
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
.order_by(db.desc(Package.score)).limit(6).all()
releases = get_releases(package)
review_thread = package.review_thread
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
if review_thread is not None and not review_thread.check_perm(current_user, Permission.SEE_THREAD):
review_thread = None
topic_error = None
@@ -149,47 +174,53 @@ def view(package):
if package.state != PackageState.APPROVED and package.forums is not None:
errors = []
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
errors.append("<b>Error: Another package already uses this forum topic!</b>")
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>")
topic_error_lvl = "danger"
topic = ForumTopic.query.get(package.forums)
if topic is not None:
if topic.author != package.author:
errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
topic_error_lvl = "danger"
elif package.type != PackageType.TXP:
errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated:
threads = threads.filter_by(private=False)
elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
elif not current_user.rank.at_least(UserRank.APPROVER) and not current_user == package.author:
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
has_review = current_user.is_authenticated and \
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
is_favorited = current_user.is_authenticated and \
Collection.query.filter(
Collection.author == current_user,
Collection.packages.contains(package),
Collection.name == "favorites").count() > 0
return render_template("packages/view.html",
package=package, releases=releases,
alternatives=alternatives, similar_topics=similar_topics,
package=package, releases=releases, packages_uses=packages_uses,
conflicting_modnames=conflicting_modnames,
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
threads=threads.all(), has_review=has_review)
threads=threads.all(), has_review=has_review, is_favorited=is_favorited)
@bp.route("/packages/<author>/<name>/shields/<type>/")
@is_package_page
def shield(package, type):
if type == "title":
url = "https://img.shields.io/badge/ContentDB-{}-{}" \
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
.format(urlescape(package.title), urlescape("#375a7f"))
elif type == "downloads":
#api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
api_url = "https://content.minetest.net" + url_for("api.package", author=package.author.username, name=package.name)
api_url = abs_url_for("api.package_view", author=package.author.username, name=package.name)
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
.format(urlescape("#375a7f"), urlescape(api_url))
else:
from flask import abort
abort(404)
return redirect(url)
@@ -198,17 +229,17 @@ def shield(package, type):
@bp.route("/packages/<author>/<name>/download/")
@is_package_page
def download(package):
release = package.getDownloadRelease()
release = package.get_download_release()
if release is None:
if "application/zip" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes:
"text/html" not in request.accept_mimetypes:
return "", 204
else:
flash("No download available.", "danger")
return redirect(package.getDetailsURL())
flash(gettext("No download available."), "danger")
return redirect(package.get_url("packages.view"))
else:
return redirect(release.getDownloadURL(), code=302)
return redirect(release.get_download_url())
def makeLabel(obj):
@@ -217,25 +248,99 @@ def makeLabel(obj):
else:
return obj.title
class PackageForm(FlaskForm):
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 100)])
name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
type = SelectField(lazy_gettext("Type"), [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
content_warnings = QuerySelectMultipleField('Content Warnings', query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
license = QuerySelectField("License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField("Media License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)])
submit = SubmitField("Save")
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0, 999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None])
donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None])
submit = SubmitField(lazy_gettext("Save"))
def validate_name(self, field):
if field.data == "_game":
raise ValidationError(lazy_gettext("_game is not an allowed name"))
def handle_create_edit(package: typing.Optional[Package], form: PackageForm, author: User):
wasNew = False
if package is None:
package = Package.query.filter_by(name=form.name.data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.DELETED:
flash(
gettext("Package already exists, but is removed. Please contact ContentDB staff to restore the package"),
"danger")
else:
flash(markupsafe.Markup(
f"<a class='btn btn-sm btn-danger float-end' href='{package.get_url('packages.view')}'>View</a>" +
gettext("Package already exists")), "danger")
return None
if Collection.query \
.filter(Collection.name == form.name.data, Collection.author == author) \
.count() > 0:
flash(gettext("A collection with a similar name already exists"), "danger")
return
package = Package()
db.session.add(package)
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
"video_url": form.video_url.data,
"donate_url": form.donate_url.data,
})
if wasNew:
msg = f"Created package {author.username}/{form.name.data}"
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
if wasNew and package.repo is not None:
import_repo_screenshot.delay(package.id)
next_url = package.get_url("packages.view")
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.get_url("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
@bp.route("/packages/new/", methods=["GET", "POST"])
@@ -251,19 +356,19 @@ def create_edit(author=None, name=None):
else:
author = User.query.filter_by(username=author).first()
if author is None:
flash("Unable to find that user", "danger")
flash(gettext("Unable to find that user"), "danger")
return redirect(url_for("packages.create_edit"))
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash("Permission denied", "danger")
if not author.check_perm(current_user, Permission.CHANGE_AUTHOR):
flash(gettext("Permission denied"), "danger")
return redirect(url_for("packages.create_edit"))
else:
package = getPackageByInfo(author, name)
package = get_package_by_info(author, name)
if package is None:
abort(404)
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.get_url("packages.view"))
author = package.author
@@ -272,66 +377,23 @@ def create_edit(author=None, name=None):
# Initial form class from post data and default data
if request.method == "GET":
if package is None:
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums")
form.license.data = None
form.media_license.data = None
else:
# form.harddep_str.data = ",".join([str(x) for x in package.getSortedHardDependencies() ])
# form.softdep_str.data = ",".join([str(x) for x in package.getSortedOptionalDependencies() ])
form.tags.data = list(package.tags)
form.content_warnings.data = list(package.content_warnings)
form.tags.data = package.tags
form.content_warnings.data = package.content_warnings
if request.method == "POST" and form.type.data == PackageType.TXP:
form.license.data = form.media_license.data
if form.validate_on_submit():
wasNew = False
if not package:
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.READY_FOR_REVIEW:
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else:
flash("Package already exists!", "danger")
return redirect(url_for("packages.create_edit"))
package = Package()
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
})
if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id)
next_url = package.getDetailsURL()
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.getSetupReleasesURL()
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
ret = handle_create_edit(package, form, author)
if ret:
return ret
package_query = Package.query.filter_by(state=PackageState.APPROVED)
if package is not None:
@@ -341,7 +403,8 @@ def create_edit(author=None, name=None):
return render_template("packages/create_edit.html", package=package,
form=form, author=author, enable_wizard=enableWizard,
packages=package_query.all(),
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
modnames=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
tabs=get_package_tabs(current_user, package), current_tab="edit")
@bp.route("/packages/<author>/<name>/state/", methods=["POST"])
@@ -352,15 +415,18 @@ def move_to_state(package):
if state is None:
abort(400)
if not package.canMoveToState(current_user, state):
flash("You don't have permission to do that", "danger")
return redirect(package.getDetailsURL())
if not package.can_move_to_state(current_user, state):
flash(gettext("You don't have permission to do that"), "danger")
return redirect(package.get_url("packages.view"))
package.state = state
msg = "Marked {} as {}".format(package.title, state.value)
if state == PackageState.APPROVED:
if not package.approved_at:
post_discord_webhook.delay(package.author.display_name,
"New package {}".format(package.get_url("packages.view", absolute=True)), False,
package.title, package.short_desc, package.get_thumb_url(2, True))
package.approved_at = datetime.datetime.now()
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
@@ -368,21 +434,25 @@ def move_to_state(package):
s.approved = True
msg = "Approved {}".format(package.title)
elif state == PackageState.READY_FOR_REVIEW:
post_discord_webhook.delay(package.author.display_name,
"Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True,
package.title, package.short_desc, package.get_thumb_url(2, True))
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
db.session.commit()
if package.state == PackageState.CHANGES_NEEDED:
flash("Please comment what changes are needed in the review thread", "warning")
flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
if package.review_thread:
return redirect(package.review_thread.getViewURL())
return redirect(package.review_thread.get_view_url())
else:
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@@ -390,57 +460,68 @@ def move_to_state(package):
@is_package_page
def remove(package):
if request.method == "GET":
return render_template("packages/remove.html", package=package)
return render_template("packages/remove.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="remove")
reason = request.form.get("reason") or "?"
if "delete" in request.form:
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
flash("You don't have permission to do that.", "danger")
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.DELETE_PACKAGE):
flash(gettext("You don't have permission to do that"), "danger")
return redirect(package.get_url("packages.view"))
package.state = PackageState.DELETED
url = url_for("users.profile", username=package.author.username)
msg = "Deleted {}".format(package.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
msg = "Deleted {}, reason={}".format(package.title, reason)
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
add_audit_log(AuditSeverity.EDITOR, current_user, msg, url, package)
db.session.commit()
flash("Deleted package", "success")
post_discord_webhook.delay(current_user.username,
f"Deleted package {package.author.username}/{package.name} with reason '{reason}'",
True, package.title, package.short_desc, package.get_thumb_url(2, True))
flash(gettext("Deleted package"), "success")
return redirect(url)
elif "unapprove" in request.form:
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash("You don't have permission to do that.", "danger")
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.UNAPPROVE_PACKAGE):
flash(gettext("You don't have permission to do that"), "danger")
return redirect(package.get_url("packages.view"))
package.state = PackageState.WIP
msg = "Unapproved {}".format(package.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getDetailsURL(), package)
msg = "Unapproved {}, reason={}".format(package.title, reason)
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package)
add_audit_log(AuditSeverity.EDITOR, current_user, msg, package.get_url("packages.view"), package)
db.session.commit()
flash("Unapproved package", "success")
post_discord_webhook.delay(current_user.username,
"Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True,
package.title, package.short_desc, package.get_thumb_url(2, True))
return redirect(package.getDetailsURL())
flash(gettext("Unapproved package"), "success")
return redirect(package.get_url("packages.view"))
else:
abort(400)
class PackageMaintainersForm(FlaskForm):
maintainers_str = StringField("Maintainers (Comma-separated)", [Optional()])
submit = SubmitField("Save")
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_maintainers(package):
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
flash("You do not have permission to edit maintainers", "danger")
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.EDIT_MAINTAINERS):
flash(gettext("You don't have permission to edit maintainers"), "danger")
return redirect(package.get_url("packages.view"))
form = PackageMaintainersForm(formdata=request.form)
if request.method == "GET":
@@ -450,15 +531,19 @@ def edit_maintainers(package):
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
users = User.query.filter(func.lower(User.username).in_(usernames)).all()
thread = package.threads.filter_by(author=get_system_user()).first()
for user in users:
if not user in package.maintainers:
addNotification(user, current_user, NotificationType.MAINTAINER,
"Added you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
if thread:
thread.watchers.append(user)
add_notification(user, current_user, NotificationType.MAINTAINER,
"Added you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
for user in package.maintainers:
if user != package.author and not user in users:
addNotification(user, current_user, NotificationType.MAINTAINER,
"Removed you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
add_notification(user, current_user, NotificationType.MAINTAINER,
"Removed you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
package.maintainers.clear()
package.maintainers.extend(users)
@@ -466,18 +551,18 @@ def edit_maintainers(package):
package.maintainers.append(package.author)
msg = "Edited {} maintainers".format(package.title)
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getDetailsURL(), package)
add_notification(package.author, current_user, NotificationType.MAINTAINER, msg, package.get_url("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
return render_template("packages/edit_maintainers.html",
package=package, form=form, users=users)
return render_template("packages/edit_maintainers.html", package=package, form=form,
users=users, tabs=get_package_tabs(current_user, package), current_tab="maintainers")
@bp.route("/packages/<author>/<name>/remove-self-maintainer/", methods=["POST"])
@@ -485,45 +570,253 @@ def edit_maintainers(package):
@is_package_page
def remove_self_maintainers(package):
if not current_user in package.maintainers:
flash("You are not a maintainer", "danger")
flash(gettext("You are not a maintainer"), "danger")
elif current_user == package.author:
flash("Package owners cannot remove themselves as maintainers", "danger")
flash(gettext("Package owners cannot remove themselves as maintainers"), "danger")
else:
package.maintainers.remove(current_user)
addNotification(package.author, current_user, NotificationType.MAINTAINER,
"Removed themself as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
add_notification(package.author, current_user, NotificationType.MAINTAINER,
"Removed themself as a maintainer of {}".format(package.title), package.get_url("packages.view"), package)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
@bp.route("/packages/<author>/<name>/import-meta/", methods=["POST"])
@bp.route("/packages/<author>/<name>/audit/")
@login_required
@is_package_page
def update_from_release(package):
if not package.checkPerm(current_user, Permission.REIMPORT_META):
flash("You don't have permission to reimport meta", "danger")
return redirect(package.getDetailsURL())
def audit(package):
if not (package.check_perm(current_user, Permission.EDIT_PACKAGE) or
package.check_perm(current_user, Permission.APPROVE_NEW)):
abort(403)
release = package.releases.first()
if not release:
flash("Release needed", "danger")
return redirect(package.getDetailsURL())
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
msg = "Updated meta from latest release"
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT,
msg, package.getDetailsURL(), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
db.session.commit()
pagination = query.paginate(page=page, per_page=num)
return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
task_id = uuid()
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
checkZipRelease.apply_async((release.id, zippath), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=package.getEditURL()))
class PackageAliasForm(FlaskForm):
author = StringField(lazy_gettext("Author Name"), [InputRequired(), Length(1, 50)])
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100),
Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/aliases/")
@rank_required(UserRank.ADMIN)
@is_package_page
def alias_list(package: Package):
return render_template("packages/alias_list.html", package=package)
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
@is_package_page
def alias_create_edit(package: Package, alias_id: int = None):
alias = None
if alias_id:
alias = PackageAlias.query.get(alias_id)
if alias is None or alias.package != package:
abort(404)
form = PackageAliasForm(request.form, obj=alias)
if form.validate_on_submit():
if alias is None:
alias = PackageAlias()
alias.package = package
db.session.add(alias)
form.populate_obj(alias)
db.session.commit()
return redirect(package.get_url("packages.alias_list"))
return render_template("packages/alias_create_edit.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/share/")
@login_required
@is_package_page
def share(package):
return render_template("packages/share.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="share")
@bp.route("/packages/<author>/<name>/similar/")
@is_package_page
def similar(package):
packages_modnames = {}
for mname in package.provides:
packages_modnames[mname] = Package.query.filter(Package.id != package.id,
Package.state != PackageState.DELETED) \
.filter(Package.provides.any(PackageProvides.c.metapackage_id == mname.id)) \
.order_by(db.desc(Package.score)) \
.all()
similar_topics = ForumTopic.query \
.filter_by(name=package.name) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
return render_template("packages/similar.html", package=package,
packages_modnames=packages_modnames, similar_topics=similar_topics)
class GameSupportForm(FlaskForm):
enable_support_detection = BooleanField(lazy_gettext("Enable support detection based on dependencies (recommended)"), [Optional()])
supported = StringField(lazy_gettext("Supported games"), [Optional()])
unsupported = StringField(lazy_gettext("Unsupported games"), [Optional()])
supports_all_games = BooleanField(lazy_gettext("Supports all games (unless stated) / is game independent"), [Optional()])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/support/", methods=["GET", "POST"])
@login_required
@is_package_page
def game_support(package):
if package.type != PackageType.MOD and package.type != PackageType.TXP:
abort(404)
can_edit = package.check_perm(current_user, Permission.EDIT_PACKAGE)
if not (can_edit or package.check_perm(current_user, Permission.APPROVE_NEW)):
abort(403)
if package.releases.count() == 0:
flash(gettext("You need at least one release before you can edit game support"), "danger")
return redirect(package.get_url('packages.create_release' if package.update_config else 'packages.setup_releases'))
if package.type == PackageType.MOD and len(package.provides) == 0:
flash(gettext("Mod(pack) needs to contain at least one mod. Please create a new release"), "danger")
return redirect(package.get_url('packages.list_releases'))
force_game_detection = package.supported_games.filter(and_(
PackageGameSupport.confidence > 1, PackageGameSupport.supports == True)).count() == 0
can_support_all_games = package.type != PackageType.TXP and \
package.supported_games.filter(and_(
PackageGameSupport.confidence == 1, PackageGameSupport.supports == True)).count() == 0
can_override = can_edit
form = GameSupportForm() if can_edit else None
if form and request.method == "GET":
form.enable_support_detection.data = package.enable_game_support_detection
form.supports_all_games.data = package.supports_all_games and can_support_all_games
if can_override:
manual_supported_games = package.supported_games.filter_by(confidence=11).all()
form.supported.data = ", ".join([x.game.name for x in manual_supported_games if x.supports])
form.unsupported.data = ", ".join([x.game.name for x in manual_supported_games if not x.supports])
else:
form.supported = None
form.unsupported = None
if form and form.validate_on_submit():
detect_update_needed = False
if can_override:
try:
resolver = GameSupportResolver(db.session)
game_is_supported = {}
for game in get_games_from_csv(db.session, form.supported.data or ""):
game_is_supported[game.id] = True
for game in get_games_from_csv(db.session, form.unsupported.data or ""):
game_is_supported[game.id] = False
resolver.set_supported(package, game_is_supported, 11)
detect_update_needed = True
except LogicError as e:
flash(e.message, "danger")
next_url = package.get_url("packages.game_support")
enable_support_detection = form.enable_support_detection.data or force_game_detection
if enable_support_detection != package.enable_game_support_detection:
package.enable_game_support_detection = enable_support_detection
if package.enable_game_support_detection:
detect_update_needed = True
else:
package.supported_games.filter_by(confidence=1).delete()
if can_support_all_games:
package.supports_all_games = form.supports_all_games.data
add_audit_log(AuditSeverity.NORMAL, current_user, "Edited game support", package.get_url("packages.game_support"), package)
db.session.commit()
if detect_update_needed:
release = package.releases.first()
if release:
task_id = uuid()
check_zip_release.apply_async((release.id, release.file_path), task_id=task_id)
next_url = url_for("tasks.check", id=task_id, r=next_url)
return redirect(next_url)
all_game_support = package.supported_games.all()
all_game_support.sort(key=lambda x: -x.game.score)
supported_games_list: typing.List[str] = [x.game.name for x in all_game_support if x.supports]
if package.supports_all_games:
supported_games_list.insert(0, "*")
supported_games = ", ".join(supported_games_list)
unsupported_games = ", ".join([x.game.name for x in all_game_support if not x.supports])
mod_conf_lines = ""
if supported_games:
mod_conf_lines += f"supported_games = {supported_games}"
if unsupported_games:
mod_conf_lines += f"\nunsupported_games = {unsupported_games}"
return render_template("packages/game_support.html", package=package, form=form,
mod_conf_lines=mod_conf_lines, force_game_detection=force_game_detection,
can_support_all_games=can_support_all_games, tabs=get_package_tabs(current_user, package),
current_tab="game_support")
@bp.route("/packages/<author>/<name>/stats/")
@is_package_page
def statistics(package):
start = request.args.get("start")
end = request.args.get("end")
return render_template("packages/stats.html",
package=package, tabs=get_package_tabs(current_user, package), current_tab="stats",
start=start, end=end, options=get_daterange_options(), noindex=start or end)
@bp.route("/packages/<author>/<name>/stats.csv")
@is_package_page
def stats_csv(package):
stats: typing.List[PackageDailyStats] = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all()
columns = ["platform_minetest", "platform_other", "reason_new",
"reason_dependency", "reason_update"]
result = "Date, " + ", ".join(columns) + "\n"
for stat in stats:
stat: PackageDailyStats
result += stat.date.isoformat()
for i, key in enumerate(columns):
result += ", " + str(getattr(stat, key))
result += "\n"
date = datetime.datetime.utcnow().date()
res = make_response(result, 200)
res.headers["Content-Disposition"] = f"attachment; filename={package.author.username}_{package.name}_stats_{date.isoformat()}.csv"
res.headers["Content-type"] = "text/csv"
return res

View File

@@ -14,19 +14,29 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_login import login_required
from flask import render_template, request, redirect, flash, url_for, abort
from flask_babel import lazy_gettext, gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField
from wtforms.validators import InputRequired, Length, Optional
from wtforms_sqlalchemy.fields import QuerySelectField
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, MinetestRelease, \
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
from app.rediscache import has_key, set_key, make_download_key
from app.tasks.importtasks import check_update_config
from app.utils import *
from . import bp
from app.utils import is_user_bot, is_package_page, nonempty_or_none
from . import bp, get_package_tabs
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
@is_package_page
def list_releases(package):
return render_template("packages/releases_list.html",
package=package,
tabs=get_package_tabs(current_user, package), current_tab="releases")
def get_mt_releases(is_max):
@@ -40,38 +50,40 @@ def get_mt_releases(is_max):
class CreatePackageReleaseForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 30)])
uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
vcsLabel = StringField("Git reference (ie: commit hash, branch, or tag)", default=None)
fileUpload = FileField("File Upload")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
file_upload = FileField(lazy_gettext("File Upload"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
class EditPackageReleaseForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 30)])
url = StringField("URL", [Optional()])
task_id = StringField("Task ID", filters = [lambda x: x or None])
approved = BooleanField("Is Approved")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
url = StringField(lazy_gettext("URL"), [Optional()])
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None])
approved = BooleanField(lazy_gettext("Is Approved"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
@login_required
@is_package_page
def create_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
return redirect(package.get_url("packages.view"))
# Initial form class from post data and default data
form = CreatePackageReleaseForm()
if package.repo is not None:
form["uploadOpt"].choices = [("vcs", "Import from Git"), ("upload", "Upload .zip file")]
form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
if request.method == "GET":
form["uploadOpt"].data = "vcs"
form.vcsLabel.data = request.args.get("ref")
@@ -83,18 +95,18 @@ def create_release(package):
try:
if form["uploadOpt"].data == "vcs":
rel = do_create_vcs_release(current_user, package, form.title.data,
form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
form.vcsLabel.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
else:
rel = do_create_zip_release(current_user, package, form.title.data,
form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
form.file_upload.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual())
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.get_edit_url()))
except LogicError as e:
flash(e.message, "danger")
return render_template("packages/release_new.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
@bp.route("/packages/<author>/<name>/releases/<int:id>/download/")
@is_package_page
def download_release(package, id):
release = PackageRelease.query.get(id)
@@ -103,11 +115,19 @@ def download_release(package, id):
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
if ip is not None and not is_user_bot():
is_minetest = (request.headers.get("User-Agent") or "").startswith("Minetest")
reason = request.args.get("reason")
PackageDailyStats.update(package, is_minetest, reason)
key = make_download_key(ip, release.package)
if not has_key(key):
set_key(key, "true")
bonus = 1
bonus = 0
if reason == "new":
bonus = 1
elif reason == "dependency" or reason == "update":
bonus = 0.5
PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1
@@ -119,12 +139,12 @@ def download_release(package, id):
"score": Package.score + bonus
})
db.session.commit()
db.session.commit()
return redirect(release.url, code=300)
return redirect(release.url)
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/releases/<int:id>/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_release(package, id):
@@ -132,10 +152,10 @@ def edit_release(package, id):
if release is None or release.package != package:
abort(404)
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
canApprove = package.checkPerm(current_user, Permission.APPROVE_RELEASE)
canEdit = package.check_perm(current_user, Permission.MAKE_RELEASE)
canApprove = release.check_perm(current_user, Permission.APPROVE_RELEASE)
if not (canEdit or canApprove):
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
# Initial form class from post data and default data
form = EditPackageReleaseForm(formdata=request.form, obj=release)
@@ -147,10 +167,10 @@ def edit_release(package, id):
if form.validate_on_submit():
if canEdit:
release.title = form["title"].data
release.min_rel = form["min_rel"].data.getActual()
release.max_rel = form["max_rel"].data.getActual()
release.min_rel = form["min_rel"].data.get_actual()
release.max_rel = form["max_rel"].data.get_actual()
if package.checkPerm(current_user, Permission.CHANGE_RELEASE_URL):
if package.check_perm(current_user, Permission.CHANGE_RELEASE_URL):
release.url = form["url"].data
release.task_id = form["task_id"].data
if release.task_id is not None:
@@ -162,29 +182,29 @@ def edit_release(package, id):
release.approved = False
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.list_releases"))
return render_template("packages/release_edit.html", package=package, release=release, form=form)
class BulkReleaseForm(FlaskForm):
set_min = BooleanField("Set Min")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
set_min = BooleanField(lazy_gettext("Set Min"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
set_max = BooleanField("Set Max")
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
set_max = BooleanField(lazy_gettext("Set Max"))
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
only_change_none = BooleanField("Only change values previously set as none")
submit = SubmitField("Update")
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none"))
submit = SubmitField(lazy_gettext("Update"))
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@login_required
@is_package_page
def bulk_change_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
return redirect(package.get_url("packages.view"))
# Initial form class from post data and default data
form = BulkReleaseForm()
@@ -196,18 +216,18 @@ def bulk_change_release(package):
for release in package.releases.all():
if form["set_min"].data and (not only_change_none or release.min_rel is None):
release.min_rel = form["min_rel"].data.getActual()
release.min_rel = form["min_rel"].data.get_actual()
if form["set_max"].data and (not only_change_none or release.max_rel is None):
release.max_rel = form["max_rel"].data.getActual()
release.max_rel = form["max_rel"].data.get_actual()
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.list_releases"))
return render_template("packages/release_bulk_change.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<id>/delete/", methods=["POST"])
@bp.route("/packages/<author>/<name>/releases/<int:id>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_release(package, id):
@@ -215,22 +235,26 @@ def delete_release(package, id):
if release is None or release.package != package:
abort(404)
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
return redirect(release.getEditURL())
if not release.check_perm(current_user, Permission.DELETE_RELEASE):
return redirect(package.get_url("packages.list_releases"))
db.session.delete(release)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
class PackageUpdateConfigFrom(FlaskForm):
trigger = RadioField("Trigger", [InputRequired()], choices=PackageUpdateTrigger.choices(), coerce=PackageUpdateTrigger.coerce,
default=PackageUpdateTrigger.TAG)
ref = StringField("Branch name", [Optional()], default=None)
action = RadioField("Action", [InputRequired()], choices=[("notification", "Send notification and mark as outdated"), ("make_release", "Create release")], default="make_release")
submit = SubmitField("Save Settings")
disable = SubmitField("Disable Automation")
trigger = RadioField(lazy_gettext("Trigger"), [InputRequired()],
choices=[(PackageUpdateTrigger.COMMIT, lazy_gettext("New Commit")),
(PackageUpdateTrigger.TAG, lazy_gettext("New Tag"))],
coerce=PackageUpdateTrigger.coerce, default=PackageUpdateTrigger.TAG)
ref = StringField(lazy_gettext("Branch name"), [Optional()], default=None)
action = RadioField(lazy_gettext("Action"), [InputRequired()],
choices=[("notification", lazy_gettext("Send notification and mark as outdated")), ("make_release", lazy_gettext("Create release"))],
default="make_release")
submit = SubmitField(lazy_gettext("Save Settings"))
disable = SubmitField(lazy_gettext("Disable Automation"))
def set_update_config(package, form):
@@ -239,7 +263,7 @@ def set_update_config(package, form):
db.session.add(package.update_config)
form.populate_obj(package.update_config)
package.update_config.ref = nonEmptyOrNone(form.ref.data)
package.update_config.ref = nonempty_or_none(form.ref.data)
package.update_config.make_release = form.action.data == "make_release"
if package.update_config.trigger == PackageUpdateTrigger.COMMIT:
@@ -265,12 +289,12 @@ def set_update_config(package, form):
@login_required
@is_package_page
def update_config(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
abort(403)
if not package.repo:
flash("Please add a Git repository URL in order to set up automatic releases", "danger")
return redirect(package.getEditURL())
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
return redirect(package.get_url("packages.create_edit"))
form = PackageUpdateConfigFrom(obj=package.update_config)
if request.method == "GET":
@@ -280,9 +304,12 @@ def update_config(package):
form.trigger.data = PackageUpdateTrigger.COMMIT
form.action.data = "notification"
if "trigger" in request.args:
form.trigger.data = PackageUpdateTrigger.get(request.args["trigger"])
if form.validate_on_submit():
if form.disable.data:
flash("Deleted update configuration", "success")
flash(gettext("Deleted update configuration"), "success")
if package.update_config:
db.session.delete(package.update_config)
db.session.commit()
@@ -290,10 +317,10 @@ def update_config(package):
set_update_config(package, form)
if not form.disable.data and package.releases.count() == 0:
flash("Now, please create an initial release", "success")
return redirect(package.getCreateReleaseURL())
flash(gettext("Now, please create an initial release"), "success")
return redirect(package.get_url("packages.create_release"))
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.list_releases"))
return render_template("packages/update_config.html", package=package, form=form)
@@ -302,11 +329,11 @@ def update_config(package):
@login_required
@is_package_page
def setup_releases(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
abort(403)
if package.update_config:
return redirect(package.getUpdateConfigURL())
return redirect(package.get_url("packages.update_config"))
return render_template("packages/release_wizard.html", package=package)
@@ -322,7 +349,7 @@ def bulk_update_config(username=None):
if not user:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
abort(403)
form = PackageUpdateConfigFrom()

View File

@@ -14,15 +14,22 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from . import bp
from collections import namedtuple
from flask import *
import typing
from flask import render_template, request, redirect, flash, url_for, abort, jsonify
from flask_babel import gettext, lazy_gettext
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType
from app.utils import is_package_page, addNotification, get_int_or_abort
from wtforms import StringField, TextAreaField, SubmitField, RadioField
from wtforms.validators import InputRequired, Length
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
Permission, AuditSeverity, PackageState
from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_package_page, add_notification, get_int_or_abort, is_yes, is_safe_url, rank_required, \
add_audit_log, has_blocked_domains, should_return_json
from . import bp
@bp.route("/reviews/")
@@ -30,114 +37,223 @@ def list_reviews():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page, num, True)
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page=page, per_page=num)
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
class ReviewForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
recommends = RadioField("Private", [InputRequired()], choices=[("yes", "Yes"), ("no", "No")])
submit = SubmitField("Save")
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
rating = RadioField(lazy_gettext("Rating"), [InputRequired()],
choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))])
btn_submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
@login_required
@is_package_page
def review(package):
if current_user in package.maintainers:
flash("You can't review your own package!", "danger")
return redirect(package.getDetailsURL())
flash(gettext("You can't review your own package!"), "danger")
return redirect(package.get_url("packages.view"))
if package.state != PackageState.APPROVED:
abort(404)
review = PackageReview.query.filter_by(package=package, author=current_user).first()
can_review = review is not None or current_user.can_review_ratelimit()
if not can_review:
flash(gettext("You've reviewed too many packages recently. Please wait before trying again, and consider making your reviews more detailed"), "danger")
form = ReviewForm(formdata=request.form, obj=review)
# Set default values
if request.method == "GET" and review:
form.title.data = review.thread.title
form.recommends.data = "yes" if review.recommends else "no"
form.comment.data = review.thread.replies[0].comment
form.rating.data = str(review.rating)
form.comment.data = review.thread.first_reply.comment
# Validate and submit
elif form.validate_on_submit():
was_new = False
if not review:
was_new = True
review = PackageReview()
review.package = package
review.author = current_user
db.session.add(review)
review.recommends = form.recommends.data == "yes"
thread = review.thread
if not thread:
thread = Thread()
thread.author = current_user
thread.private = False
thread.package = package
thread.review = review
db.session.add(thread)
thread.watchers.append(current_user)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
thread.replies.append(reply)
elif can_review and form.validate_on_submit():
if has_blocked_domains(form.comment.data, current_user.username, f"review of {package.get_id()}"):
flash(gettext("Linking to blocked sites is not allowed"), "danger")
else:
reply = thread.replies[0]
reply.comment = form.comment.data
was_new = False
if not review:
was_new = True
review = PackageReview()
review.package = package
review.author = current_user
db.session.add(review)
thread.title = form.title.data
review.rating = int(form.rating.data)
db.session.commit()
thread = review.thread
if not thread:
thread = Thread()
thread.author = current_user
thread.private = False
thread.package = package
thread.review = review
db.session.add(thread)
package.recalcScore()
thread.watchers.append(current_user)
if was_new:
notif_msg = "New review '{}'".format(form.title.data)
type = NotificationType.NEW_REVIEW
else:
notif_msg = "Updated review '{}'".format(form.title.data)
type = NotificationType.OTHER
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
addNotification(package.maintainers, current_user, type, notif_msg,
url_for("threads.view", id=thread.id), package)
thread.replies.append(reply)
else:
reply = thread.first_reply
reply.comment = form.comment.data
db.session.commit()
thread.title = form.title.data
return redirect(package.getDetailsURL())
db.session.commit()
package.recalculate_score()
if was_new:
notif_msg = "New review '{}'".format(form.title.data)
type = NotificationType.NEW_REVIEW
else:
notif_msg = "Updated review '{}'".format(form.title.data)
type = NotificationType.OTHER
add_notification(package.maintainers, current_user, type, notif_msg,
url_for("threads.view", id=thread.id), package)
if was_new:
post_discord_webhook.delay(thread.author.display_name,
"Reviewed {}: {}".format(package.title, thread.get_view_url(absolute=True)), False)
db.session.commit()
return redirect(package.get_url("packages.view"))
return render_template("packages/review_create_edit.html",
form=form, package=package, review=review)
@bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"])
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_review(package):
review = PackageReview.query.filter_by(package=package, author=current_user).first()
def delete_review(package, reviewer):
review = PackageReview.query \
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
.first()
if review is None or review.package != package:
abort(404)
if not review.check_perm(current_user, Permission.DELETE_REVIEW):
abort(403)
thread = review.thread
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = "_converted review into a thread_"
reply.is_status_update = True
db.session.add(reply)
thread.review = None
msg = "Converted review by {} to thread".format(review.author.display_name)
add_audit_log(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
current_user, msg, thread.get_view_url(), thread.package)
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
add_notification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
db.session.delete(review)
package.recalculate_score()
db.session.commit()
return redirect(thread.getViewURL())
return redirect(thread.get_view_url())
def handle_review_vote(package: Package, review_id: int) -> typing.Optional[str]:
if current_user in package.maintainers:
return gettext("You can't vote on the reviews on your own package!")
review: PackageReview = PackageReview.query.get(review_id)
if review is None or review.package != package:
abort(404)
if review.author == current_user:
return gettext("You can't vote on your own reviews!")
is_positive = is_yes(request.form["is_positive"])
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
if vote is None:
vote = PackageReviewVote()
vote.review = review
vote.user = current_user
vote.is_positive = is_positive
db.session.add(vote)
elif vote.is_positive == is_positive:
db.session.delete(vote)
else:
vote.is_positive = is_positive
review.update_score()
db.session.commit()
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
@login_required
@is_package_page
def review_vote(package, review_id):
msg = handle_review_vote(package, review_id)
if should_return_json():
if msg:
return jsonify({"success": False, "error": msg}), 403
else:
return jsonify({"success": True})
if msg:
flash(msg, "danger")
next_url = request.args.get("r")
if next_url and is_safe_url(next_url):
return redirect(next_url)
else:
return redirect(review.thread.get_view_url())
@bp.route("/packages/<author>/<name>/review-votes/")
@rank_required(UserRank.ADMIN)
@is_package_page
def review_votes(package):
user_biases = {}
for review in package.reviews:
review_sign = review.as_weight()
for vote in review.votes:
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
vote_sign = 1 if vote.is_positive else -1
vote_bias = review_sign * vote_sign
if vote_bias == 1:
user_biases[vote.user.username][0] += 1
else:
user_biases[vote.user.username][1] += 1
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
user_biases_info = []
for username, bias in user_biases.items():
total_votes = bias[0] + bias[1]
balance = bias[0] - bias[1]
perc_with = round((100 * bias[0]) / total_votes)
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(package.reviews) - total_votes, perc_with))
user_biases_info.sort(key=lambda x: -abs(x.balance))
return render_template("packages/review_votes.html", package=package, reviews=package.reviews,
user_biases=user_biases_info)

View File

@@ -14,46 +14,44 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import render_template, request, redirect, flash, url_for, abort
from flask_babel import lazy_gettext, gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_login import login_required
from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from wtforms import StringField, SubmitField, BooleanField, FileField
from wtforms.validators import InputRequired, Length, DataRequired, Optional
from wtforms_sqlalchemy.fields import QuerySelectField
from app.utils import *
from . import bp
from app.logic.LogicError import LogicError
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
from . import bp, get_package_tabs
from app.models import Permission, db, PackageScreenshot
from app.utils import is_package_page
class CreateScreenshotForm(FlaskForm):
title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
fileUpload = FileField("File Upload", [InputRequired()])
submit = SubmitField("Save")
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
file_upload = FileField(lazy_gettext("File Upload"), [InputRequired()])
submit = SubmitField(lazy_gettext("Save"))
class EditScreenshotForm(FlaskForm):
title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
approved = BooleanField("Is Approved")
submit = SubmitField("Save")
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
approved = BooleanField(lazy_gettext("Is Approved"))
submit = SubmitField(lazy_gettext("Save"))
class EditPackageScreenshotsForm(FlaskForm):
cover_image = QuerySelectField("Cover Image", [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField("Save")
cover_image = QuerySelectField(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
@login_required
@is_package_page
def screenshots(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getDetailsURL())
if package.screenshots.count() == 0:
return redirect(package.getNewScreenshotURL())
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.get_url("packages.view"))
form = EditPackageScreenshotsForm(obj=package)
form.cover_image.query = package.screenshots
@@ -63,7 +61,7 @@ def screenshots(package):
if order:
try:
do_order_screenshots(current_user, package, order.split(","))
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
except LogicError as e:
flash(e.message, "danger")
@@ -71,29 +69,30 @@ def screenshots(package):
form.populate_obj(package)
db.session.commit()
return render_template("packages/screenshots.html", package=package, form=form)
return render_template("packages/screenshots.html", package=package, form=form,
tabs=get_package_tabs(current_user, package), current_tab="screenshots")
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
@login_required
@is_package_page
def create_screenshot(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getDetailsURL())
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.get_url("packages.view"))
# Initial form class from post data and default data
form = CreateScreenshotForm()
if form.validate_on_submit():
try:
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
return redirect(package.getEditScreenshotsURL())
do_create_screenshot(current_user, package, form.title.data, form.file_upload.data, False)
return redirect(package.get_url("packages.screenshots"))
except LogicError as e:
flash(e.message, "danger")
return render_template("packages/screenshot_new.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/edit/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_screenshot(package, id):
@@ -101,31 +100,31 @@ def edit_screenshot(package, id):
if screenshot is None or screenshot.package != package:
abort(404)
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
if not (canEdit or canApprove):
return redirect(package.getEditScreenshotsURL())
can_edit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS)
can_approve = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT)
if not (can_edit or can_approve):
return redirect(package.get_url("packages.screenshots"))
# Initial form class from post data and default data
form = EditScreenshotForm(obj=screenshot)
if form.validate_on_submit():
wasApproved = screenshot.approved
was_approved = screenshot.approved
if canEdit:
if can_edit:
screenshot.title = form["title"].data or "Untitled"
if canApprove:
if can_approve:
screenshot.approved = form["approved"].data
else:
screenshot.approved = wasApproved
screenshot.approved = was_approved
db.session.commit()
return redirect(package.getEditScreenshotsURL())
return redirect(package.get_url("packages.screenshots"))
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
@bp.route("/packages/<author>/<name>/screenshots/<id>/delete/", methods=["POST"])
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_screenshot(package, id):
@@ -133,8 +132,8 @@ def delete_screenshot(package, id):
if screenshot is None or screenshot.package != package:
abort(404)
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
flash("Permission denied", "danger")
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS):
flash(gettext("Permission denied"), "danger")
return redirect(url_for("homepage.home"))
if package.cover_image == screenshot:
@@ -144,4 +143,4 @@ def delete_screenshot(package, id):
db.session.delete(screenshot)
db.session.commit()
return redirect(package.getEditScreenshotsURL())
return redirect(package.get_url("packages.screenshots"))

View File

@@ -0,0 +1,67 @@
# ContentDB
# Copyright (C) 2022 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, request, render_template, url_for, abort
from flask_babel import lazy_gettext
from flask_login import current_user
from flask_wtf import FlaskForm
from werkzeug.utils import redirect
from wtforms import TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length
from app.models import User, UserRank
from app.tasks.emails import send_user_email
from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_no, abs_url_samesite
bp = Blueprint("report", __name__)
class ReportForm(FlaskForm):
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)])
submit = SubmitField(lazy_gettext("Report"))
@bp.route("/report/", methods=["GET", "POST"])
def report():
is_anon = not current_user.is_authenticated or not is_no(request.args.get("anon"))
url = request.args.get("url")
if url:
if url.startswith("/report/"):
abort(404)
url = abs_url_samesite(url)
form = ReportForm(formdata=request.form) if current_user.is_authenticated else None
if form and form.validate_on_submit():
if current_user.is_authenticated:
user_info = f"{current_user.username}"
else:
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
text = f"{url}\n\n{form.message.data}"
task = None
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
task = send_user_email.delay(admin.email, admin.locale or "en",
f"User report from {user_info}", text)
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)

View File

@@ -14,28 +14,31 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_login import login_required
from flask import Blueprint, jsonify, url_for, request, redirect, render_template
from flask_login import login_required, current_user
from app import csrf
from app.models import UserRank
from app.tasks import celery
from app.tasks.importtasks import getMeta
from app.utils import *
from app.tasks.importtasks import get_meta
from app.utils import should_return_json
bp = Blueprint("tasks", __name__)
@csrf.exempt
@bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required
def start_getmeta():
from flask import request
author = request.args.get("author")
author = current_user.forums_username if author is None else author
aresult = getMeta.delay(request.args.get("url"), author)
aresult = get_meta.delay(request.args.get("url"), author)
return jsonify({
"poll_url": url_for("tasks.check", id=aresult.id),
})
@bp.route("/tasks/<id>/")
def check(id):
result = celery.AsyncResult(id)
@@ -43,14 +46,13 @@ def check(id):
traceback = result.traceback
result = result.result
None
if isinstance(result, Exception):
info = {
'id': id,
'status': status,
}
if current_user.is_authenticated and current_user.rank.atLeast(UserRank.ADMIN):
if current_user.is_authenticated and current_user.rank.at_least(UserRank.ADMIN):
info["error"] = str(traceback)
elif str(result)[1:12] == "TaskError: ":
info["error"] = str(result)[12:-1]
@@ -63,7 +65,7 @@ def check(id):
'result': result,
}
if shouldReturnJson():
if should_return_json():
return jsonify(info)
else:
r = request.args.get("r")

View File

@@ -13,30 +13,39 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import Blueprint, request, render_template, abort, flash, redirect, url_for
from flask_babel import gettext, lazy_gettext
from sqlalchemy import or_
from sqlalchemy.orm import selectinload, joinedload
from app.markdown import get_user_mentions, render_markdown
from app.tasks.webhooktasks import post_discord_webhook
bp = Blueprint("threads", __name__)
from flask_login import current_user, login_required
from app import menu
from app.models import *
from app.utils import addNotification, isYes, addAuditLog
from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \
NotificationType, ThreadReply
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, TextAreaField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length
from app.utils import get_int_or_abort
@menu.register_menu(bp, ".threads", "Threads", order=20)
@bp.route("/threads/")
def list_all():
query = Thread.query
if not Permission.SEE_THREAD.check(current_user):
query = query.filter_by(private=False)
package = None
pid = request.args.get("pid")
if pid:
pid = get_int_or_abort(pid)
query = query.filter_by(package_id=pid)
package = Package.query.get_or_404(pid)
query = query.filter_by(package=package)
query = query.filter_by(review_id=None)
@@ -45,77 +54,77 @@ def list_all():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
pagination = query.paginate(page, num, True)
pagination = query.paginate(page=page, per_page=num)
return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
return render_template("threads/list.html", pagination=pagination, threads=pagination.items,
package=package, noindex=pid)
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@login_required
def subscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user in thread.watchers:
flash("Already subscribed!", "success")
flash(gettext("Already subscribed!"), "success")
else:
flash("Subscribed to thread", "success")
flash(gettext("Subscribed to thread"), "success")
thread.watchers.append(current_user)
db.session.commit()
return redirect(thread.getViewURL())
return redirect(thread.get_view_url())
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@login_required
def unsubscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user in thread.watchers:
flash("Unsubscribed!", "success")
flash(gettext("Unsubscribed!"), "success")
thread.watchers.remove(current_user)
db.session.commit()
else:
flash("Already not subscribed!", "success")
flash(gettext("Already not subscribed!"), "success")
return redirect(thread.getViewURL())
return redirect(thread.get_view_url())
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
@login_required
def set_lock(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.LOCK_THREAD):
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD):
abort(404)
thread.locked = isYes(request.args.get("lock"))
thread.locked = is_yes(request.args.get("lock"))
if thread.locked is None:
abort(400)
msg = None
if thread.locked:
msg = "Locked thread '{}'".format(thread.title)
flash("Locked thread", "success")
flash(gettext("Locked thread"), "success")
else:
msg = "Unlocked thread '{}'".format(thread.title)
flash("Unlocked thread", "success")
flash(gettext("Unlocked thread"), "success")
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(AuditSeverity.MODERATION, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
add_notification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package)
db.session.commit()
return redirect(thread.getViewURL())
return redirect(thread.get_view_url())
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
@login_required
def delete_thread(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.DELETE_THREAD):
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD):
abort(404)
if request.method == "GET":
@@ -127,7 +136,7 @@ def delete_thread(id):
db.session.delete(thread)
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
db.session.commit()
@@ -149,28 +158,28 @@ def delete_reply(id):
if reply is None or reply.thread != thread:
abort(404)
if thread.replies[0] == reply:
flash("Cannot delete thread opening post!", "danger")
return redirect(thread.getViewURL())
if thread.first_reply == reply:
flash(gettext("Cannot delete thread opening post!"), "danger")
return redirect(thread.get_view_url())
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
if not reply.check_perm(current_user, Permission.DELETE_REPLY):
abort(403)
if request.method == "GET":
return render_template("threads/delete_reply.html", thread=thread, reply=reply)
msg = "Deleted reply by {}".format(reply.author.display_name)
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
db.session.delete(reply)
db.session.commit()
return redirect(thread.getViewURL())
return redirect(thread.get_view_url())
class CommentForm(FlaskForm):
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
submit = SubmitField("Comment")
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)])
btn_submit = SubmitField(lazy_gettext("Comment"))
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@@ -184,75 +193,95 @@ def edit_reply(id):
if reply_id is None:
abort(404)
reply = ThreadReply.query.get(reply_id)
reply: ThreadReply = ThreadReply.query.get(reply_id)
if reply is None or reply.thread != thread:
abort(404)
if not reply.checkPerm(current_user, Permission.EDIT_REPLY):
if not reply.check_perm(current_user, Permission.EDIT_REPLY):
abort(403)
form = CommentForm(formdata=request.form, obj=reply)
if form.validate_on_submit():
comment = form.comment.data
if has_blocked_domains(comment, current_user.username, f"edit to reply {reply.get_url(True)}"):
flash(gettext("Linking to blocked sites is not allowed"), "danger")
else:
msg = "Edited reply by {}".format(reply.author.display_name)
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
add_notification(reply.author, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
add_audit_log(severity, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
msg = "Edited reply by {}".format(reply.author.display_name)
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
addNotification(reply.author, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
reply.comment = comment
reply.comment = comment
db.session.commit()
db.session.commit()
return redirect(thread.getViewURL())
return redirect(thread.get_view_url())
return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form)
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
thread: Thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user.is_authenticated and request.method == "POST":
comment = request.form["comment"]
form = CommentForm(formdata=request.form) if thread.check_perm(current_user, Permission.COMMENT_THREAD) else None
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
flash("You cannot comment on this thread", "danger")
return redirect(thread.getViewURL())
# Check that title is none to load comments into textarea if redirected from new thread page
if form and form.validate_on_submit() and request.form.get("title") is None:
comment = form.comment.data
if not current_user.canCommentRL():
flash("Please wait before commenting again", "danger")
return redirect(thread.getViewURL())
if not current_user.can_comment_ratelimit():
flash(gettext("Please wait before commenting again"), "danger")
return redirect(thread.get_view_url())
if 2000 >= len(comment) > 3:
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
if has_blocked_domains(comment, current_user.username, f"reply to {thread.get_view_url(True)}"):
flash(gettext("Linking to blocked sites is not allowed"), "danger")
return render_template("threads/view.html", thread=thread, form=form)
thread.replies.append(reply)
if not current_user in thread.watchers:
thread.watchers.append(current_user)
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
msg = "New comment on '{}'".format(thread.title)
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
db.session.commit()
thread.replies.append(reply)
if current_user not in thread.watchers:
thread.watchers.append(current_user)
return redirect(thread.getViewURL())
for mentioned_username in get_user_mentions(render_markdown(comment)):
mentioned = User.query.filter_by(username=mentioned_username).first()
if mentioned is None:
continue
else:
flash("Comment needs to be between 3 and 2000 characters.")
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY,
msg, thread.get_view_url(), thread.package)
return render_template("threads/view.html", thread=thread)
thread.watchers.append(mentioned)
msg = "New comment on '{}'".format(thread.title)
add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package)
if thread.author == get_system_user():
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
thread.get_view_url(), thread.package)
post_discord_webhook.delay(current_user.display_name,
"Replied to bot messages: {}".format(thread.get_view_url(absolute=True)), True)
db.session.commit()
return redirect(thread.get_view_url())
return render_template("threads/view.html", thread=thread, form=form)
class ThreadForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
private = BooleanField("Private")
submit = SubmitField("Open Thread")
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
private = BooleanField(lazy_gettext("Private"))
btn_submit = SubmitField(lazy_gettext("Open Thread"))
@bp.route("/threads/new/", methods=["GET", "POST"])
@@ -264,33 +293,33 @@ def new():
if "pid" in request.args:
package = Package.query.get(int(request.args.get("pid")))
if package is None:
flash("Unable to find that package!", "danger")
abort(404)
# Don't allow making orphan threads on approved packages for now
if package is None:
abort(403)
def_is_private = request.args.get("private") or False
if package is None and not current_user.rank.at_least(UserRank.APPROVER):
abort(404)
def_is_private = request.args.get("private") or False
if package is None:
def_is_private = True
allow_change = package and package.approved
is_review_thread = package and not package.approved
allow_private_change = not is_review_thread
if is_review_thread:
def_is_private = True
# Check that user can make the thread
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
flash("Unable to create thread!", "danger")
if package and not package.check_perm(current_user, Permission.CREATE_THREAD):
flash(gettext("Unable to create thread!"), "danger")
return redirect(url_for("homepage.home"))
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
flash("A review thread already exists!", "danger")
return redirect(package.review_thread.getViewURL())
# Redirect submit to `view` page, which checks for `title` in the form data and so won't commit the reply
flash(gettext("An approval thread already exists! Consider replying there instead"), "danger")
return redirect(package.review_thread.get_view_url(), code=307)
elif not current_user.canOpenThreadRL():
flash("Please wait before opening another thread", "danger")
elif not current_user.can_open_thread_ratelimit():
flash(gettext("Please wait before opening another thread"), "danger")
if package:
return redirect(package.getDetailsURL())
return redirect(package.get_url("packages.view"))
else:
return redirect(url_for("homepage.home"))
@@ -301,44 +330,78 @@ def new():
# Validate and submit
elif form.validate_on_submit():
thread = Thread()
thread.author = current_user
thread.title = form.title.data
thread.private = form.private.data if allow_change else def_is_private
thread.package = package
db.session.add(thread)
if has_blocked_domains(form.comment.data, current_user.username, f"new thread"):
flash(gettext("Linking to blocked sites is not allowed"), "danger")
else:
thread = Thread()
thread.author = current_user
thread.title = form.title.data
thread.private = form.private.data if allow_private_change else def_is_private
thread.package = package
db.session.add(thread)
thread.watchers.append(current_user)
if package is not None and package.author != current_user:
thread.watchers.append(package.author)
thread.watchers.append(current_user)
if package and package.author != current_user:
thread.watchers.append(package.author)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
thread.replies.append(reply)
thread.replies.append(reply)
db.session.commit()
db.session.commit()
if is_review_thread:
package.review_thread = thread
if is_review_thread:
package.review_thread = thread
if package.state == PackageState.READY_FOR_REVIEW and current_user not in package.maintainers:
package.state = PackageState.CHANGES_NEEDED
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
mentioned = User.query.filter_by(username=mentioned_username).first()
if mentioned is None:
continue
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
add_notification(mentioned, current_user, NotificationType.NEW_THREAD,
msg, thread.get_view_url(), thread.package)
thread.watchers.append(mentioned)
notif_msg = "New thread '{}'".format(thread.title)
if package is not None:
add_notification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.get_view_url(), package)
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.get_view_url(), package)
if is_review_thread:
post_discord_webhook.delay(current_user.display_name,
"Opened approval thread: {}".format(thread.get_view_url(absolute=True)), True)
db.session.commit()
return redirect(thread.get_view_url())
return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
notif_msg = "New thread '{}'".format(thread.title)
if package is not None:
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
@bp.route("/users/<username>/comments/")
def user_comments(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
editors = User.query.filter(User.rank >= UserRank.EDITOR).all()
addNotification(editors, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 40))
db.session.commit()
# Filter replies the current user can see
query = ThreadReply.query.options(selectinload(ThreadReply.thread)).filter_by(author=user)
only_public = False
if current_user != user and not (current_user.is_authenticated and current_user.rank.at_least(UserRank.APPROVER)):
query = query.filter(ThreadReply.thread.has(private=False))
only_public = True
return redirect(thread.getViewURL())
pagination = query.order_by(db.desc(ThreadReply.created_at)).paginate(page=page, per_page=num)
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
return render_template("threads/user_comments.html", user=user, pagination=pagination, only_public=only_public)

View File

@@ -22,7 +22,7 @@ bp = Blueprint("thumbnails", __name__)
import os
from PIL import Image
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233)]
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233), (1100,520)]
def mkdir(path):
assert path != "" and path is not None
@@ -68,7 +68,6 @@ def resize_and_crop(img_path, modified_path, size):
def make_thumbnail(img, level):
if level > len(ALLOWED_RESOLUTIONS) or level <= 0:
abort(403)
w, h = ALLOWED_RESOLUTIONS[level - 1]
upload_dir = current_app.config["UPLOAD_DIR"]

View File

@@ -14,234 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from celery import uuid
from flask import *
from flask_login import current_user, login_required
from sqlalchemy import or_
from app.models import *
from app.querybuilder import QueryBuilder
from app.utils import get_int_or_abort, addNotification, addAuditLog, isYes
from app.tasks.importtasks import makeVCSRelease
from flask import Blueprint
bp = Blueprint("todo", __name__)
@bp.route("/todo/", methods=["GET", "POST"])
@login_required
def view_editor():
canApproveNew = Permission.APPROVE_NEW.check(current_user)
canApproveRel = Permission.APPROVE_RELEASE.check(current_user)
canApproveScn = Permission.APPROVE_SCREENSHOT.check(current_user)
packages = None
wip_packages = None
if canApproveNew:
packages = Package.query.filter_by(state=PackageState.READY_FOR_REVIEW) \
.order_by(db.desc(Package.created_at)).all()
wip_packages = Package.query.filter(or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
.order_by(db.desc(Package.created_at)).all()
releases = None
if canApproveRel:
releases = PackageRelease.query.filter_by(approved=False).all()
screenshots = None
if canApproveScn:
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
if not canApproveNew and not canApproveRel and not canApproveScn:
abort(403)
if request.method == "POST":
if request.form["action"] == "screenshots_approve_all":
if not canApproveScn:
abort(403)
PackageScreenshot.query.update({ "approved": True })
db.session.commit()
return redirect(url_for("todo.view_editor"))
else:
abort(400)
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
unfulfilled_meta_packages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(optional=False)) \
.order_by(db.asc(MetaPackage.name)).count()
return render_template("todo/editor.html", current_tab="editor",
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
total_packages=total_packages, total_to_tag=total_to_tag,
unfulfilled_meta_packages=unfulfilled_meta_packages)
@bp.route("/todo/topics/")
@login_required
def topics():
qb = QueryBuilder(request.args)
qb.setSortIfNone("date")
query = qb.buildTopicQuery()
tmp_q = ForumTopic.query
if not qb.show_discarded:
tmp_q = tmp_q.filter_by(discarded=False)
total = tmp_q.count()
topic_count = query.count()
page = get_int_or_abort(request.args.get("page"), 1)
num = get_int_or_abort(request.args.get("n"), 100)
if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
num = 100
query = query.paginate(page, num, True)
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_next else None
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_prev else None
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
n=num, sort_by=qb.order_by)
@bp.route("/todo/tags/")
@login_required
def tags():
qb = QueryBuilder(request.args)
qb.setSortIfNone("score", "desc")
query = qb.buildPackageQuery()
tags = Tag.query.order_by(db.asc(Tag.title)).all()
return render_template("todo/tags.html", current_tab="tags", packages=query.all(), tags=tags)
@bp.route("/user/tags/")
def tags_user():
return redirect(url_for('todo.tags', author=current_user.username))
@bp.route("/todo/metapackages/")
@login_required
def metapackages():
mpackages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(optional=False)) \
.order_by(db.asc(MetaPackage.name)).all()
return render_template("todo/metapackages.html", mpackages=mpackages)
@bp.route("/user/todo/")
@bp.route("/users/<username>/todo/")
@login_required
def view_user(username=None):
if username is None:
return redirect(url_for("todo.view_user", username=current_user.username))
user : User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
abort(403)
unapproved_packages = user.packages \
.filter(or_(Package.state == PackageState.WIP,
Package.state == PackageState.CHANGES_NEEDED)) \
.order_by(db.asc(Package.created_at)).all()
outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.order_by(db.asc(Package.title)).all()
topics_to_add = ForumTopic.query \
.filter_by(author_id=user.id) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
needs_tags = user.maintained_packages \
.filter(Package.state != PackageState.DELETED) \
.filter_by(tags=None).order_by(db.asc(Package.title)).all()
return render_template("todo/user.html", current_tab="user", user=user,
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
needs_tags=needs_tags, topics_to_add=topics_to_add)
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
@login_required
def apply_all_updates(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
abort(403)
outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.order_by(db.asc(Package.title)).all()
for package in outdated_packages:
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
continue
if package.releases.filter(or_(PackageRelease.task_id.isnot(None),
PackageRelease.commit_hash==package.update_config.last_commit)).count() > 0:
continue
title = package.update_config.get_title()
ref = package.update_config.get_ref()
rel = PackageRelease()
rel.package = package
rel.title = title
rel.url = ""
rel.task_id = uuid()
db.session.add(rel)
db.session.commit()
makeVCSRelease.apply_async((rel.id, ref),
task_id=rel.task_id)
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
rel.getEditURL(), package)
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getDetailsURL(), package)
db.session.commit()
return redirect(url_for("todo.view_user", username=username))
@bp.route("/todo/outdated/")
@login_required
def outdated():
is_mtm_only = isYes(request.args.get("mtm"))
query = db.session.query(Package).select_from(PackageUpdateConfig) \
.filter(PackageUpdateConfig.outdated_at.isnot(None)) \
.join(PackageUpdateConfig.package) \
.filter(Package.state == PackageState.APPROVED)
if is_mtm_only:
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
sort_by = request.args.get("sort")
if sort_by == "date":
query = query.order_by(db.desc(PackageUpdateConfig.outdated_at))
else:
sort_by = "score"
query = query.order_by(db.desc(Package.score))
return render_template("todo/outdated.html", current_tab="outdated",
outdated_packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
from . import editor, user

View File

@@ -0,0 +1,222 @@
# ContentDB
# Copyright (C) 2018-23 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, url_for, abort, render_template, request
from flask_login import current_user, login_required
from sqlalchemy import or_
from app.models import Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, MinetestRelease
from app.querybuilder import QueryBuilder
from app.utils import get_int_or_abort, is_yes
from . import bp
@bp.route("/todo/", methods=["GET", "POST"])
@login_required
def view_editor():
can_approve_new = Permission.APPROVE_NEW.check(current_user)
can_approve_rel = Permission.APPROVE_RELEASE.check(current_user)
can_approve_scn = Permission.APPROVE_SCREENSHOT.check(current_user)
packages = None
wip_packages = None
if can_approve_new:
packages = Package.query.filter_by(state=PackageState.READY_FOR_REVIEW) \
.order_by(db.desc(Package.created_at)).all()
wip_packages = Package.query \
.filter(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
.order_by(db.desc(Package.created_at)).all()
releases = None
if can_approve_rel:
releases = PackageRelease.query.filter_by(approved=False).all()
screenshots = None
if can_approve_scn:
screenshots = PackageScreenshot.query.filter_by(approved=False).all()
if not can_approve_new and not can_approve_rel and not can_approve_scn:
abort(403)
if request.method == "POST":
if request.form["action"] == "screenshots_approve_all":
if not can_approve_scn:
abort(403)
PackageScreenshot.query.update({"approved": True})
db.session.commit()
return redirect(url_for("todo.view_editor"))
else:
abort(400)
license_needed = Package.query \
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
.filter(or_(Package.license.has(License.name.like("Other %")),
Package.media_license.has(License.name.like("Other %")))) \
.all()
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
unfulfilled_meta_packages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
.order_by(db.asc(MetaPackage.name)).count()
audit_log = AuditLogEntry.query \
.filter(AuditLogEntry.package.has()) \
.order_by(db.desc(AuditLogEntry.created_at)) \
.limit(20).all()
return render_template("todo/editor.html", current_tab="editor",
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
can_approve_new=can_approve_new, can_approve_rel=can_approve_rel, can_approve_scn=can_approve_scn,
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log)
@bp.route("/todo/topics/")
@login_required
def topics():
qb = QueryBuilder(request.args)
qb.set_sort_if_none("date")
query = qb.build_topic_query()
tmp_q = ForumTopic.query
if not qb.show_discarded:
tmp_q = tmp_q.filter_by(discarded=False)
total = tmp_q.count()
topic_count = query.count()
page = get_int_or_abort(request.args.get("page"), 1)
num = get_int_or_abort(request.args.get("n"), 100)
if num > 100 and not current_user.rank.at_least(UserRank.APPROVER):
num = 100
query = query.paginate(page=page, per_page=num)
next_url = url_for("todo.topics", page=query.next_num, query=qb.search,
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_next else None
prev_url = url_for("todo.topics", page=query.prev_num, query=qb.search,
show_discarded=qb.show_discarded, n=num, sort=qb.order_by) \
if query.has_prev else None
return render_template("todo/topics.html", current_tab="topics", topics=query.items, total=total,
topic_count=topic_count, query=qb.search, show_discarded=qb.show_discarded,
next_url=next_url, prev_url=prev_url, page=page, page_max=query.pages,
n=num, sort_by=qb.order_by)
@bp.route("/todo/tags/")
@login_required
def tags():
qb = QueryBuilder(request.args)
qb.set_sort_if_none("score", "desc")
query = qb.build_package_query()
only_no_tags = is_yes(request.args.get("no_tags"))
if only_no_tags:
query = query.filter(Package.tags == None)
tags = Tag.query.order_by(db.asc(Tag.title)).all()
return render_template("todo/tags.html", current_tab="tags", packages=query.all(),
tags=tags, only_no_tags=only_no_tags)
@bp.route("/todo/modnames/")
@login_required
def modnames():
mnames = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
.order_by(db.asc(MetaPackage.name)).all()
return render_template("todo/modnames.html", modnames=mnames)
@bp.route("/todo/outdated/")
@login_required
def outdated():
is_mtm_only = is_yes(request.args.get("mtm"))
query = db.session.query(Package).select_from(PackageUpdateConfig) \
.filter(PackageUpdateConfig.outdated_at.isnot(None)) \
.join(PackageUpdateConfig.package) \
.filter(Package.state == PackageState.APPROVED)
if is_mtm_only:
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
sort_by = request.args.get("sort")
if sort_by == "date":
query = query.order_by(db.desc(PackageUpdateConfig.outdated_at))
else:
sort_by = "score"
query = query.order_by(db.desc(Package.score))
return render_template("todo/outdated.html", current_tab="outdated",
outdated_packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
@bp.route("/todo/screenshots/")
@login_required
def screenshots():
is_mtm_only = is_yes(request.args.get("mtm"))
query = db.session.query(Package) \
.filter(~Package.screenshots.any()) \
.filter(Package.state == PackageState.APPROVED)
if is_mtm_only:
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
sort_by = request.args.get("sort")
if sort_by == "date":
query = query.order_by(db.desc(Package.approved_at))
else:
sort_by = "score"
query = query.order_by(db.desc(Package.score))
return render_template("todo/screenshots.html", current_tab="screenshots",
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only)
@bp.route("/todo/mtver_support/")
@login_required
def mtver_support():
is_mtm_only = is_yes(request.args.get("mtm"))
current_stable = MinetestRelease.query.filter(~MinetestRelease.name.like("%-dev")).order_by(db.desc(MinetestRelease.id)).first()
query = db.session.query(Package) \
.filter(~Package.releases.any(or_(PackageRelease.max_rel==None, PackageRelease.max_rel == current_stable))) \
.filter(Package.state == PackageState.APPROVED)
if is_mtm_only:
query = query.filter(Package.repo.ilike("%github.com/minetest-mods/%"))
sort_by = request.args.get("sort")
if sort_by == "date":
query = query.order_by(db.desc(Package.approved_at))
else:
sort_by = "score"
query = query.order_by(db.desc(Package.score))
return render_template("todo/mtver_support.html", current_tab="screenshots",
packages=query.all(), sort_by=sort_by, is_mtm_only=is_mtm_only, current_stable=current_stable)

193
app/blueprints/todo/user.py Normal file
View File

@@ -0,0 +1,193 @@
# ContentDB
# Copyright (C) 2018-23 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from celery import uuid
from flask import redirect, url_for, abort, render_template, flash
from flask_babel import gettext
from flask_login import current_user, login_required
from sqlalchemy import or_, and_
from app.models import User, Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
PackageRelease, Permission, NotificationType, AuditSeverity, UserRank, PackageType
from app.tasks.importtasks import make_vcs_release
from app.utils import add_notification, add_audit_log
from . import bp
@bp.route("/user/tags/")
def tags_user():
return redirect(url_for('todo.tags', author=current_user.username))
@bp.route("/user/todo/")
@bp.route("/users/<username>/todo/")
@login_required
def view_user(username=None):
if username is None:
return redirect(url_for("todo.view_user", username=current_user.username))
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if current_user != user and not current_user.rank.at_least(UserRank.APPROVER):
abort(403)
unapproved_packages = user.packages \
.filter(or_(Package.state == PackageState.WIP,
Package.state == PackageState.CHANGES_NEEDED)) \
.order_by(db.asc(Package.created_at)).all()
outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.order_by(db.asc(Package.title)).all()
missing_game_support = user.maintained_packages.filter(
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False) \
.order_by(db.asc(Package.title)).all()
packages_with_no_screenshots = user.maintained_packages.filter(
~Package.screenshots.any(), Package.state == PackageState.APPROVED).all()
packages_with_small_screenshots = user.maintained_packages \
.filter(Package.state != PackageState.DELETED,
Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
.all()
topics_to_add = ForumTopic.query \
.filter_by(author_id=user.id) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
needs_tags = user.maintained_packages \
.filter(Package.state != PackageState.DELETED, ~Package.tags.any()) \
.order_by(db.asc(Package.title)).all()
return render_template("todo/user.html", current_tab="user", user=user,
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
missing_game_support=missing_game_support, needs_tags=needs_tags, topics_to_add=topics_to_add,
packages_with_no_screenshots=packages_with_no_screenshots,
packages_with_small_screenshots=packages_with_small_screenshots,
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
@login_required
def apply_all_updates(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
abort(403)
outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.order_by(db.asc(Package.title)).all()
for package in outdated_packages:
if not package.check_perm(current_user, Permission.MAKE_RELEASE):
continue
if package.releases.filter(or_(PackageRelease.task_id.isnot(None),
PackageRelease.commit_hash == package.update_config.last_commit)).count() > 0:
continue
title = package.update_config.get_title()
ref = package.update_config.get_ref()
rel = PackageRelease()
rel.package = package
rel.title = title
rel.url = ""
rel.task_id = uuid()
db.session.add(rel)
db.session.commit()
make_vcs_release.apply_async((rel.id, ref),
task_id=rel.task_id)
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
package.get_url("packages.create_edit"), package)
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
db.session.commit()
return redirect(url_for("todo.view_user", username=username))
@bp.route("/user/game_support/")
@bp.route("/users/<username>/game_support/")
@login_required
def all_game_support(username=None):
if username is None:
return redirect(url_for("todo.all_game_support", username=current_user.username))
user: User = User.query.filter_by(username=username).one_or_404()
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
abort(403)
packages = user.maintained_packages.filter(
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP])) \
.order_by(db.asc(Package.title)).all()
bulk_support_names = db.session.query(Package.title) \
.select_from(Package).filter(
Package.maintainers.contains(user),
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False) \
.order_by(db.asc(Package.title)).all()
bulk_support_names = ", ".join([x[0] for x in bulk_support_names])
return render_template("todo/game_support.html", user=user, packages=packages, bulk_support_names=bulk_support_names)
@bp.route("/users/<username>/confirm_supports_all_games/", methods=["POST"])
@login_required
def confirm_supports_all_games(username=None):
user: User = User.query.filter_by(username=username).one_or_404()
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR):
abort(403)
packages = user.maintained_packages.filter(
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False) \
.all()
for package in packages:
package.supports_all_games = True
db.session.merge(package)
add_audit_log(AuditSeverity.NORMAL, current_user, "Enabled 'Supports all games' (bulk)",
package.get_url("packages.game_support"), package)
db.session.commit()
flash(gettext("Done"), "success")
return redirect(url_for("todo.all_game_support", username=current_user.username))

View File

@@ -14,70 +14,68 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import *
from flask import redirect, abort, render_template, flash, request, url_for
from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, SubmitField, BooleanField, PasswordField, validators
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, Email, EqualTo
from app.models import *
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, nonEmptyOrNone
from passlib.pwd import genphrase
from app.utils import random_string, make_flask_login_password, is_safe_url, check_password_hash, add_audit_log, \
nonempty_or_none, post_login, is_username_valid
from . import bp
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification
class LoginForm(FlaskForm):
username = StringField("Username or email", [InputRequired()])
password = PasswordField("Password", [InputRequired(), Length(6, 100)])
remember_me = BooleanField("Remember me", default=True)
submit = SubmitField("Sign in")
username = StringField(lazy_gettext("Username or email"), [InputRequired()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
submit = SubmitField(lazy_gettext("Sign in"))
def handle_login(form):
def show_safe_err(err):
if "@" in username:
flash("Incorrect email or password", "danger")
flash(gettext("Incorrect email or password"), "danger")
else:
flash(err, "danger")
username = form.username.data.strip()
user = User.query.filter(or_(User.username == username, User.email == username)).first()
if user is None:
return show_safe_err("User {} does not exist".format(username))
return show_safe_err(gettext(u"User %(username)s does not exist", username=username))
if not check_password_hash(user.password, form.password.data):
return show_safe_err("Incorrect password. Did you set one?")
return show_safe_err(gettext(u"Incorrect password. Did you set one?"))
if not user.is_active:
flash("You need to confirm the registration email", "danger")
flash(gettext("You need to confirm the registration email"), "danger")
return
addAuditLog(AuditSeverity.USER, user, "Logged in using password",
url_for("users.profile", username=user.username))
add_audit_log(AuditSeverity.USER, user, "Logged in using password",
url_for("users.profile", username=user.username))
db.session.commit()
login_user(user, remember=form.remember_me.data)
flash("Logged in successfully.", "success")
if not login_user(user, remember=form.remember_me.data):
flash(gettext("Login failed"), "danger")
return
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return redirect(next or url_for("homepage.home"))
return post_login(user, request.args.get("next"))
@bp.route("/user/login/", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
if current_user.is_authenticated:
return redirect(next or url_for("homepage.home"))
form = LoginForm(request.form)
@@ -89,8 +87,7 @@ def login():
if request.method == "GET":
form.remember_me.data = True
return render_template("users/login.html", form=form)
return render_template("users/login.html", form=form, next=next)
@bp.route("/user/logout/", methods=["GET", "POST"])
@@ -100,45 +97,67 @@ def logout():
class RegisterForm(FlaskForm):
username = StringField("Username", [InputRequired()])
email = StringField("Email", [InputRequired(), Email()])
password = PasswordField("Password", [InputRequired(), Length(6, 100)])
submit = SubmitField("Register")
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonempty_or_none])
username = StringField(lazy_gettext("Username"), [InputRequired(),
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext(
"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed"))])
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(12, 100)])
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
submit = SubmitField(lazy_gettext("Register"))
def handle_register(form):
if form.question.data.strip().lower() != "19":
flash(gettext("Incorrect captcha answer"), "danger")
return
if not is_username_valid(form.username.data):
flash(gettext("Username is invalid"))
return
user_by_name = User.query.filter(or_(
User.username == form.username.data,
User.username == form.display_name.data,
User.display_name == form.display_name.data,
User.forums_username == form.username.data,
User.github_username == form.username.data)).first()
if user_by_name:
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
flash("An account already exists for that username but hasn't been claimed yet.", "danger")
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
else:
flash("That username is already in use, please choose another.", "danger")
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author==form.username.data,
PackageAlias.author==form.display_name.data)).first()
if alias_by_name:
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, "Email already in use",
"We were unable to create the account as the email is already in use by {}. Try a different email address.".format(
user_by_email.display_name))
flash("Check your email address to verify your account", "success")
return redirect(url_for("homepage.home"))
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent"))
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
user.notification_preferences = UserNotificationPreferences(user)
if form.display_name.data:
user.display_name = form.display_name.data
db.session.add(user)
addAuditLog(AuditSeverity.USER, user, "Registered with email",
url_for("users.profile", username=user.username))
add_audit_log(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
url_for("users.profile", username=user.username))
token = randomString(32)
token = random_string(32)
ver = UserEmailVerification()
ver.user = user
@@ -147,10 +166,9 @@ def handle_register(form):
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token)
send_verify_email.delay(form.email.data, token, get_locale().language)
flash("Check your email address to verify your account", "success")
return redirect(url_for("homepage.home"))
return redirect(url_for("users.email_sent"))
@bp.route("/user/register/", methods=["GET", "POST"])
@@ -161,12 +179,13 @@ def register():
if ret:
return ret
return render_template("users/register.html", form=form, suggested_password=genphrase(entropy=52, wordset="bip39"))
return render_template("users/register.html", form=form)
class ForgotPasswordForm(FlaskForm):
email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField("Reset Password")
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password"))
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password():
@@ -175,10 +194,10 @@ def forgot_password():
email = form.email.data
user = User.query.filter_by(email=email).first()
if user:
token = randomString(32)
token = random_string(32)
addAuditLog(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
url_for("users.profile", username=user.username), None)
add_audit_log(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
url_for("users.profile", username=user.username), None)
ver = UserEmailVerification()
ver.user = user
@@ -188,65 +207,73 @@ def forgot_password():
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token)
send_verify_email.delay(form.email.data, token, get_locale().language)
else:
send_anon_email.delay(email, "Unable to find account", """
<p>
We were unable to perform the password reset as we could not find an account
associated with this email.
</p>
<p>
If you weren't expecting to receive this email, then you can safely ignore it.
</p>
""")
html = render_template("emails/unable_to_find_account.html")
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
html, html)
flash("Check your email address to continue the reset", "success")
return redirect(url_for("homepage.home"))
return redirect(url_for("users.email_sent"))
return render_template("users/forgot_password.html", form=form)
class SetPasswordForm(FlaskForm):
email = StringField("Email", [Optional(), Email()])
password = PasswordField("New password", [InputRequired(), Length(8, 100)])
password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
submit = SubmitField("Save")
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100),
EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save"))
class ChangePasswordForm(FlaskForm):
old_password = PasswordField("Old password", [InputRequired(), Length(8, 100)])
password = PasswordField("New password", [InputRequired(), Length(8, 100)])
password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
submit = SubmitField("Save")
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(6, 100)])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100),
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save"))
def handle_set_password(form):
one = form.password.data
two = form.password2.data
if one != two:
flash("Passwords do not much", "danger")
flash(gettext("Passwords do not match"), "danger")
return
addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
add_audit_log(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
current_user.password = make_flask_login_password(form.password.data)
if hasattr(form, "email"):
newEmail = nonEmptyOrNone(form.email.data)
if newEmail and newEmail != current_user.email:
new_email = nonempty_or_none(form.email.data)
if new_email and new_email != current_user.email:
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
token = randomString(32)
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
else:
token = random_string(32)
ver = UserEmailVerification()
ver.user = current_user
ver.token = token
ver.email = newEmail
db.session.add(ver)
ver = UserEmailVerification()
ver.user = current_user
ver.token = token
ver.email = new_email
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language)
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("users.email_sent"))
db.session.commit()
flash("Your password has been changed successfully.", "success")
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("homepage.home"))
@@ -261,10 +288,9 @@ def change_password():
if ret:
return ret
else:
flash("Old password is incorrect", "danger")
flash(gettext("Old password is incorrect"), "danger")
return render_template("users/change_set_password.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
return render_template("users/change_set_password.html", form=form)
@bp.route("/user/set-password/", methods=["GET", "POST"])
@@ -282,36 +308,44 @@ def set_password():
if ret:
return ret
return render_template("users/change_set_password.html", form=form, optional=request.args.get("optional"),
suggested_password=genphrase(entropy=52, wordset="bip39"))
return render_template("users/change_set_password.html", form=form)
@bp.route("/user/verify/")
def verify_email():
token = request.args.get("token")
ver : UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
if ver is None:
flash("Unknown verification token!", "danger")
flash(gettext("Unknown verification token!"), "danger")
return redirect(url_for("homepage.home"))
delta = (datetime.datetime.now() - ver.created_at)
delta: datetime.timedelta
if delta.total_seconds() > 12*60*60:
flash(gettext("Token has expired"), "danger")
db.session.delete(ver)
db.session.commit()
return redirect(url_for("homepage.home"))
user = ver.user
addAuditLog(AuditSeverity.USER, user, "Confirmed their email",
url_for("users.profile", username=user.username))
add_audit_log(AuditSeverity.USER, user, "Confirmed their email",
url_for("users.profile", username=user.username))
was_activating = not user.is_active
if ver.email and user.email != ver.email:
if User.query.filter_by(email=ver.email).count() > 0:
flash("Another user is already using that email", "danger")
flash(gettext("Another user is already using that email"), "danger")
return redirect(url_for("homepage.home"))
flash("Confirmed email change", "success")
flash(gettext("Confirmed email change"), "success")
if user.email:
send_user_email.delay(user.email,
"Email address changed",
"Your email address has changed. If you didn't request this, please contact an administrator.")
user.locale or "en",
gettext("Email address changed"),
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
user.is_active = True
user.email = ver.email
@@ -329,15 +363,15 @@ def verify_email():
if current_user.is_authenticated:
return redirect(url_for("users.profile", username=current_user.username))
elif was_activating:
flash("You may now log in", "success")
flash(gettext("You may now log in"), "success")
return redirect(url_for("users.login"))
else:
return redirect(url_for("homepage.home"))
class UnsubscribeForm(FlaskForm):
email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField("Send")
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Send"))
def unsubscribe_verify():
@@ -349,12 +383,11 @@ def unsubscribe_verify():
sub = EmailSubscription(email)
db.session.add(sub)
sub.token = randomString(32)
sub.token = random_string(32)
db.session.commit()
send_unsubscribe_verify.delay(form.email.data)
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
flash("Check your email address to continue the unsubscribe", "success")
return redirect(url_for("homepage.home"))
return redirect(url_for("users.email_sent"))
return render_template("users/unsubscribe.html", form=form)
@@ -369,7 +402,7 @@ def unsubscribe_manage(sub: EmailSubscription):
sub.blacklisted = True
db.session.commit()
flash("That email is now blacklisted. Please contact an admin if you wish to undo this.", "success")
flash(gettext("That email is now blacklisted. Please contact an admin if you wish to undo this."), "success")
return redirect(url_for("homepage.home"))
return render_template("users/unsubscribe.html", user=user)
@@ -384,3 +417,8 @@ def unsubscribe():
return unsubscribe_manage(sub)
return unsubscribe_verify()
@bp.route("/email_sent/")
def email_sent():
return render_template("users/email_sent.html")

View File

@@ -14,18 +14,14 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask_babel import gettext
from . import bp
from flask import redirect, render_template, session, request, flash, url_for
from app.models import db, User, UserRank
from app.utils import randomString, login_user_set_active
from app.tasks.forumtasks import checkForumAccount
from app.utils.phpbbparser import getProfile
import re
def check_username(username):
return username is not None and len(username) >= 2 and re.match("^[A-Za-z0-9._-]*$", username)
from app.utils import random_string, login_user_set_active, is_username_valid
from app.tasks.forumtasks import check_forum_account
from app.utils.phpbbparser import get_profile
@bp.route("/user/claim/", methods=["GET", "POST"])
@@ -41,17 +37,17 @@ def claim_forums():
else:
method = request.args.get("method")
if not check_username(username):
flash("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin", "danger")
if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
return redirect(url_for("users.claim_forums"))
user = User.query.filter_by(forums_username=username).first()
if user and user.rank.atLeast(UserRank.NEW_MEMBER):
flash("User has already been claimed", "danger")
if user and user.rank.at_least(UserRank.NEW_MEMBER):
flash(gettext("User has already been claimed"), "danger")
return redirect(url_for("users.claim_forums"))
elif method == "github":
if user is None or user.github_username is None:
flash("Unable to get GitHub username for user", "danger")
flash(gettext("Unable to get GitHub username for user"), "danger")
return redirect(url_for("users.claim_forums", username=username))
else:
return redirect(url_for("github.start"))
@@ -59,28 +55,27 @@ def claim_forums():
if "forum_token" in session:
token = session["forum_token"]
else:
token = randomString(12)
token = random_string(12)
session["forum_token"] = token
if request.method == "POST":
ctype = request.form.get("claim_type")
ctype = request.form.get("claim_type")
username = request.form.get("username")
if not check_username(username):
flash("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin", "danger")
if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
elif ctype == "github":
task = checkForumAccount.delay(username)
task = check_forum_account.delay(username)
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
elif ctype == "forum":
user = User.query.filter_by(forums_username=username).first()
if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
flash("That user has already been claimed!", "danger")
if user is not None and user.rank.at_least(UserRank.NEW_MEMBER):
flash(gettext("That user has already been claimed!"), "danger")
return redirect(url_for("users.claim_forums"))
# Get signature
sig = None
try:
profile = getProfile("https://forum.minetest.net", username)
profile = get_profile("https://forum.minetest.net", username)
sig = profile.signature if profile else None
except IOError as e:
if hasattr(e, 'message'):
@@ -88,11 +83,11 @@ def claim_forums():
else:
message = str(e)
flash("Error whilst attempting to access forums: " + message, "danger")
flash(gettext(u"Error whilst attempting to access forums: %(message)s", message=message), "danger")
return redirect(url_for("users.claim_forums", username=username))
if profile is None:
flash("Unable to get forum signature - does the user exist?", "danger")
flash(gettext("Unable to get forum signature - does the user exist?"), "danger")
return redirect(url_for("users.claim_forums", username=username))
# Look for key
@@ -105,16 +100,17 @@ def claim_forums():
db.session.add(user)
db.session.commit()
if login_user_set_active(user, remember=True):
return redirect(url_for("users.set_password"))
else:
flash("Unable to login as user", "danger")
ret = login_user_set_active(user, remember=True)
if ret is None:
flash(gettext("Unable to login as user"), "danger")
return redirect(url_for("users.claim_forums", username=username))
return ret
else:
flash("Could not find the key in your signature!", "danger")
flash(gettext("Could not find the key in your signature!"), "danger")
return redirect(url_for("users.claim_forums", username=username))
else:
flash("Unknown claim type", "danger")
flash(gettext("Unknown claim type"), "danger")
return render_template("users/claim_forums.html", username=username, key="cdb_" + token)

View File

@@ -14,13 +14,18 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import math
from typing import Optional, Tuple, List
from flask import *
from flask import redirect, url_for, abort, render_template, request
from flask_babel import gettext
from flask_login import current_user, login_required
from sqlalchemy import func
from sqlalchemy import func, text
from app.models import User, db, Package, PackageReview, PackageState, PackageType, UserRank
from app.utils import get_daterange_options
from app.tasks.forumtasks import check_forum_account
from app.models import *
from app.tasks.forumtasks import checkForumAccount
from . import bp
@@ -43,35 +48,229 @@ def by_forums_username(username):
return render_template("users/forums_no_such_user.html", username=username)
class Medal:
description: str
color: Optional[str]
icon: str
title: Optional[str]
progress: Optional[Tuple[int, int]]
def __init__(self, description: str, **kwargs):
self.description = description
self.color = kwargs.get("color", "white")
self.icon = kwargs.get("icon", None)
self.title = kwargs.get("title", None)
self.progress = kwargs.get("progress", None)
@classmethod
def make_unlocked(cls, color: str, icon: str, title: str, description: str):
return Medal(description=description, color=color, icon=icon, title=title)
@classmethod
def make_locked(cls, description: str, progress: Tuple[int, int]):
if progress[0] is None or progress[1] is None:
raise Exception("Invalid progress")
return Medal(description=description, progress=progress)
def place_to_color(place: int) -> str:
if place == 1:
return "gold"
elif place == 2:
return "#888"
elif place == 3:
return "#cd7f32"
else:
return "white"
def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
unlocked = []
locked = []
#
# REVIEWS
#
users_by_reviews = db.session.query(User.username, func.sum(PackageReview.score).label("karma")) \
.select_from(User).join(PackageReview) \
.group_by(User.username).order_by(text("karma DESC")).all()
try:
review_boundary = users_by_reviews[math.floor(len(users_by_reviews) * 0.25)][1] + 1
except IndexError:
review_boundary = None
usernames_by_reviews = [username for username, _ in users_by_reviews]
review_idx = None
review_percent = None
review_karma = 0
try:
review_idx = usernames_by_reviews.index(user.username)
review_percent = round(100 * review_idx / len(users_by_reviews), 1)
review_karma = max(users_by_reviews[review_idx][1], 0)
except ValueError:
pass
if review_percent is not None and review_percent < 25:
if review_idx == 0:
title = gettext(u"Top reviewer")
description = gettext(
u"%(display_name)s has written the most helpful reviews on ContentDB.",
display_name=user.display_name)
elif review_idx <= 2:
if review_idx == 1:
title = gettext(u"2nd most helpful reviewer")
else:
title = gettext(u"3rd most helpful reviewer")
description = gettext(
u"This puts %(display_name)s in the top %(perc)s%%",
display_name=user.display_name, perc=review_percent)
else:
title = gettext(u"Top %(perc)s%% reviewer", perc=review_percent)
description = gettext(u"Only %(place)d users have written more helpful reviews.", place=review_idx)
unlocked.append(Medal.make_unlocked(
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
elif review_boundary is not None:
description = gettext(u"Consider writing more helpful reviews to get a medal.")
if review_idx:
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
locked.append(Medal.make_locked(
description, (review_karma, review_boundary)))
#
# TOP PACKAGES
#
all_package_ranks = db.session.query(
Package.type,
Package.author_id,
func.rank().over(
order_by=db.desc(Package.score),
partition_by=Package.type) \
.label("rank")).order_by(db.asc(text("rank"))) \
.filter_by(state=PackageState.APPROVED).subquery()
user_package_ranks = db.session.query(all_package_ranks) \
.filter_by(author_id=user.id) \
.filter(text("rank <= 30")) \
.all()
user_package_ranks = next(
(x for x in user_package_ranks if x[0] == PackageType.MOD or x[2] <= 10),
None)
if user_package_ranks:
top_rank = user_package_ranks[2]
top_type = PackageType.coerce(user_package_ranks[0])
if top_rank == 1:
title = gettext(u"Top %(type)s", type=top_type.text.lower())
else:
title = gettext(u"Top %(group)d %(type)s", group=top_rank, type=top_type.text.lower())
if top_type == PackageType.MOD:
icon = "fa-box"
elif top_type == PackageType.GAME:
icon = "fa-gamepad"
else:
icon = "fa-paint-brush"
description = gettext(u"%(display_name)s has a %(type)s placed at #%(place)d.",
display_name=user.display_name, type=top_type.text.lower(), place=top_rank)
unlocked.append(
Medal.make_unlocked(place_to_color(top_rank), icon, title, description))
#
# DOWNLOADS
#
total_downloads = db.session.query(func.sum(Package.downloads)) \
.select_from(User) \
.join(User.packages) \
.filter(User.id == user.id,
Package.state == PackageState.APPROVED).scalar()
if total_downloads is None:
pass
elif total_downloads < 50000:
description = gettext(u"Your packages have %(downloads)d downloads in total.", downloads=total_downloads)
description += " " + gettext(u"First medal is at 50k.")
locked.append(Medal.make_locked(description, (total_downloads, 50000)))
else:
if total_downloads >= 300000:
place = 1
title = gettext(u">300k downloads")
elif total_downloads >= 100000:
place = 2
title = gettext(u">100k downloads")
elif total_downloads >= 75000:
place = 3
title = gettext(u">75k downloads")
else:
place = 10
title = gettext(u">50k downloads")
description = gettext(u"Has received %(downloads)d downloads across all packages.",
display_name=user.display_name, downloads=total_downloads)
unlocked.append(Medal.make_unlocked(place_to_color(place), "fa-users", title, description))
return unlocked, locked
@bp.route("/users/<username>/")
def profile(username):
user = User.query.filter_by(username=username).first()
if not user:
abort(404)
packages = user.packages.filter(Package.state != PackageState.DELETED)
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
packages = packages.filter_by(state=PackageState.APPROVED)
packages = packages.order_by(db.asc(Package.title))
if not current_user.is_authenticated or (user != current_user and not current_user.can_access_todo_list()):
packages = user.packages.filter_by(state=PackageState.APPROVED)
maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED)
else:
packages = user.packages.filter(Package.state != PackageState.DELETED)
maintained_packages = user.maintained_packages.filter(Package.state != PackageState.DELETED)
packages = packages.order_by(db.asc(Package.title)).all()
maintained_packages = maintained_packages \
.filter(Package.author != user) \
.order_by(db.asc(Package.title)).all()
unlocked, locked = get_user_medals(user)
# Process GET or invalid POST
return render_template("users/profile.html", user=user, packages=packages)
return render_template("users/profile.html", user=user,
packages=packages, maintained_packages=maintained_packages,
medals_unlocked=unlocked, medals_locked=locked)
@bp.route("/users/<username>/check/", methods=["POST"])
@bp.route("/users/<username>/check-forums/", methods=["POST"])
@login_required
def user_check(username):
def user_check_forums(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.MODERATOR):
if current_user != user and not current_user.rank.at_least(UserRank.MODERATOR):
abort(403)
if user.forums_username is None:
abort(404)
task = checkForumAccount.delay(user.forums_username)
task = check_forum_account.delay(user.forums_username, force_replace_pic=True)
next_url = url_for("users.profile", username=username)
return redirect(url_for("tasks.check", id=task.id, r=next_url))
@bp.route("/user/stats/")
@login_required
def statistics_redirect():
return redirect(url_for("users.statistics", username=current_user.username))
@bp.route("/users/<username>/stats/")
def statistics(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
downloads = db.session.query(func.sum(Package.downloads)).filter(Package.author==user).one()
start = request.args.get("start")
end = request.args.get("end")
return render_template("users/stats.html", user=user, downloads=downloads[0],
start=start, end=end, options=get_daterange_options(), noindex=start or end)

View File

@@ -1,44 +1,123 @@
from flask import *
# ContentDB
# Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, abort, render_template, request, flash, url_for
from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from sqlalchemy import or_
from wtforms import StringField, SubmitField, BooleanField, SelectField
from wtforms.validators import Length, Optional, Email, URL
from app.models import *
from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification, Permission, NotificationType, UserBan
from app.tasks.emails import send_verify_email
from app.utils import nonempty_or_none, add_audit_log, random_string, rank_required, has_blocked_domains
from . import bp
def get_setting_tabs(user):
return [
ret = [
{
"id": "edit_profile",
"title": "Edit Profile",
"title": gettext("Edit Profile"),
"url": url_for("users.profile_edit", username=user.username)
},
{
"id": "account",
"title": "Account and Security",
"title": gettext("Account and Security"),
"url": url_for("users.account", username=user.username)
},
{
"id": "notifications",
"title": "Email and Notifications",
"title": gettext("Email and Notifications"),
"url": url_for("users.email_notifications", username=user.username)
},
{
"id": "api_tokens",
"title": "API Tokens",
"title": gettext("API Tokens"),
"url": url_for("api.list_tokens", username=user.username)
},
]
if user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT):
ret.append({
"id": "oauth_clients",
"title": gettext("OAuth2 Applications"),
"url": url_for("oauth.list_clients", username=user.username)
})
if current_user.rank.at_least(UserRank.MODERATOR):
ret.append({
"id": "modtools",
"title": gettext("Moderator Tools"),
"url": url_for("users.modtools", username=user.username)
})
return ret
class UserProfileForm(FlaskForm):
website_url = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
donate_url = StringField("Donation URL", [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField("Save")
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonempty_or_none(x.strip())])
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField(lazy_gettext("Save"))
def handle_profile_edit(form: UserProfileForm, user: User, username: str):
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
add_audit_log(severity, current_user, "Edited {}'s profile".format(user.display_name),
url_for("users.profile", username=username))
display_name = form.display_name.data or user.username
if user.check_perm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
user.display_name != display_name:
if User.query.filter(User.id != user.id,
or_(User.username == display_name,
User.display_name.ilike(display_name))).count() > 0:
flash(gettext("A user already has that name"), "danger")
return None
alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author == display_name)).first()
if alias_by_name:
flash(gettext("A user already has that name"), "danger")
return
user.display_name = display_name
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
add_audit_log(severity, current_user, "Changed display name of {} to {}"
.format(user.username, user.display_name),
url_for("users.profile", username=username))
if user.check_perm(current_user, Permission.CHANGE_PROFILE_URLS):
if has_blocked_domains(form.website_url.data, current_user.username, f"{user.username}'s website_url") or \
has_blocked_domains(form.donate_url.data, current_user.username, f"{user.username}'s donate_url"):
flash(gettext("Linking to blocked sites is not allowed"), "danger")
return
user.website_url = form.website_url.data
user.donate_url = form.donate_url.data
db.session.commit()
return redirect(url_for("users.profile", username=username))
@bp.route("/users/<username>/settings/profile/", methods=["GET", "POST"])
@@ -49,22 +128,14 @@ def profile_edit(username):
abort(404)
if not user.can_see_edit_profile(current_user):
flash("Permission denied", "danger")
flash(gettext("Permission denied"), "danger")
return redirect(url_for("users.profile", username=username))
form = UserProfileForm(obj=user)
if form.validate_on_submit():
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
url_for("users.profile", username=username))
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
user.website_url = form["website_url"].data
user.donate_url = form["donate_url"].data
db.session.commit()
return redirect(url_for("users.profile", username=username))
ret = handle_profile_edit(form, user, username)
if ret:
return ret
# Process GET or invalid POST
return render_template("users/profile_edit.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="edit_profile")
@@ -72,12 +143,12 @@ def profile_edit(username):
def make_settings_form():
attrs = {
"email": StringField("Email", [Optional(), Email()]),
"submit": SubmitField("Save")
"email": StringField(lazy_gettext("Email"), [Optional(), Email()]),
"submit": SubmitField(lazy_gettext("Save"))
}
for notificationType in NotificationType:
key = "pref_" + notificationType.toName()
key = "pref_" + notificationType.to_name()
attrs[key] = BooleanField("")
attrs[key + "_digest"] = BooleanField("")
@@ -88,27 +159,27 @@ SettingsForm = make_settings_form()
def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form):
for notificationType in NotificationType:
field_email = getattr(form, "pref_" + notificationType.toName()).data
field_digest = getattr(form, "pref_" + notificationType.toName() + "_digest").data or field_email
field_email = getattr(form, "pref_" + notificationType.to_name()).data
field_digest = getattr(form, "pref_" + notificationType.to_name() + "_digest").data or field_email
prefs.set_can_email(notificationType, field_email)
prefs.set_can_digest(notificationType, field_digest)
if is_new:
db.session.add(prefs)
if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
if user.check_perm(current_user, Permission.CHANGE_EMAIL):
newEmail = form.email.data
if newEmail and newEmail != user.email and newEmail.strip() != "":
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
token = randomString(32)
token = random_string(32)
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
msg = "Changed email of {}".format(user.display_name)
addAuditLog(severity, current_user, msg, url_for("users.profile", username=user.username))
add_audit_log(severity, current_user, msg, url_for("users.profile", username=user.username))
ver = UserEmailVerification()
ver.user = user
@@ -117,10 +188,8 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
db.session.add(ver)
db.session.commit()
flash("Check your email to confirm it", "success")
send_verify_email.delay(newEmail, token)
return redirect(url_for("users.email_notifications", username=user.username))
send_verify_email.delay(newEmail, token, get_locale().language)
return redirect(url_for("users.email_sent"))
db.session.commit()
return redirect(url_for("users.email_notifications", username=user.username))
@@ -137,7 +206,7 @@ def email_notifications(username=None):
if not user:
abort(404)
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
abort(403)
is_new = False
@@ -150,8 +219,8 @@ def email_notifications(username=None):
types = []
for notificationType in NotificationType:
types.append(notificationType)
data["pref_" + notificationType.toName()] = prefs.get_can_email(notificationType)
data["pref_" + notificationType.toName() + "_digest"] = prefs.get_can_digest(notificationType)
data["pref_" + notificationType.to_name()] = prefs.get_can_email(notificationType)
data["pref_" + notificationType.to_name() + "_digest"] = prefs.get_can_digest(notificationType)
data["email"] = user.email
@@ -166,56 +235,14 @@ def email_notifications(username=None):
tabs=get_setting_tabs(user), current_tab="notifications")
class UserAccountForm(FlaskForm):
display_name = StringField("Display name", [Optional(), Length(2, 100)])
forums_username = StringField("Forums Username", [Optional(), Length(2, 50)])
github_username = StringField("GitHub Username", [Optional(), Length(2, 50)])
rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
default=UserRank.NEW_MEMBER)
submit = SubmitField("Save")
@bp.route("/users/<username>/settings/account/", methods=["GET", "POST"])
@bp.route("/users/<username>/settings/account/")
@login_required
def account(username):
user : User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.can_see_edit_profile(current_user):
flash("Permission denied", "danger")
return redirect(url_for("users.profile", username=username))
can_edit_account_settings = user.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
user.checkPerm(current_user, Permission.CHANGE_RANK)
form = UserAccountForm(obj=user) if can_edit_account_settings else None
if form and form.validate_on_submit():
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
url_for("users.profile", username=username))
# Copy form fields to user_profile fields
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
user.display_name = form.display_name.data
user.forums_username = nonEmptyOrNone(form.forums_username.data)
user.github_username = nonEmptyOrNone(form.github_username.data)
if user.checkPerm(current_user, Permission.CHANGE_RANK):
newRank = form["rank"].data
if current_user.rank.atLeast(newRank):
if newRank != user.rank:
user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
addAuditLog(AuditSeverity.MODERATION, current_user, msg,
url_for("users.profile", username=username))
else:
flash("Can't promote a user to a rank higher than yourself!", "danger")
db.session.commit()
return redirect(url_for("users.account", username=username))
return render_template("users/account.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="account")
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
@@ -225,29 +252,39 @@ def delete(username):
if not user:
abort(404)
if user.rank.atLeast(UserRank.MODERATOR):
flash("Users with moderator rank or above cannot be deleted", "danger")
if user.rank.at_least(UserRank.MODERATOR):
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
return redirect(url_for("users.account", username=username))
if request.method == "GET":
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
if user.can_delete():
if "delete" in request.form and (user.can_delete() or current_user.rank.at_least(UserRank.ADMIN)):
msg = "Deleted user {}".format(user.username)
flash(msg, "success")
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None)
if current_user.rank.at_least(UserRank.ADMIN):
for pkg in user.packages.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.delete(user)
else:
user.replies.delete()
elif "deactivate" in request.form:
for reply in user.replies.all():
db.session.delete(reply)
for thread in user.threads.all():
db.session.delete(thread)
user.email = None
user.rank = UserRank.NOT_JOINED
if user.rank != UserRank.BANNED:
user.rank = UserRank.NOT_JOINED
msg = "Deactivated user {}".format(user.username)
flash(msg, "success")
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None)
else:
assert False
db.session.commit()
@@ -255,3 +292,146 @@ def delete(username):
logout_user()
return redirect(url_for("homepage.home"))
class ModToolsForm(FlaskForm):
username = StringField(lazy_gettext("Username"), [Optional(), Length(1, 50)])
display_name = StringField(lazy_gettext("Display name"), [Optional(), Length(2, 100)])
forums_username = StringField(lazy_gettext("Forums Username"), [Optional(), Length(2, 50)])
github_username = StringField(lazy_gettext("GitHub Username"), [Optional(), Length(2, 50)])
rank = SelectField(lazy_gettext("Rank"), [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
default=UserRank.NEW_MEMBER)
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/users/<username>/modtools/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def modtools(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
abort(403)
form = ModToolsForm(obj=user)
if form.validate_on_submit():
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
add_audit_log(severity, current_user, "Edited {}'s account".format(user.display_name),
url_for("users.profile", username=username))
# Copy form fields to user_profile fields
if user.check_perm(current_user, Permission.CHANGE_USERNAMES):
if user.username != form.username.data:
for package in user.packages:
alias = PackageAlias(user.username, package.name)
package.aliases.append(alias)
db.session.add(alias)
user.username = form.username.data
user.display_name = form.display_name.data
user.forums_username = nonempty_or_none(form.forums_username.data)
user.github_username = nonempty_or_none(form.github_username.data)
if user.check_perm(current_user, Permission.CHANGE_RANK):
new_rank = form["rank"].data
if current_user.rank.at_least(new_rank):
if new_rank != user.rank:
user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title())
add_audit_log(AuditSeverity.MODERATION, current_user, msg,
url_for("users.profile", username=username))
else:
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
db.session.commit()
return redirect(url_for("users.modtools", username=username))
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")
@bp.route("/users/<username>/modtools/set-email/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_set_email(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.check_perm(current_user, Permission.CHANGE_EMAIL):
abort(403)
user.email = request.form["email"]
user.is_active = False
token = random_string(32)
add_audit_log(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
url_for("users.profile", username=user.username), None)
ver = UserEmailVerification()
ver.user = user
ver.token = token
ver.email = user.email
ver.is_password_reset = True
db.session.add(ver)
db.session.commit()
send_verify_email.delay(user.email, token, user.locale or "en")
flash(f"Set email and sent a password reset on {user.username}", "success")
return redirect(url_for("users.modtools", username=username))
@bp.route("/users/<username>/modtools/ban/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_ban(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.check_perm(current_user, Permission.CHANGE_RANK):
abort(403)
message = request.form["message"]
expires_at = request.form.get("expires_at")
user.ban = UserBan()
user.ban.banned_by = current_user
user.ban.message = message
if expires_at and expires_at != "":
user.ban.expires_at = expires_at
else:
user.rank = UserRank.BANNED
add_audit_log(AuditSeverity.MODERATION, current_user, f"Banned {user.username}, expires {user.ban.expires_at or '-'}, message: {message}",
url_for("users.profile", username=user.username), None)
db.session.commit()
flash(f"Banned {user.username}", "success")
return redirect(url_for("users.modtools", username=username))
@bp.route("/users/<username>/modtools/unban/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_unban(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.check_perm(current_user, Permission.CHANGE_RANK):
abort(403)
if user.ban:
db.session.delete(user.ban)
if user.rank == UserRank.BANNED:
user.rank = UserRank.MEMBER
add_audit_log(AuditSeverity.MODERATION, current_user, f"Unbanned {user.username}",
url_for("users.profile", username=user.username), None)
db.session.commit()
flash(f"Unbanned {user.username}", "success")
return redirect(url_for("users.modtools", username=username))

View File

@@ -0,0 +1,70 @@
# ContentDB
# Copyright (C) 2022 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from celery import uuid
from flask import Blueprint, render_template, redirect, request, abort, url_for
from flask_babel import lazy_gettext
from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField, SubmitField
from wtforms.validators import InputRequired, Length
from app.tasks import celery
from app.utils import rank_required
bp = Blueprint("zipgrep", __name__)
from app.models import UserRank, Package
from app.tasks.zipgrep import search_in_releases
class SearchForm(FlaskForm):
query = StringField(lazy_gettext("Text to find (regex)"), [InputRequired(), Length(1, 100)])
file_filter = StringField(lazy_gettext("File filter"), [InputRequired(), Length(1, 100)], default="*.lua")
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
submit = SubmitField(lazy_gettext("Search"))
@bp.route("/zipgrep/", methods=["GET", "POST"])
@rank_required(UserRank.EDITOR)
def zipgrep_search():
form = SearchForm(request.form)
if form.validate_on_submit():
task_id = uuid()
search_in_releases.apply_async((form.query.data, form.file_filter.data), task_id=task_id)
result_url = url_for("zipgrep.view_results", id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=result_url))
return render_template("zipgrep/search.html", form=form)
@bp.route("/zipgrep/<id>/")
def view_results(id):
result = celery.AsyncResult(id)
if result.status == "PENDING":
abort(404)
if result.status != "SUCCESS" or isinstance(result.result, Exception):
result_url = url_for("zipgrep.view_results", id=id)
return redirect(url_for("tasks.check", id=id, r=result_url))
matches = result.result["matches"]
for match in matches:
match["package"] = Package.query.filter(
Package.name == match["package"]["name"],
Package.author.has(username=match["package"]["author"])).one()
return render_template("zipgrep/view_results.html", query=result.result["query"], matches=matches)

View File

@@ -1,4 +1,23 @@
from .models import *
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from .models import User, UserRank, MinetestRelease, Tag, License, Notification, NotificationType, Package, \
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
from .utils import make_flask_login_password
@@ -11,24 +30,31 @@ def populate(session):
admin_user.rank = UserRank.ADMIN
session.add(admin_user)
system_user = User("ContentDB", active=False)
system_user.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
system_user.rank = UserRank.BOT
session.add(system_user)
session.add(MinetestRelease("None", 0))
session.add(MinetestRelease("0.4.16/17", 32))
session.add(MinetestRelease("5.0", 37))
session.add(MinetestRelease("5.1", 38))
session.add(MinetestRelease("5.2", 39))
session.add(MinetestRelease("5.3", 39))
tags = {}
for tag in ["Inventory", "Mapgen", "Building",
"Mobs and NPCs", "Tools", "Player effects",
"Environment", "Transport", "Maintenance", "Plants and farming",
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
"Mobs and NPCs", "Tools", "Player effects",
"Environment", "Transport", "Maintenance", "Plants and farming",
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
row = Tag(tag)
tags[row.name] = row
session.add(row)
licenses = {}
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
row = License(license)
licenses[row.name] = row
session.add(row)
@@ -44,7 +70,6 @@ def populate_test_data(session):
tags = { x.name : x for x in Tag.query.all() }
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
v4 = MinetestRelease.query.filter_by(protocol=32).first()
v50 = MinetestRelease.query.filter_by(protocol=37).first()
v51 = MinetestRelease.query.filter_by(protocol=38).first()
ez = User("Shara")
@@ -53,7 +78,7 @@ def populate_test_data(session):
ez.rank = UserRank.EDITOR
session.add(ez)
not1 = Notification(admin_user, ez, "Awards approved", "/packages/rubenwardy/awards/")
not1 = Notification(admin_user, ez, NotificationType.PACKAGE_APPROVAL, "Awards approved", "/packages/rubenwardy/awards/")
session.add(not1)
jeija = User("Jeija")
@@ -61,7 +86,6 @@ def populate_test_data(session):
jeija.forums_username = "Jeija"
session.add(jeija)
mod = Package()
mod.state = PackageState.APPROVED
mod.name = "alpha"
@@ -354,7 +378,7 @@ Uses the CTF PvP Engine.
mod.name = "pixelbox"
mod.title = "PixelBOX Reloaded"
mod.license = licenses["CC0"]
mod.media_license = licenses["MIT"]
mod.media_license = licenses["CC0"]
mod.type = PackageType.TXP
mod.author = admin_user
mod.forums = 14132
@@ -373,7 +397,6 @@ Uses the CTF PvP Engine.
metas = {}
for package in Package.query.filter_by(type=PackageType.MOD).all():
meta = None
try:
meta = metas[package.name]
except KeyError:

39
app/flatpages/about.md Normal file
View File

@@ -0,0 +1,39 @@
title: About ContentDB
description: Information about ContentDB's development, history, and more
toc: False
## Development
ContentDB was created by [rubenwardy](https://rubenwardy.com/) in 2018, he was lucky enough to have the time available
as it was submitted as university coursework. To learn about the history and development of ContentDB,
[see the blog post](https://blog.rubenwardy.com/2022/03/24/contentdb/).
ContentDB is open source software, licensed under AGPLv3.0.
<a href="https://github.com/minetest/contentdb/" class="btn btn-primary me-1">Source code</a>
<a href="https://github.com/minetest/contentdb/issues/" class="btn btn-secondary me-1">Issue tracker</a>
<a href="https://rubenwardy.com/contact/" class="btn btn-secondary me-1">Contact admin</a>
<a href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb" class="btn btn-secondary">Stats / monitoring</a>
## Why was ContentDB created?
Before ContentDB, users had to manually install mods and games by unzipping their files into a directory. This is
poor user experience, especially for first-time users.
ContentDB isn't just about supporting the in-game content downloader; it's common for technical users to find
and review packages using the ContentDB website, but install using Git rather than the in-game installer.
**ContentDB's purpose is to be a well-formatted source of information about mods, games,
and texture packs for Minetest**.
## How do I learn how to make mods and games for Minetest?
You should read
[the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
for a guide to making mods and games using Minetest.
## How can I support / donate to ContentDB?
You can donate to rubenwardy to cover ContentDB's costs and support future
development.
<a href="https://rubenwardy.com/donate/" class="btn btn-primary me-1">Donate</a>

View File

@@ -1,27 +1,39 @@
title: Help
toc: False
## Rules
* [Rules](/rules/)
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
## General Help
* [Content Ratings and Flags](content_flags)
* [Non-free Licenses](non_free)
* [Why WTFPL is a terrible license](wtfpl)
* [Ranks and Permissions](ranks_permissions)
* [Reporting Content](reporting)
* [Top Packages Algorithm](top_packages)
* [Frequently Asked Questions](faq/)
* [Installing content](installing/)
* [Content Ratings and Flags](content_flags/)
* [Non-free Licenses](non_free/)
* [Why WTFPL is a terrible license](wtfpl/)
* [Ranks and Permissions](ranks_permissions/)
* [Contact Us](contact_us/)
* [Top Packages Algorithm](top_packages/)
* [Featured Packages](featured/)
## Help for Package Authors
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
* [Git Update Detection](update_config)
* [Creating Releases using Webhooks](release_webhooks)
* [Package Configuration and Releases Guide](package_config)
* [Copyright Guide](copyright/)
* [Git Update Detection](update_config/)
* [Creating Releases using Webhooks](release_webhooks/)
* [Package Configuration and Releases Guide](package_config/)
* [Supported Games](game_support/)
## Help for Specific User Ranks
* [Editors](editors)
* [Editors](editors/)
## APIs
* [API](api)
* [Prometheus Metrics](metrics)
* [API](api/)
* [OAuth2 Applications](oauth/)
* [Prometheus Metrics](metrics/)

View File

@@ -1,8 +1,14 @@
title: API
## Resources
* [How the Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
## Responses and Error Handling
If there is an error, the response will be JSON similar to the following with a non-200 status code:
If there is an error, the response will be JSON similar to the following with a non-200 status code:
```json
{
@@ -14,7 +20,33 @@ If there is an error, the response will be JSON similar to the following with a
Successful GET requests will return the resource's information directly as a JSON response.
Other successful results will return a dictionary with `success` equaling true, and
often other keys with information.
often other keys with information. For example:
```js
{
"success": true,
"release": {
/* same as returned by a GET */
}
}
```
### Paginated Results
Some API endpoints returns results in pages. The page number is specified using the `page` query argument, and
the number of items is specified using `num`
The response will be a dictionary with the following keys:
* `page`: page number, integer from 1 to max
* `per_page`: number of items per page, same as `n`
* `page_count`: number of pages
* `total`: total number of results
* `urls`: dictionary containing
* `next`: url to next page
* `previous`: url to previous page
* `items`: array of items
## Authentication
@@ -23,7 +55,7 @@ Not all endpoints require authentication, but it is done using Bearer tokens:
```bash
curl https://content.minetest.net/api/whoami/ \
-H "Authorization: Bearer YOURTOKEN"
-H "Authorization: Bearer YOURTOKEN"
```
Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
@@ -32,6 +64,13 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `is_authenticated`: True on successful API authentication
* `username`: Username of the user authenticated as, null otherwise.
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
* DELETE `/api/delete-token/`: Deletes the currently used token.
```bash
# Logout
curl -X DELETE https://content.minetest.net/api/delete-token/ \
-H "Authorization: Bearer YOURTOKEN"
```
## Packages
@@ -46,28 +85,88 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved).
* `short_description`
* `tags`: List of tag names, see [misc](#misc).
* `content_warnings`: List of content warning names, see [misc](#misc).
* `license`: A license name.
* `media_license`: A license name.
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
`LOOKING_FOR_MAINTAINER`.
* `tags`: List of [tag](#tags) names.
* `content_warnings`: List of [content warning](#content-warnings) names.
* `license`: A [license](#licenses) name.
* `media_license`: A [license](#licenses) name.
* `long_description`: Long markdown description.
* `repo`: Git repo URL.
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* `video_url`: URL to a video.
* `donate_url`: URL to a donation page.
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
* GET `/api/packages/<author>/<name>/hypertext/`
* Converts the long description to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
to be used in a `hypertext` formspec element.
* Query arguments:
* `formspec_version`: Required, maximum supported formspec version.
* `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description.
* `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text.
* GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates
* If query argument `only_hard` is present, only hard deps will be returned.
* GET `/api/dependencies/`
* Returns `provides` and raw dependencies for all packages.
* Supports [Package Queries](#package-queries)
* [Paginated result](#paginated-results), max 300 results per page
* Each item in `items` will be a dictionary with the following keys:
* `type`: One of `GAME`, `MOD`, `TXP`.
* `author`: Username of the package author.
* `name`: Package name.
* `provides`: List of technical mod names inside the package.
* `depends`: List of hard dependencies.
* Each dep will either be a modname dependency (`name`), or a
package dependency (`author/name`).
* `optional_depends`: list of optional dependencies
* Same as above.
* GET `/api/packages/<username>/<name>/stats/`
* Returns daily stats for package, or null if there is no data.
* Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning.
* Query args:
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
* `end`: end date, inclusive. Optional. Default: today. UTC.
* An object with the following keys:
* `start`: start date, inclusive. Ex: 2022-10-22. M
* `end`: end date, inclusive. Ex: 2022-11-05.
* `platform_minetest`: list of integers per day.
* `platform_other`: list of integers per day.
* `reason_new`: list of integers per day.
* `reason_dependency`: list of integers per day.
* `reason_update`: list of integers per day.
* GET `/api/package_stats/`
* Returns last 30 days of daily stats for _all_ packages.
* An object with the following keys:
* `start`: start date, inclusive. Ex: 2022-10-22.
* `end`: end date, inclusive. Ex: 2022-11-05.
* `package_downloads`: map from package key to list of download integers.
You can download a package by building one of the two URLs:
```
https://content.minetest.net/packages/${author}/${name}/download/`
https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
```
Examples:
```bash
# Edit packages
curl -X PUT http://localhost:5123/api/packages/username/name/ \
# Edit package
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
# Remove website URL
curl -X PUT http://localhost:5123/api/packages/username/name/ \
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "website": null }'
```
@@ -84,6 +183,7 @@ Supported query parameters:
* `q`: Query string.
* `author`: Filter by author.
* `tag`: Filter by tags.
* `game`: Filter by [Game Support](/help/game_support/), ex: `Wuzzy/mineclone2`. (experimental, doesn't show items that support every game currently).
* `random`: When present, enable random ordering and ignore `sort`.
* `limit`: Return at most `limit` packages.
* `hide`: Hide content based on [Content Flags](/help/content_flags/).
@@ -91,14 +191,18 @@ Supported query parameters:
* `order`: Sort ascending (`asc`) or descending (`desc`).
* `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* `fmt`: How the response is formated.
* `fmt`: How the response is formatted.
* `keys`: author/name only.
* `short`: stuff needed for the Minetest client.
* `short`: stuff needed for the Minetest client.
## Releases
### Releases
* GET `/api/packages/<username>/<name>/releases/` (List)
* GET `/api/releases/` (List)
* Limited to 30 most recent releases.
* Optional arguments:
* `author`: Filter by author
* `maintainer`: Filter by maintainer
* Returns array of release dictionaries with keys:
* `id`: release ID
* `title`: human-readable title
@@ -108,6 +212,17 @@ Supported query parameters:
* `downloads`: number of downloads
* `min_minetest_version`: dict or null, minimum supported minetest version (inclusive).
* `max_minetest_version`: dict or null, minimum supported minetest version (inclusive).
* `package`
* `author`: author username
* `name`: technical name
* `type`: `mod`, `game`, or `txp`
* GET `/api/updates/` (Look-up table)
* Returns a look-up table from package key (`author/name`) to latest release id
* Query arguments
* `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* GET `/api/packages/<username>/<name>/releases/` (List)
* Returns array of release dictionaries, see above, but without package info.
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
* POST `/api/packages/<username>/<name>/releases/new/` (Create)
* Requires authentication.
@@ -116,13 +231,14 @@ Supported query parameters:
* For Git release creation:
* `method`: must be `git`.
* `ref`: (Optional) git reference, eg: `master`.
* For zip upload release creation:
* `file`: multipart file to upload, like `<input type=file>`.
* For zip upload release creation:
* `file`: multipart file to upload, like `<input type="file" name="file">`.
* `commit`: (Optional) Source Git commit hash, for informational purposes.
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
* Requires authentication.
* Deletes release.
Examples:
```bash
@@ -136,13 +252,18 @@ curl -X POST https://content.minetest.net/api/packages/username/name/releases/ne
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/file.zip
# Create release from zip upload with commit hash
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F commit="8ef74deec170a8ce789f6055a59d43876d16a7ea" -F file=@path/to/file.zip
# Delete release
curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/3/ \
-H "Authorization: Bearer YOURTOKEN"
-H "Authorization: Bearer YOURTOKEN"
```
## Screenshots
### Screenshots
* GET `/api/packages/<username>/<name>/screenshots/` (List)
* Returns array of screenshot dictionaries with keys:
@@ -152,6 +273,7 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
* `url`: absolute URL to screenshot.
* `created_at`: ISO time.
* `order`: Number used in ordering.
* `is_cover_image`: true for cover image.
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
* Returns screenshot dictionary like above.
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
@@ -159,66 +281,224 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
* Body is multipart form data.
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
* `file`: multipart file to upload, like `<input type=file>`.
* `is_cover_image`: set cover image to this.
* DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
* Requires authentication.
* Deletes screenshot.
* POST `/api/packages/<username>/<name>/screenshots/order/`
* Requires authentication.
* Body is a JSON array containing the screenshot IDs in their order.
* POST `/api/packages/<username>/<name>/screenshots/cover-image/`
* Requires authentication.
* Body is a JSON dictionary with "cover_image" containing the screenshot ID.
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
Examples:
```bash
# Create screenshots
# Create screenshot
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png
# Create screenshot and set it as the cover image
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
# Delete screenshot
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
-H "Authorization: Bearer YOURTOKEN"
-H "Authorization: Bearer YOURTOKEN"
# Reorder screenshots
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "[13, 2, 5, 7]"
# Set cover image
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/cover-image/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "{ 'cover_image': 123 }"
```
### Reviews
* GET `/api/packages/<username>/<name>/reviews/` (List)
* Returns array of review dictionaries with keys:
* `user`: dictionary with `display_name` and `username`.
* `title`: review title
* `comment`: the text
* `rating`: 1 for negative, 3 for neutral, 5 for positive
* `is_positive`: boolean
* `created_at`: iso timestamp
* `votes`: dictionary with `helpful` and `unhelpful`,
* GET `/api/reviews/` (List)
* Returns a paginated response. This is a dictionary with `page`, `url`, and `items`.
* [Paginated result](#paginated-results)
* `items`: array of review dictionaries, like above
* Each review also has a `package` dictionary with `type`, `author` and `name`
* Ordered by created at, newest to oldest.
* Query arguments:
* `page`: page number, integer from 1 to max
* `n`: number of results per page, max 200
* `author`: filter by review author username
* `for_user`: filter by package author
* `rating`: 1 for negative, 3 for neutral, 5 for positive
* `is_positive`: true or false. Default: null
* `q`: filter by title (case-insensitive, no fulltext search)
Example:
```json
[
{
"comment": "This is a really good mod!",
"created_at": "2021-11-24T16:18:33.764084",
"is_positive": true,
"title": "Really good",
"user": {
"display_name": "rubenwardy",
"username": "rubenwardy"
},
"votes": {
"helpful": 0,
"unhelpful": 0
}
}
]
```
## Users
* GET `/api/users/<username>/`
* `username`
* `display_name`: human-readable name to be displayed in GUIs.
* `rank`: ContentDB [rank](/help/ranks_permissions/).
* `profile_pic_url`: URL to profile picture, or null.
* `website_url`: URL to website, or null.
* `donate_url`: URL to donate page, or null.
* `connections`: object
* `github`: GitHub username, or null.
* `forums`: forums username, or null.
* `links`: object
* `api_packages`: URL to API to list this user's packages.
* `profile`: URL to the HTML profile page.
* GET `/api/users/<username>/stats/`
* Returns daily stats for the user's packages, or null if there is no data.
* Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning.
* Query args:
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
* `end`: end date, inclusive. Optional. Default: today. UTC.
* A table with the following keys:
* `from`: start date, inclusive. Ex: 2022-10-22.
* `end`: end date, inclusive. Ex: 2022-11-05.
* `package_downloads`: map of package title to list of integers per day.
* `platform_minetest`: list of integers per day.
* `platform_other`: list of integers per day.
* `reason_new`: list of integers per day.
* `reason_dependency`: list of integers per day.
* `reason_update`: list of integers per day.
## Topics
* GET `/api/topics/`: Supports [Package Queries](#package-queries), and the following two options:
* `show_added`: Show topics which exist as packages, default true.
* `show_discarded`: Show topics which have been marked as outdated, default false.
* GET `/api/topics/` ([View](/api/topics/))
* See [Topic Queries](#topic-queries)
### Topic Queries
Example:
/api/topics/?q=mobs
/api/topics/?q=mobs&type=mod&type=game
Supported query parameters:
* `q`: Query string.
* `sort`: Sort by (`name`, `views`, `date`).
* `order`: Sort ascending (`asc`) or descending (`desc`).
* `type`: Package types (`mod`, `game`, `txp`).
* `sort`: Sort by (`name`, `views`, `created_at`).
* `show_added`: Show topics that have an existing package.
* `show_discarded`: Show topics marked as discarded.
* `limit`: Return at most `limit` topics.
## Misc
## Collections
* GET `/api/scores/`
* See [Package Queries](#package-queries)
* GET `/api/tags/`: List of:
* GET `/api/collections/`
* Query args:
* `author`: collection author username.
* `package`: collections that contain the package.
* Returns JSON array of collection entries:
* `author`: author username.
* `name`: collection name.
* `title`
* `short_description`
* `created_at`: creation time in iso format.
* `private`: whether collection is private, boolean.
* `package_count`: number of packages, integer.
* GET `/api/collections/<username>/<name>/`
* Returns JSON object for collection:
* `author`: author username.
* `name`: collection name.
* `title`
* `short_description`
* `long_description`
* `created_at`: creation time in iso format.
* `private`: whether collection is private, boolean.
* `items`: array of item objects:
* `package`: short info about the package.
* `description`: custom short description.
* `created_at`: when the package was added to the collection.
* `order`: integer.
## Types
### Tags
* GET `/api/tags/` ([View](/api/tags/)): List of:
* `name`: technical name.
* `title`: human-readable title.
* `description`: tag description or null.
* `views`: number of views of this tag.
### Content Warnings
* GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
* `name`: technical name
* `title`: human-readable title
* `description`: tag description or null
* GET `/api/licenses/`: List of:
### Licenses
* GET `/api/licenses/` ([View](/api/licenses/)): List of:
* `name`
* `is_foss`: whether the license is foss
* GET `/api/homepage/`
### Minetest Versions
* GET `/api/minetest_versions/` ([View](/api/minetest_versions/))
* `name`: Version name.
* `is_dev`: boolean, is dev version.
* `protocol_version`: protocol version umber.
## Misc
* GET `/api/scores/` ([View](/api/scores/))
* See [Top Packages Algorithm](/help/top_packages/).
* Supports [Package Queries](#package-queries).
* Returns list of:
* `author`: package author name.
* `name`: package technical name.
* `downloads`: number of downloads.
* `score`: total package score.
* `score_reviews`: score from reviews.
* `score_downloads`: score from downloads.
* GET `/api/homepage/` ([View](/api/homepage/)) - get contents of homepage.
* `count`: number of packages
* `downloads`: get number of downloads
* `new`: new packages
@@ -227,4 +507,22 @@ Supported query parameters:
* `pop_txp`: popular textures
* `pop_game`: popular games
* `high_reviewed`: highest reviewed
* GET `/api/minetest_versions/`
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
* `featured`: featured games
* GET `/api/cdb_schema/` ([View](/api/cdb_schema/))
* Get JSON Schema of `.cdb.json`, including licenses, tags and content warnings.
* See [JSON Schema Reference](https://json-schema.org/).
* POST `/api/hypertext/`
* Converts HTML or Markdown to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
to be used in a `hypertext` formspec element.
* Post data: HTML or Markdown as plain text.
* Content-Type: `text/html` or `text/markdown`.
* Query arguments:
* `formspec_version`: Required, maximum supported formspec version. Ie: 6
* `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description.
* `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text.

View File

@@ -0,0 +1,14 @@
title: Contact Us
## Reports
Please let us know if anything on the ContentDB violates our rules or any applicable
laws.
We take copyright violation and other offenses very seriously.
<a href="/report/" class="btn btn-primary">Report</a>
## Other
<a href="https://rubenwardy.com/contact/" class="btn btn-primary">Contact the admin</a>

View File

@@ -15,20 +15,27 @@ contentdb_flag_blacklist = nonfree, bad_language, drugs
A flag can be:
* `nonfree` - can be used to hide packages which do not qualify as
'free software', as defined by the Free Software Foundation.
* `nonfree`: can be used to hide packages which do not qualify as
'free software', as defined by the Free Software Foundation.
* `wip`: packages marked as Work in Progress
* `deprecated`: packages marked as Deprecated
* A content warning, given below.
* `android_default` - meta-flag that filters out any content with a content warning.
* `desktop_default` - meta-flag that doesn't filter anything out for now.
* `*`: hides all content warnings.
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list
without making a release.
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
## Content Warnings
Packages with mature content will be tagged with a content warning based
on the content type.
* `bad_language` - swearing.
* `drugs` - drugs or alcohol.
* `bad_language`: swearing.
* `drugs`: drugs or alcohol.
* `gambling`
* `gore` - blood, etc.
* `horror` - shocking and scary content.
* `violence` - non-cartoon violence.
* `gore`: blood, etc.
* `horror`: shocking and scary content.
* `violence`: non-cartoon violence.

View File

@@ -0,0 +1,146 @@
title: Copyright Guide
## Why should I care?
Falling foul of copyright law can put you and ContentDB into legal trouble. Receiving a Cease and Desist, DMCA notice,
or a Court Summons isn't pleasant for anyone, and can turn out to be very expensive. This page contains some
guidance on how to ensure your content is clearly licensed and attributed to avoid these issues.
Additionally, ContentDB and the forums both have some
[requirements on the licenses](/policy_and_guidance/#41-allowed-licenses) you are allowed to use. Both require
[free distribution and modification](/help/non_free/), allowing us to remain an open community where people can fork
and remix each other's content. To this end, you need to make sure your content is clearly licensed.
**As always, we are not lawyers and this does not constitute legal advice.**
## What do I need to do?
### Follow the licenses
Make sure you understand the licenses for anything you copy into your content.
[TL;DR Legal](https://tldrlegal.com/license/mit-license) is a good resource for quickly understanding
licenses, although you should actually read the text as well.
If you use code from other sources (such as mods or games), you'll need to make sure you follow
their license. A common one is attribution, you should do this by adding a comment next to the
code and crediting the author in your LICENSE file.
It's sometimes fine to copy trivial/small amounts of code under fair use, but this
is a bit of a grey area. It's better to understand the solution and rewrite it yourself.
### List the sources of your media
It's a good idea to create a list of all the media you used in your package, as it allows
you to keep track of where the media came from. Media includes textures, 3d models,
sounds, and more.
You should have the following information:
* File name (as found in your package)
* Author name
* License
* Source (URL to the webpage, mod name, website name)
It's common to do this in README.md or LICENSE.md like so:
```md
* conquer_arrow_*.png from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC0 1.0.
* conquer_arrow.b3d from [Simple Shooter](https://github.com/stujones11/shooter) by Stuart Jones, CC-BY-SA 3.0.
* conquer_arrow_head.png from MTG, CC-BY-SA 3.0.
* health_*.png from [Gauges](https://content.minetest.net/packages/Calinou/gauges/) by Calinou, CC0.
```
if you have a lot of media, then you can split it up by author like so:
```md
[Kenney](https://www.kenney.nl/assets/voxel-pack), CC0:
* mymod_fence.png
John Green, CC BY-SA 4.0 from [OpenGameArt](https://opengameart.org/content/tiny-16-basic):
* mymod_texture.png
* mymod_another.png
Your Name, CC BY-SA 4.0:
* mymod_texture_i_made.png
```
## Where can I get freely licensed media?
* [OpenGameArt](https://opengameart.org/) - everything
* [Kenney game assets](https://www.kenney.nl/assets) - everything
* [Free Sound](https://freesound.org/) - sounds
* [PolyHaven](https://polyhaven.com/) - 3d models and textures.
* Other Minetest mods/games
Don't assume the author has correctly licensed their work.
Make sure they have clearly indicated the source in a list [like above](#list-the-sources-of-your-media).
If they didn't make it, then go to the actual source to check the license.
## Common Situations
### I made it myself, using X as a guide
Copying by hand is still copying, the law doesn't distinguish this from copy+paste.
Make your own art without copying colors or patterns from existing games/art.
If you need a good set of colors, see [LOSPEC](https://lospec.com/palette-list).
### I got it from Google Images / Search / the Internet
You do not have permission to use things unless you are given permission to do so by the author.
No license is exactly the same as "Copyright &copy; All Rights Reserved".
To use on ContentDB or the forums, you must also be given a clear license.
Try searching with "creative commons" in the search term, and then clicking through to the page
and looking for a license. Make sure the source looks trustworthy, as there are a lot of websites
that rip off art and give an incorrect license. But it might be better to use a trusted source directly, see
[the section above](#where-can-i-get-freely-licensed-media) for a list.
### I have permission from the author
You'll also need to make sure that the author gives you an explicit license for it, such as CC BY-SA 4.0.
Permission for *you* to use it doesn't mean that *everyone* has permission to use it. A license outlines the terms of
the permission, making things clearer and less vague.
### The author said it's free for anyone to use, is that enough?
No, you need an explicit license like CC0 or CC BY-SA 4.0. ContentDB does not allow custom licenses
or public domain.
### I used an AI
Errrr. This is a legally untested area, we highly recommend that **you don't use AI art/code** in packages
for that reason.
For now, we haven't banned AI art/code from ContentDB. Make sure to clearly include it in your package's
credit list (include the name of the AI tool used).
Check the tools terms and conditions to see if there are any constraints on use. It looks
like AI-generated art and code isn't copyrightable by itself, but the tool's T&Cs may still
impose conditions.
AI art/code may regurgitate copyrighted things. Make sure that you don't include the
names of any copyrighted materials in your AI prompts, such as names of games or artists.
## What does ContentDB do?
The package authors and maintainers are responsible for the licenses and copyright of packages on ContentDB.
ContentDB editors will check packages to make sure the package page's license matches up with the list of licenses
inside the package download, but do not investigate each piece of media or line of code.
If a copyright violation is reported to us, we will unlist the package and contact the author/maintainers.
Once the problem has been fixed, the package can be restored.
## Where can I get help?
[Join](https://www.minetest.net/get-involved/) IRC, Matrix, or Discord to ask for help.
In Discord, there are the #assets or #contentdb channels. In IRC or Matrix, you can just ask in the main channels.
If your package is already on ContentDB, you can open a thread.

View File

@@ -15,8 +15,9 @@ Editors should make sure they are familiar with the
## ContentDB is not a curated platform
It's important to note that ContentDB isn't a curated platform, but it also does have some
requirements on minimum usefulness. See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
It's important to note that ContentDB isn't a curated platform - a mod doesn't need to be good
to be accepted, but there are some minimum requirements when it comes to usefulness and other things.
See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
## Editor Work Queue
@@ -32,3 +33,26 @@ The [Editor Work Queue](/todo/) and related pages contain useful information for
Editors currently receive notifications for any new thread opened on a package, so that they
know when a user is asking for help. These notifications are shown separately in the notifications
interface, and can be configured separately in Emails and Notifications.
## Crash Course to being an Editor
The [Package Inclusion Policy and Guidance](/policy_and_guidance/) is our go-to resource for making decisions in
changes needed, similar to how lua_api.txt is the doc for modders to consult.
In the [Editor console](/todo/), the two most important tabs are the Editor Work Queue and the Forum
Topics tab. Primarily you will be focusing on the Editor Work Queue tab, where a list of packages to review is.
When you have some free time, feel free to scroll through the Forum Topics page and import mods into ContentDB.
But don't import a mod if it's broken, outdated, not that useful, or not worth importing - click Discard instead.
A simplified process for reviewing a package is as follows:
1. scan the package image if present for any obvious closed source assets.
2. if right to a name warning is present, check its validity and if the package meets
the exceptions.
3. if the forums topic missing warning is present, feel free to check it, but it's
usually incorrect.
4. check source, etc links to make sure they work and are correct.
5. verify that the package has license file that matches what is on the contentdb fields
6. verify that all assets and code are licensed correctly
7. if the above steps pass, approve the package, else request changes needed from the author

64
app/flatpages/help/faq.md Normal file
View File

@@ -0,0 +1,64 @@
title: Frequently Asked Questions
description: FAQ about using ContentDB
## Users and Logins
### How do I create an account?
How you create an account depends on whether you have a forum account.
If you have a forum account, then you'll need to prove that you are the owner of the account. This can
be done using a GitHub account or a random string in your forum account signature.
If you don't, then you can just sign up using an email address and password.
GitHub can only be used to log in, not to register.
<a class="btn btn-primary" href="/user/claim/">Register</a>
### My verification email never arrived
There are a number of reasons this may have happened:
* Incorrect email address entered.
* Temporary problem with ContentDB.
* Email has been unsubscribed.
**When creating an account by email:**
If the email doesn't arrive after registering by email, then you'll need to
try registering again in 12 hours. Unconfirmed accounts are deleted after 12 hours.
**When changing your email (or it was set after a forum-based registration)**:
then you can just set a new email in
[Settings > Email and Notifications](/user/settings/email/).
If you have previously unsubscribed this email, then ContentDB is completely prevented from sending emails to that
address. You'll need to use a different email address, or [contact rubenwardy](https://rubenwardy.com/contact/) to
remove your email from the blacklist.
## Packages
### How can I create releases automatically?
There are a number of methods:
* [Git Update Detection](/help/update_config/): ContentDB will check your Git repo daily, and create updates or send you notifications.
* [Webhooks](/help/release_webhooks/): you can configure your Git host to send a webhook to ContentDB, and create an update immediately.
* the [API](/help/api/): This is especially powerful when combined with CI/CD and other API endpoints.
### How do I learn how to make mods and games for Minetest?
You should read
[the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
for a guide to making mods and games using Minetest.
### How do I install something from here?
See [Installing content](/help/installing/).
## How do I get help?
Please [contact rubenwardy](https://rubenwardy.com/contact/).

View File

@@ -0,0 +1,137 @@
title: Featured Packages
<p class="alert alert-warning">
<b>Note:</b> This is a draft, and is likely to change
</p>
## What are Featured Packages?
Featured Packages are shown at the top of the ContentDB homepage. In the future,
featured packages may be shown inside the Minetest client.
The purpose is to promote content that demonstrates a high quality of what is
possible in Minetest. The selection should be varied, and should vary over time.
The featured content should be content that we are comfortable recommending to
a first time player.
## How are the packages chosen?
Before a package can be considered, it must fulfil the criteria in the below lists.
There are three types of criteria:
* "MUST": These must absolutely be fulfilled, no exceptions!
* "SHOULD": Most of them should be fulfilled, if possible. Some of them can be
left out if there's a reason.
* "CAN": Can be fulfilled for bonus points, they are entirely optional.
For a chance to get featured, a package must fulfil all "MUST" criteria and
ideally as many "SHOULD" criteria as possible. The more, the better. Thankfully,
many criteria are trivial to fulfil. Note that ticking off all the boxes is not
enough: Just because a package completes the checklist does not make it good.
Other aspects of the package should be rated as well. See this list as a
starting point, not as an exhaustive quality control.
Editors are responsible for maintaining the list of featured packages. Authors
can request that their package be considered by opening a thread titled
"Feature Package" on their package. To speed things up, they should justify
why they meet (or don't meet) the below criteria. Editors must abstain from
voting on packages where they have a conflict of interest.
A package being featured does not mean that it will be featured forever. A
package may be unfeatured if it no longer meets the criteria, to make space for
other packages to be featured, or for another reason.
## General Requirements
### General
* MUST: Be 100% free and open source (as marked as Free on ContentDB).
* MUST: Work out-of-the-box (no weird setup or settings required).
* MUST: Be compatible with the latest stable Minetest release.
* SHOULD: Use public source control (such as Git).
* SHOULD: Have at least 3 reviews, and be largely positive.
### Stability
* MUST: Be well maintained (author is present and active).
* MUST: Be reasonably stable, with no game-breaking or major bugs.
* MUST: The author does not consider the package to be in an
experimental/development/alpha state. Beta and "unfinished" packages are fine.
* MUST: No error messages from the engine (e.g. missing textures).
* SHOULD: No major map breakages (including unknown nodes, corruption, loss of inventories).
Map breakages are a sign that the package isn't sufficiently stable.
Note: Any map breakage will be excused if "disaster relief" (i.e. tools to repair the damage)
is available.
### Meta and packaging
* MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200).
* MUST: Have a high resolution cover image on ContentDB (at least 1280x720 pixels).
It may be shown cropped to 16:9 aspect ratio, or shorter.
* MUST: mod.conf/game.conf/texture_pack.conf present with:
* name (if mod or game)
* description
* dependencies (if relevant)
* `min_minetest_version` and `max_minetest_version` (if relevant)
* MUST: Contain a README file and a LICENSE file. These may be `.md` or `.txt`.
* README files typically contain helpful links (download, manual, bugtracker, etc), and other
information that players or (potential) contributors may need.
* SHOULD: All important settings are in settingtypes.txt with description.
## Game-specific Requirements
### Meta and packaging
* MUST: Have a main menu icon and header image.
### Stability
* MUST: If any major setting (like `enable_damage`) is unsupported, the game must disable it
using `disabled_settings` in the `game.conf`, and deal with it appropriately in the code
(e.g. force-disable the setting, as the user may still set the setting in `minetest.conf`)
### Usability
* MUST: Unsupported mapgens are disabled in game.conf.
* SHOULD: Passes the Beginner Test: A newbie to the game (but not Minetest) wouldn't get completely
stuck within the first 5 minutes of playing.
* SHOULD: Have good documentation. This may include one or more of:
* A craftguide, or other in-game learning system
* A manual
* A wiki
* Something else
### Gameplay
* CAN: Passes the Six Hour Test (only applies to sandbox games): The game doesn't run out of new
content before the first 6 hours of playing.
* CAN: Players don't feel that something in the game is "lacking".
### Audiovisuals
* MUST: Audiovisual design should be of good quality.
* MUST: No obvious GUI/HUD breakages.
* MUST: Sounds have no obvious artifacts like clicks or unintentional noise.
* SHOULD: Graphical design is mostly consistent.
* SHOULD: Sounds are used.
* SHOULD: Sounds are normalized (more or less).
### Quality Assurance
* MUST: No flooding the console/log file with warnings.
* MUST: No duplicate crafting recipes.
* MUST: Highly experimental game features are disabled by default.
* MUST: Experimental game features are clearly marked as such.
* SHOULD: No unknown nodes/items/objects appear.
* SHOULD: No dependency on legacy API calls.
* SHOULD: No console warnings.
### Writing
* MUST: All items that can be obtained in normal gameplay have `description` set (whether in the definition or meta).
* MUST: Game is not littered with typos or bad grammar (a few typos are OK but should be fixed, when found).
* SHOULD: All items have unique names (items which disguise themselves as another item are exempt).
* SHOULD: The writing style of all item names is grammatical and consistent.
* SHOULD: Descriptions of things convey useful and meaningful information (if applicable).
* CAN: Text is written in clear and (if possible) simple language.

View File

@@ -0,0 +1,56 @@
title: Supported Games
<p class="alert alert-warning">
This feature is experimental
</p>
## Why?
The supported/compatible games feature allows mods to specify the games that
they work with, which improves user experience.
## Support sources
### mod.conf / texture_pack.conf
You can use `supported_games` to specify games that your mod/modpack/texture
pack is compatible with.
You can use `unsupported_games` to specify games that your package doesn't work
with, which is useful for overriding ContentDB's automatic detection.
Both of these are comma-separated lists of game technical ids. Any `_game`
suffixes are ignored, just like in Minetest.
supported_games = minetest_game, repixture
unsupported_games = lordofthetest, nodecore, whynot
If your package supports all games by default, you can put "*" in
supported_games. You can still use unsupported_games to mark games as
unsupported. You can also specify games that you've tested in supported_games.
# Should work with all games but I've only tested using Minetest Game:
supported_games = *, minetest_game
# But doesn't work in capturetheflag
unsupported_game = capturetheflag
### Dependencies
ContentDB will analyse hard dependencies and work out which games a mod
supports.
This uses a recursive algorithm that works out whether a dependency can be
installed independently, or if it requires a certain game.
### On ContentDB
You can define supported games on ContentDB, but using .conf is recommended
instead.
## Combining all the sources
.conf will override anything ContentDB detects. The manual override on ContentDB
overrides .conf and dependencies.

View File

@@ -0,0 +1,89 @@
title: How to install mods, games, and texture packs
description: A guide to installing mods, games, and texture packs in Minetest.
## Installing from the main menu (recommended)
### Install
1. Open the mainmenu
2. Go to the Content tab and click "Browse online content".
If you don't see this, then you need to update Minetest to v5.
3. Search for the package you want to install, and click "Install".
4. When installing a mod, you may be shown a dialog about dependencies here.
Make sure the base game dropdown box is correct, and then click "Install".
<div class="row mt-5">
<div class="col-md-6">
<figure>
<a href="/static/installing_content_tab.png">
<img class="w-100" src="/static/installing_content_tab.png" alt="Screenshot of the content tab in minetest">
</a>
<figcaption class="text-muted ps-1">
1. Click Browser Online Content in the content tab.
</figcaption>
</figure>
</div>
<div class="col-md-6">
<figure>
<a href="/static/installing_cdb_dialog.png">
<img class="w-100" src="/static/installing_cdb_dialog.png" alt="Screenshot of the content tab in minetest">
</a>
<figcaption class="text-muted ps-1">
2. Search for the package and click "Install".
</figcaption>
</figure>
</div>
</div>
Troubleshooting:
* I can't find it in the ContentDB dialog (Browse online content)
* Make sure that you're on the latest version of Minetest.
* Are you using Android? Packages with content warnings are hidden by default on android,
you can show them by removing `android_default` from the `contentdb_flag_blacklist` setting.
* Does the webpage show "Non-free" warnings? Non-free content is hidden by default from all clients,
you can show them by removing `nonfree` from the `contentdb_flag_blacklist` setting.
* It says "required dependencies could not be found"
* Make sure you're using the correct "Base Game". A lot of packages only work with certain games, you can look
at "Compatible Games" on the web page to see which.
### Enable in Select Mods
1. Mods: Enable the content using "Select Mods" when selecting a world.
2. Games: choose a game when making a world.
3. Texture packs: Content > Select pack > Click enable.
<div class="row mt-5">
<div class="col-md-6">
<figure>
<a href="/static/installing_select_mods.png">
<img class="w-100" src="/static/installing_select_mods.png" alt="Screenshot of Select Mods in Minetest">
</a>
<figcaption class="text-muted ps-1">
Enable mods using the Select Mods dialog.
</figcaption>
</figure>
</div>
</div>
## Installing using the command line
### Git clone
1. Install git
2. Find the package on ContentDB and copy "source" link.
3. Find the user data directory.
In 5.4.0 and above, you can click "Open user data directory" in the Credits tab.
Otherwise:
* Windows: whereever you extracted or installed Minetest to.
* Linux: usually `~/.minetest/`
4. Open or create the folder for the type of content (`mods`, `games`, or `textures`)
5. Git clone there
6. For mods, make sure to install any required dependencies.
### Enable
* Mods: Edit world.mt in the world's folder to contain `load_file_MODNAME = true`
* Games: Use `--game` or edit game_id in world.mt.
* Texture packs: change the `texture_path` setting to the texture pack absolute path.

View File

@@ -6,7 +6,14 @@ title: Prometheus Metrics
dimensional data model, flexible query language, efficient time series database
and modern alerting approach".
Prometheus Metrics can be accessed at [/metrics](/metrics).
Prometheus Metrics can be accessed at [/metrics](/metrics), or you can view them
on the Grafana instance below.
<p>
<a class="btn btn-primary" href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">
View ContentDB on Grafana
</a>
</p>
## Metrics

View File

@@ -55,7 +55,7 @@ Here's a quick summary related to Minetest content:
Non-free packages are hidden in the client by default, partly in order to comply
with the rules of various Linux distributions.
Users can opt-in to showing non-free software, if they wish:
Users can opt in to showing non-free software, if they wish:
1. In the main menu, go to Settings > All settings
2. Search for "ContentDB Flag Blacklist".
@@ -66,8 +66,8 @@ Users can opt-in to showing non-free software, if they wish:
<figcaption class="figure-caption">Screenshot of the ContentDB Flag Blacklist setting</figcaption>
</figure>
In the future, [the `platform_default` flag](/help/content_flags/) will be used to control what content
each platforms shows - Android is significantly stricter about mature content.
You may wish to remove all text from that setting completely, leaving it blank,
if you wish to view all content when this happens. Currently, [mature content is
not permitted on ContentDB](/policy_and_guidance/).
The [`platform_default` flag](/help/content_flags/) is used to control what content
each platforms shows. It doesn't hide anything on Desktop, but hides all mature
content on Android. You may wish to remove all text from that setting completely,
leaving it blank. See [Content Warnings](/help/content_flags/#content-warnings)
for information on mature content.

106
app/flatpages/help/oauth.md Normal file
View File

@@ -0,0 +1,106 @@
title: OAuth2 API
<p class="alert alert-warning">
The OAuth2 applications API is currently experimental, and may break without notice.
</p>
ContentDB allows you to create an OAuth2 Application and obtain access tokens
for users.
## Create an OAuth2 Client
Go to Settings > [OAuth2 Applications](/user/apps/) > Create
## Obtaining access tokens
ContentDB supports the Authorization Code OAuth2 method.
### Authorize
Get the user to open the following URL in a web browser:
```
https://content.minetest.net/oauth/authorize/
?response_type=code
&client_id={CLIENT_ID}
&redirect_uri={REDIRECT_URL}
```
The redirect_url must much the value set in your oauth client. Make sure to URL encode it.
ContentDB also supports `state`.
Afterwards, the user will be redirected to your callback URL.
If the user accepts the authorization, you'll receive an authorization code (`code`).
Otherwise, the redirect_url will not be modified.
For example, with `REDIRECT_URL` set as `https://example.com/callback/`:
* If the user accepts: `https://example.com/callback/?code=abcdef`
* If the user cancels: `https://example.com/callback/`
### Exchange auth code for access token
Next, you'll need to exchange the auth for an access token.
Do this by making a POST request to the `/oauth/token/` API:
```bash
curl -X POST https://content.minetest.net/oauth/token/ \
-F grant_type=authorization_code \
-F client_id="CLIENT_ID" \
-F client_secret="CLIENT_SECRET" \
-F code="abcdef"
```
<p class="alert alert-warning">
<i class="fas fa-exclamation-circle me-2"></i>
You should make this request on a server to prevent the user
from getting access to your client secret.
</p>
If successful, you'll receive:
```json
{
"access_token": "access_token",
"token_type": "Bearer"
}
```
If there's an error, you'll receive a standard API error message:
```json
{
"success": false,
"error": "The error message"
}
```
Possible errors:
* Unsupported grant_type, only authorization_code is supported
* Missing client_id
* Missing client_secret
* Missing code
* client_id and/or client_secret is incorrect
* Incorrect code. It may have already been redeemed
### Check access token
Next, you should check the access token works by getting the user information:
```bash
curl https://content.minetest.net/api/whoami/ \
-H "Authorization: Bearer YOURTOKEN"
```
## Scopes
* (no scope) - public data only
* `user:email`: read user email
* `package`: write access to packages
* `package:release`: create and delete releases
* `package:screenshot`: create, edit, delete screenshots

View File

@@ -19,15 +19,26 @@ The filename of the `.conf` file depends on the content type:
* `game.conf` for games.
* `texture_pack.conf` for texture packs.
The `.conf` uses a key-value format, separated using equals. Here's a simple example:
The `.conf` uses a key-value format, separated using equals.
Here's a simple example of `mod.conf`, `modpack.conf`, or `texture_pack.conf`:
name = mymod
title = My Mod
description = A short description to show in the client.
Here's a simple example of `game.conf`:
title = My Game
description = A short description to show in the client.
Note that you should not specify `name` in game.conf.
### Understood values
ContentDB understands the following information:
* `title` - A human-readable title.
* `description` - A short description to show in the client.
* `depends` - Comma-separated hard dependencies.
* `optional_depends` - Comma-separated soft dependencies.
@@ -37,6 +48,8 @@ ContentDB understands the following information:
and for mods only:
* `name` - the mod technical name.
* `supported_games` - List of supported game technical names.
* `unsupported_games` - List of not supported game technical names. Useful to override game support detection.
## .cdb.json
@@ -50,6 +63,8 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved).
* `short_description`
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
`LOOKING_FOR_MAINTAINER`.
* `tags`: List of tag names, see [/api/tags/](/api/tags/).
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
* `license`: A license name, see [/api/licenses/](/api/licenses/).
@@ -59,8 +74,10 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* `video_url`: URL to a video.
* `donate_url`: URL to a donation page.
Use `null` to unset fields where relevant.
Use `null` or `[]` to unset fields where relevant.
Example:

View File

@@ -2,10 +2,11 @@ title: Ranks and Permissions
## Overview
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
* **Members** - Trusted to change the meta data of their own packages', but cannot approve their own packages.
* **New Members** - mostly untrusted, cannot change package metadata or publish releases without approval.
* **Members** - Trusted to change the metadata of their own packages', but cannot approve their own packages.
* **Trusted Members** - Same as above, but can approve their own releases.
* **Editors** - Trusted to edit any package or release, and also responsible for approving new packages.
* **Approvers** - Responsible for approving new packages, screenshots, and releases.
* **Editors** - Same as above, and can edit any package or release.
* **Moderators** - Same as above, but can manage users.
* **Admins** - Full access.
@@ -18,6 +19,7 @@ title: Ranks and Permissions
<th colspan=2 class="NEW_MEMBER">New Member</th>
<th colspan=2 class="MEMBER">Member</th>
<th colspan=2 class="TRUSTED_MEMBER">Trusted</th>
<th colspan=2 class="APPROVER">Approver</th>
<th colspan=2 class="EDITOR">Editor</th>
<th colspan=2 class="MODERATOR">Moderator</th>
<th colspan=2 class="ADMIN">Admin</th>
@@ -36,6 +38,8 @@ title: Ranks and Permissions
<th>N</th>
<th>Y</th>
<th>N</th>
<th>Y</th>
<th>N</th>
</tr>
</thead>
<tbody>
@@ -47,6 +51,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -62,6 +68,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -77,6 +85,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -92,6 +102,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -107,8 +119,10 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- editor -->
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
@@ -122,6 +136,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -137,6 +153,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -152,6 +170,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -167,6 +187,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -182,6 +204,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -197,6 +221,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -212,6 +238,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -227,6 +255,8 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -236,12 +266,14 @@ title: Ranks and Permissions
</tr>
<tr>
<td>Create Token</td>
<td></td> <!-- new -->
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
@@ -257,10 +289,12 @@ title: Ranks and Permissions
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<th><sup>3</sup></th> <!-- moderator -->
<th><sup>2</sup><sup>3</sup></th>
<th><sup>2</sup></th> <!-- moderator -->
<th><sup>1</sup><sup>2</sup></th>
<td></td> <!-- admin -->
<td></td>
</tr>
@@ -268,5 +302,5 @@ title: Ranks and Permissions
</table>
2. Target user cannot be an admin.
3. Cannot set user to a higher rank than themselves.
1. Target user cannot be an admin.
2 Cannot set user to a higher rank than themselves.

View File

@@ -6,7 +6,7 @@ A webhook is a notification from one service to another. Put simply, a webhook
is used to notify ContentDB that the git repository has changed.
ContentDB offers the ability to automatically create releases using webhooks
from either Github or Gitlab. If you're not using either of those services,
from either GitHub or GitLab. If you're not using either of those services,
you can also use the [API](../api) to create releases.
ContentDB also offers the ability to poll a Git repo and check for updates
@@ -20,6 +20,11 @@ The process is as follows:
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
4. ContentDB checks the API token and issues a new release.
<p class="alert alert-warning">
"New commit" or "push" based webhooks will currently only work on branches named `master` or
`main`.
</p>
## Setting up
### GitHub
@@ -49,9 +54,10 @@ The process is as follows:
choose "Tag push events".
8. Add webhook.
## Configuring
## Configuring Release Creation
See the [Package Configuration and Releases Guide](/help/package_config/) for
documentation on configuring the release creation.
You can set the min/max Minetest version from the Git repository, and also
configure what files are included.
From the Git repository, you can set the min/max Minetest versions, which files are included,
and update the package meta.

View File

@@ -1,8 +0,0 @@
title: Reporting Content
Please let us know if anything on the ContentDB violates our rules or any applicable
laws.
We take copyright violation and other offenses very seriously.
<a href="https://rubenwardy.com/contact/" class="btn btn-success">Contact</a>

View File

@@ -6,17 +6,17 @@ toc: False
Please reconsider the choice of WTFPL as a license.
<script src="/static/jquery.min.js"></script>
<script>
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
var params = new URLSearchParams(location.search);
var r = params.get("r");
if (r)
var r = params.get("r");
if (r) {
document.write("<a class='alert_right button' href='" + r + "'>Okay</a>");
else
$("#warning").hide();
} else {
document.getElementById("warning").style.display = "none";
}
</script>
</div>

View File

@@ -11,6 +11,8 @@ the listings and to combat abuse.
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
* **Don't manipulate package placement using reviews or downloads.** <sup>6</sup>
* **Screenshots must not be misleading.** <sup>7</sup>
* **The ContentDB admin reserves the right to remove packages for any reason**,
including ones not covered by this document, and to ban users who abuse
this service. <sup>1</sup>
@@ -27,15 +29,11 @@ including ones not covered by this document, and to ban users who abuse this ser
### 2.1. Acceptable Content
Sexually-orientated content is not permitted.
If in doubt at what this means, [contact us by raising a report](/help/reporting/).
If in doubt at what this means, [contact us by raising a report](/report/).
Mature content is permitted providing that it is labelled correctly.
See [Content Flags](/help/content_flags/).
The submission of malware is strictly prohibited. This includes software that
does not do as it advertises, for example, if it posts telemetry without stating
clearly that it does in the package meta.
### 2.2. State of Completion
ContentDB should only currently contain playable content - content which is
@@ -46,6 +44,9 @@ but still has value. Note that this doesn't mean that you should add a thing
you started working on yesterday, it's worth adding all the basic stuff to
make your package useful.
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
as this will help advise players.
Adding non-player facing mods, such as libraries and server tools, is perfectly fine
and encouraged. ContentDB isn't just for player-facing things, and adding
libraries allows them to be installed when a mod depends on it.
@@ -86,7 +87,8 @@ reimplementation of the mod that owns the name.
### 4.1. Allowed Licenses
Please ensure that you correctly credit any resources (code, assets, or otherwise)
that you have used in your package.
that you have used in your package. For help on doing copyright correctly, see
the [Copyright help page](/help/copyright/).
**The use of licenses that do not allow derivatives or redistribution is not
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
@@ -96,7 +98,8 @@ of the content on servers or singleplayer is also not permitted.**
However, closed sourced licenses are allowed if they allow the above.
If the license you use is not on the list then please select "Other", and we'll
get around to adding it.
get around to adding it. We tend to reject custom/untested licenses, and
reserve the right to decide whether a license should be included.
Please note that the definitions of "free" and "non-free" is the same as that
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
@@ -124,7 +127,7 @@ Public domain is not a valid license in many countries, please use CC0 or MIT in
## 5. Promotions and Advertisements (inc. asking for donations)
You may not place any promotions or advertisements in any meta data including
You may not place any promotions or advertisements in any metadata including
screenshots. This includes asking for donations, promoting online shops,
or linking to personal websites and social media. Please instead use the
fields provided on your user profile page to place links to websites and
@@ -134,6 +137,66 @@ ContentDB is for the community. We may remove any promotions if we feel that
they're inappropriate.
## 6. Reporting Violations
## 6. Reviews and Package Score
See the [Reporting Content](/help/reporting/) page.
You may invite players to review your package(s). One way to do this is by sharing the link found in the
"Share and Badges" page of the package's settings.
You must not require anyone to review a package. You must not promise or provide incentives for reviewing a package,
including but not limited to monetary rewards, in-game items, features, and/or privileges.
You may give a cosmetic-only role or badge to those who review your package - this must not be tied to the content or
rating of the review.
You must not attempt to unfairly manipulate your package's ranking, whether by reviews or any other method.
Doing so may result in temporary or permanent suspension from ContentDB.
## 7. Screenshots
1. **Screenshots must not violate copyright.** You should have the rights to the
screenshot.
2. **Screenshots must depict the actual content of the package in some way, and
not be misleading.**
Do not use idealized mockups or blender concept renders if they do not
accurately reflect in-game appearance.
Content in screenshots that is prominently displayed or "focal" should be
either present in, or interact with, the package in some way. These can
include things in other packages if they have a dependency relationship
(either way), or if the submitted package in some way enhances, extends, or
alters that content.
Unrelated package content can be allowed to show what the package content
will look like in a typical/realistic game scene, but should be "in the
background" only as far as possible.
3. **Screenshots must only contain content appropriate for the Content Warnings of
the package.**
4. **Screenshots should be MOSTLY in-game screenshots, if applicable.** Some
alterations on in-game screenshots are okay, such as collages, added text,
some reasonable compositing.
Don't just use one of the textures from the package; show it in-situ as it
actually looks in the game.
5. **Packages should have a screenshot when reasonably applicable.**
6. **Screenshots should be of reasonable dimensions.** We recommend using 1920x1080.
## 8. Security
The submission of malware is strictly prohibited. This includes software that
does not do as it advertises, for example, if it posts telemetry without stating
clearly that it does in the package meta.
Packages must not ask that users disable mod security (`secure.enable_security`).
Instead, they should use the insecure environment API.
## 9. Reporting Violations
Please click "Report" on the package page.

View File

@@ -1,5 +1,8 @@
title: Privacy Policy
Last Updated: 2022-01-23
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
## What Information is Collected
**All users:**
@@ -9,13 +12,14 @@ title: Privacy Policy
* IP address
* Page URL
* Response status code
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
**With an account:**
* Email address
* Passwords (hashed and salted using BCrypt)
* Profile information, such as website URLs and donation URLs
* Comments and threads
* Comments, threads, and reviews
* Audit log actions (such as edits and logins) and their time stamps
ContentDB collects usernames of content creators from the forums,
@@ -30,10 +34,12 @@ Please avoid giving other personal information as we do not want it.
* Logged HTTP requests may be used for debugging ContentDB.
* Email addresses are used to:
* Provide essential system messages, such as password resets.
* Provide essential system messages, such as password resets and privacy policy updates.
* Send notifications - the user may configure this to their needs, including opting out.
* The admin may use ContentDB to send emails when they need to contact a user.
* Passwords are used to authenticate the user.
* The audit log is used to record actions that may be harmful
* The audit log is used to record actions that may be harmful.
* Preferred language/locale is used to translate emails and the ContentDB interface.
* Other information is displayed as part of ContentDB's service.
## Who has access
@@ -43,7 +49,7 @@ Please avoid giving other personal information as we do not want it.
* Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
The keys and the backups themselves are given to different people,
requiring at least two staff members to read a backup.
* Emails are visible to moderators and the admin.
* Email addresses are visible to moderators and the admin.
They have access to assist users, and they are not permitted to share email addresses.
* Hashing protects passwords from being read whilst stored in the database or in backups.
* Profile information is public, including URLs and linked accounts.
@@ -52,11 +58,12 @@ Please avoid giving other personal information as we do not want it.
* The complete audit log is visible to moderators.
Users may see their own audit log actions on their account settings page.
Owners, maintainers, and editors may be able to see the actions on a package in the future.
* Preferred language can only be viewed by this with access to the database or a backup.
* We may be required to share information with law enforcement.
## Location
The ContentDB production server is currently located in Canada.
The ContentDB production server is currently located in Germany.
Backups are stored in the UK.
Encrypted backups may be stored in other countries, such as the US or EU.
@@ -72,7 +79,7 @@ requested. See below.
## Removal Requests
Please [raise a report](https://content.minetest.net/help/reporting/) if you
Please [raise a report](/report/?anon=0) if you
wish to remove your personal information.
ContentDB keeps a record of each username and forum topic on the forums,

15
app/flatpages/rules.md Normal file
View File

@@ -0,0 +1,15 @@
title: Rules
The following are the rules for user behaviour on ContentDB, including reviews,
threads, comments, and profiles. For packages, see the
[Package Inclusion Policy](/policy_and_guidance/).
1. **Be respectful:** attacks towards any person or group, slurs,
trolling/baiting, and other toxic behavior are not welcome.
2. **Assume good faith:** communication over the Internet is hard, try to assume
good faith when eg: responding to reviews.
3. **No sexual content** and ensure you keep discussion appropriate given the
package's [content warnings](/help/content_flags/).
You can report things by clicking [report](/report/) in the footer of pages you
want to report.

177
app/logic/game_support.py Normal file
View File

@@ -0,0 +1,177 @@
# ContentDB
# Copyright (C) 2022 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys
from typing import List, Dict
import sqlalchemy.orm
from app.logic.LogicError import LogicError
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport
"""
get_game_support(package):
if package is a game:
return [ package ]
for all hard dependencies:
support = support AND get_meta_package_support(dep)
return support
get_meta_package_support(meta):
for package implementing mod name:
support = support OR get_game_support(package)
return support
"""
minetest_game_mods = {
"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
}
mtg_mod_blacklist = {
"pacman", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
"survivethedays", "holidayhorrors",
}
class GameSupportResolver:
session: sqlalchemy.orm.Session
checked_packages = set()
checked_modnames = set()
resolved_packages: Dict[int, set[int]] = {}
resolved_modnames: Dict[int, set[int]] = {}
def __init__(self, session):
self.session = session
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> set[int]:
print(f"Resolving for {meta.name}", file=sys.stderr)
key = meta.name
if key in self.resolved_modnames:
return self.resolved_modnames.get(key)
if key in self.checked_modnames:
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
return set()
self.checked_modnames.add(key)
retval = set()
for package in meta.packages:
if package.state != PackageState.APPROVED:
continue
if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
continue
ret = self.resolve(package, history)
if len(ret) == 0:
retval = set()
break
retval.update(ret)
self.resolved_modnames[key] = retval
return retval
def resolve(self, package: Package, history: List[str]) -> set[int]:
key: int = package.id
print(f"Resolving for {key}", file=sys.stderr)
history = history.copy()
history.append(package.get_id())
if package.type == PackageType.GAME:
return {package.id}
if key in self.resolved_packages:
return self.resolved_packages.get(key)
if key in self.checked_packages:
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
return set()
self.checked_packages.add(key)
if package.type != PackageType.MOD:
raise LogicError(500, "Got non-mod")
retval = set()
for dep in package.dependencies.filter_by(optional=False).all():
ret = self.resolve_for_meta_package(dep.meta_package, history)
if len(ret) == 0:
continue
elif len(retval) == 0:
retval.update(ret)
else:
retval.intersection_update(ret)
if len(retval) == 0:
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
self.resolved_packages[key] = retval
return retval
def init_all(self) -> None:
for package in self.session.query(Package).filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
retval = self.resolve(package, [])
for game_id in retval:
game = self.session.query(Package).get(game_id)
support = PackageGameSupport(package, game, 1, True)
self.session.add(support)
"""
Update game supported package on a package, given the confidence.
Higher confidences outweigh lower ones.
"""
def set_supported(self, package: Package, game_is_supported: Dict[int, bool], confidence: int):
previous_supported: Dict[int, PackageGameSupport] = {}
for support in package.supported_games.all():
previous_supported[support.game.id] = support
for game_id, supports in game_is_supported.items():
game = self.session.query(Package).get(game_id)
lookup = previous_supported.pop(game_id, None)
if lookup is None:
support = PackageGameSupport(package, game, confidence, supports)
self.session.add(support)
elif lookup.confidence <= confidence:
lookup.supports = supports
lookup.confidence = confidence
for game, support in previous_supported.items():
if support.confidence == confidence:
self.session.delete(support)
def update(self, package: Package) -> None:
game_is_supported = {}
if package.enable_game_support_detection:
retval = self.resolve(package, [])
for game_id in retval:
game_is_supported[game_id] = True
self.set_supported(package, game_is_supported, 1)

166
app/logic/graphs.py Normal file
View File

@@ -0,0 +1,166 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from datetime import timedelta
from typing import Optional
from app.models import User, Package, PackageDailyStats, db, PackageState
from sqlalchemy import func
def daterange(start_date, end_date):
for n in range(int((end_date - start_date).days) + 1):
yield start_date + timedelta(n)
keys = ["platform_minetest", "platform_other", "reason_new",
"reason_dependency", "reason_update"]
def flatten_data(stats):
start_date = stats[0].date
end_date = stats[-1].date
result = {
"start": start_date.isoformat(),
"end": end_date.isoformat(),
}
for key in keys:
result[key] = []
i = 0
for date in daterange(start_date, end_date):
stat = stats[i]
if stat.date == date:
for key in keys:
result[key].append(getattr(stat, key))
i += 1
else:
for key in keys:
result[key].append(0)
return result
def get_package_stats(package: Package, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
query = package.daily_stats.order_by(db.asc(PackageDailyStats.date))
if start_date:
query = query.filter(PackageDailyStats.date >= start_date)
if end_date:
query = query.filter(PackageDailyStats.date <= end_date)
stats = query.all()
if len(stats) == 0:
return None
return flatten_data(stats)
def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
query = db.session \
.query(PackageDailyStats.date,
func.sum(PackageDailyStats.platform_minetest).label("platform_minetest"),
func.sum(PackageDailyStats.platform_other).label("platform_other"),
func.sum(PackageDailyStats.reason_new).label("reason_new"),
func.sum(PackageDailyStats.reason_dependency).label("reason_dependency"),
func.sum(PackageDailyStats.reason_update).label("reason_update")) \
.filter(PackageDailyStats.package.has(author_id=user.id))
if start_date:
query = query.filter(PackageDailyStats.date >= start_date)
if end_date:
query = query.filter(PackageDailyStats.date <= end_date)
stats = query.order_by(db.asc(PackageDailyStats.date)) \
.group_by(PackageDailyStats.date) \
.all()
if len(stats) == 0:
return None
results = flatten_data(stats)
results["package_downloads"] = get_package_overview_for_user(user, stats[0].date, stats[-1].date)
return results
def get_package_overview_for_user(user: Optional[User], start_date: datetime.date, end_date: datetime.date):
query = db.session \
.query(PackageDailyStats.package_id, PackageDailyStats.date,
(PackageDailyStats.platform_minetest + PackageDailyStats.platform_other).label("downloads"))
if user:
query = query.filter(PackageDailyStats.package.has(author_id=user.id))
all_stats = query \
.filter(PackageDailyStats.package.has(state=PackageState.APPROVED),
PackageDailyStats.date >= start_date, PackageDailyStats.date <= end_date) \
.order_by(db.asc(PackageDailyStats.package_id), db.asc(PackageDailyStats.date)) \
.all()
stats_by_package = {}
for stat in all_stats:
bucket = stats_by_package.get(stat.package_id, [])
stats_by_package[stat.package_id] = bucket
bucket.append(stat)
package_title_by_id = {}
pkg_query = user.packages if user else Package.query
for package in pkg_query.filter_by(state=PackageState.APPROVED).all():
if user:
package_title_by_id[package.id] = package.title
else:
package_title_by_id[package.id] = package.get_id()
result = {}
for package_id, stats in stats_by_package.items():
i = 0
row = []
result[package_title_by_id[package_id]] = row
for date in daterange(start_date, end_date):
if i >= len(stats):
row.append(0)
continue
stat = stats[i]
if stat.date == date:
row.append(stat.downloads)
i += 1
elif stat.date > date:
row.append(0)
else:
raise Exception(f"Invalid logic, expected stat {stat.date} to be later than {date}")
return result
def get_all_package_stats(start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None):
now_date = datetime.datetime.utcnow().date()
if end_date is None or end_date > now_date:
end_date = now_date
min_start_date = (datetime.datetime.utcnow() - datetime.timedelta(days=29)).date()
if start_date is None or start_date < min_start_date:
start_date = min_start_date
return {
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"package_downloads": get_package_overview_for_user(None, start_date, end_date),
}

View File

@@ -0,0 +1,56 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import namedtuple
from typing import List
from flask_babel import lazy_gettext
from sqlalchemy import and_, or_
from app.models import Package, PackageType, PackageState, PackageRelease
ValidationError = namedtuple("ValidationError", "status message")
def validate_package_for_approval(package: Package) -> List[ValidationError]:
retval: List[ValidationError] = []
normalised_name = package.getNormalisedName()
if package.type != PackageType.MOD and Package.query.filter(
and_(Package.state == PackageState.APPROVED,
or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0:
retval.append(("danger", lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3")))
if package.releases.filter(PackageRelease.task_id == None).count() == 0:
retval.append(("danger", lazy_gettext("A release is required before this package can be approved.")))
# Don't bother validating any more until we have a release
return retval
missing_deps = package.get_missing_hard_dependencies_query().all()
if len(missing_deps) > 0:
retval.append(("danger", lazy_gettext(
"The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=missing_deps)))
if (package.type == package.type.GAME or package.type == package.type.TXP) and \
package.screenshots.count() == 0:
retval.append(("danger", lazy_gettext("You need to add at least one screenshot.")))
if "Other" in package.license.name or "Other" in package.media_license.name:
retval.append(("info", lazy_gettext("Please wait for the license to be added to CDB.")))
return retval

View File

@@ -14,16 +14,21 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import re
import typing
import validators
from flask_babel import lazy_gettext, LazyString
from app.logic.LogicError import LogicError
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, License
from app.utils import addAuditLog
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
License, UserRank, PackageDevState
from app.utils import add_audit_log, has_blocked_domains, diff_dictionaries, describe_difference
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: str):
def check(cond: bool, msg: typing.Union[str, LazyString]):
if not cond:
raise LogicError(400, msg)
@@ -34,23 +39,24 @@ def get_license(name):
license = License.query.filter(License.name.ilike(name)).first()
if license is None:
raise LogicError(400, "Unknown license: " + name)
raise LogicError(400, "Unknown license " + name)
return license
name_re = re.compile("^[a-z0-9_]+$")
any = "?"
AnyType = "?"
ALLOWED_FIELDS = {
"type": any,
"type": AnyType,
"title": str,
"name": str,
"short_description": str,
"short_desc": str,
"dev_state": AnyType,
"tags": list,
"content_warnings": list,
"license": any,
"media_license": any,
"license": AnyType,
"media_license": AnyType,
"long_description": str,
"desc": str,
"repo": str,
@@ -58,6 +64,8 @@ ALLOWED_FIELDS = {
"issue_tracker": str,
"issueTracker": str,
"forums": int,
"video_url": str,
"donate_url": str,
}
ALIASES = {
@@ -80,14 +88,14 @@ def validate(data: dict):
if value is not None:
typ = ALLOWED_FIELDS.get(key)
check(typ is not None, key + " is not a known field")
if typ != any:
if typ != AnyType:
check(isinstance(value, typ), key + " must be a " + typ.__name__)
if "name" in data:
name = data["name"]
check(isinstance(name, str), "Name must be a string")
check(bool(name_re.match(name)),
"Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)")
lazy_gettext("Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)"))
for key in ["repo", "website", "issue_tracker", "issueTracker"]:
value = data.get(key)
@@ -98,13 +106,18 @@ def validate(data: dict):
check(validators.url(value, public=True), key + " must be a valid URL")
def do_edit_package(user: User, package: Package, was_new: bool, data: dict, reason: str = None):
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, "You do not have permission to edit this package")
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
reason: str = None):
if not package.check_perm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, lazy_gettext("You don't have permission to edit this package"))
if "name" in data and package.name != data["name"] and \
not package.checkPerm(user, Permission.CHANGE_NAME):
raise LogicError(403, "You do not have permission to change the package name")
not package.check_perm(user, Permission.CHANGE_NAME):
raise LogicError(403, lazy_gettext("You don't have permission to change the package name"))
before_dict = None
if not was_new:
before_dict = package.as_dict("/")
for alias, to in ALIASES.items():
if alias in data:
@@ -112,17 +125,30 @@ def do_edit_package(user: User, package: Package, was_new: bool, data: dict, rea
validate(data)
for field in ["short_desc", "desc", "website", "issueTracker", "repo", "video_url", "donate_url"]:
if field in data and has_blocked_domains(data[field], user.username,
f"{field} of {package.get_id()}"):
raise LogicError(403, lazy_gettext("Linking to blocked sites is not allowed"))
if "type" in data:
data["type"] = PackageType.coerce(data["type"])
if "dev_state" in data:
data["dev_state"] = PackageDevState.coerce(data["dev_state"])
if "license" in data:
data["license"] = get_license(data["license"])
if "media_license" in data:
data["media_license"] = get_license(data["media_license"])
for key in ["name", "title", "short_desc", "desc", "type", "license", "media_license",
"repo", "website", "issueTracker", "forums"]:
if "video_url" in data and data["video_url"] is not None:
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
if "dQw4w9WgXcQ" in data["video_url"]:
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums", "video_url", "donate_url"]:
if key in data:
setattr(package, key, data[key])
@@ -134,19 +160,21 @@ def do_edit_package(user: User, package: Package, was_new: bool, data: dict, rea
package.provides.append(m)
if "tags" in data:
old_tags = list(package.tags)
package.tags.clear()
for tag_id in data["tags"]:
for tag_id in (data["tags"] or []):
if is_int(tag_id):
package.tags.append(Tag.query.get(tag_id))
tag = Tag.query.get(tag_id)
else:
tag = Tag.query.filter_by(name=tag_id).first()
if tag is None:
raise LogicError(400, "Unknown tag: " + tag_id)
package.tags.append(tag)
package.tags.append(tag)
if "content_warnings" in data:
package.content_warnings.clear()
for warning_id in data["content_warnings"]:
for warning_id in (data["content_warnings"] or []):
if is_int(warning_id):
package.content_warnings.append(ContentWarning.query.get(warning_id))
else:
@@ -156,13 +184,20 @@ def do_edit_package(user: User, package: Package, was_new: bool, data: dict, rea
package.content_warnings.append(warning)
if not was_new:
after_dict = package.as_dict("/")
diff = diff_dictionaries(before_dict, after_dict)
if reason is None:
msg = "Edited {}".format(package.title)
else:
msg = "Edited {} ({})".format(package.title, reason)
diff_desc = describe_difference(diff, 100 - len(msg) - 3) if diff else None
if diff_desc:
msg += " [" + diff_desc + "]"
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, user, msg, package.getDetailsURL(), package)
add_audit_log(severity, user, msg, package.get_url("packages.view"), package, json.dumps(diff, indent=4))
db.session.commit()

View File

@@ -14,26 +14,27 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import re
from celery import uuid
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file
from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease
from app.tasks.importtasks import makeVCSRelease, checkZipRelease
from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
from app.tasks.importtasks import make_vcs_release, check_zip_release
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none
def check_can_create_release(user: User, package: Package):
if not package.checkPerm(user, Permission.MAKE_RELEASE):
raise LogicError(403, "You do not have permission to make releases")
if not package.check_perm(user, Permission.MAKE_RELEASE):
raise LogicError(403, lazy_gettext("You don't have permission to make releases"))
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
if count >= 2:
raise LogicError(429, "Too many requests, please wait before trying again")
if count >= 5:
raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again"))
def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
@@ -53,19 +54,25 @@ def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
msg = "Created release {}".format(rel.title)
else:
msg = "Created release {} ({})".format(rel.title, reason)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
db.session.commit()
makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id)
make_vcs_release.apply_async((rel.id, nonempty_or_none(ref)), task_id=rel.task_id)
return rel
def do_create_zip_release(user: User, package: Package, title: str, file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None,
commit_hash: str = None):
check_can_create_release(user, package)
if commit_hash:
commit_hash = commit_hash.lower()
if not (len(commit_hash) == 40 and re.match(r"^[0-9a-f]+$", commit_hash)):
raise LogicError(400, lazy_gettext("Invalid commit hash; it must be a 40 character long base16 string"))
uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file")
rel = PackageRelease()
@@ -73,6 +80,7 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
rel.title = title
rel.url = uploaded_url
rel.task_id = uuid()
rel.commit_hash = commit_hash
rel.min_rel = min_v
rel.max_rel = max_v
db.session.add(rel)
@@ -81,10 +89,10 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
msg = "Created release {}".format(rel.title)
else:
msg = "Created release {} ({})".format(rel.title, reason)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
db.session.commit()
checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
check_zip_release.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
return rel

6
app/logic/scope.py Normal file
View File

@@ -0,0 +1,6 @@
from app.models import APIToken
class Scope:
def copy_to_token(self, token: APIToken):
pass

View File

@@ -1,18 +1,37 @@
import datetime
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime, json
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
from app.utils import addNotification, addAuditLog
from app.utils import add_notification, add_audit_log
from app.utils.image import get_image_size
def do_create_screenshot(user: User, package: Package, title: str, file, reason: str = None):
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
if count >= 20:
raise LogicError(429, "Too many requests, please wait before trying again")
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
uploaded_url, uploaded_path = upload_file(file, "image", "a PNG or JPG image file")
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file"))
counter = 1
for screenshot in package.screenshots.all():
@@ -23,8 +42,15 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
ss.package = package
ss.title = title or "Untitled"
ss.url = uploaded_url
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
ss.approved = package.check_perm(user, Permission.APPROVE_SCREENSHOT)
ss.order = counter
ss.width, ss.height = get_image_size(uploaded_path)
if ss.is_too_small():
raise LogicError(429,
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
db.session.add(ss)
if reason is None:
@@ -32,11 +58,15 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
else:
msg = "Created screenshot {} ({})".format(ss.title, reason)
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getDetailsURL(), package)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
add_notification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.get_url("packages.view"), package)
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
db.session.commit()
if is_cover_image:
package.cover_image = ss
db.session.commit()
return ss
@@ -46,13 +76,28 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
lookup[screenshot.id] = screenshot
counter = 1
for id in order:
for ss_id in order:
try:
lookup[int(id)].order = counter
lookup[int(ss_id)].order = counter
counter += 1
except KeyError as e:
raise LogicError(400, "Unable to find screenshot with id={}".format(id))
except ValueError as e:
raise LogicError(400, "Invalid number: {}".format(id))
except KeyError:
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
except (ValueError, TypeError):
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
db.session.commit()
def do_set_cover_image(_user: User, package: Package, cover_image):
try:
cover_image = int(cover_image)
except (ValueError, TypeError):
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
for screenshot in package.screenshots.all():
if screenshot.id == cover_image:
package.cover_image = screenshot
db.session.commit()
return
raise LogicError(400, "Unable to find screenshot")

View File

@@ -14,47 +14,52 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import imghdr
import os
from flask_babel import lazy_gettext
from app import app
from app.logic.LogicError import LogicError
from app.models import *
from app.utils import randomString
from app.utils import random_string
def get_extension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
ALLOWED_IMAGES = {"jpeg", "png"}
def isAllowedImage(data):
def is_allowed_image(data):
return imghdr.what(None, data) in ALLOWED_IMAGES
def upload_file(file, fileType, fileTypeDesc):
def upload_file(file, file_type, file_type_desc):
if not file or file is None or file.filename == "":
raise LogicError(400, "Expected file")
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
isImage = False
if fileType == "image":
allowedExtensions = ["jpg", "jpeg", "png"]
isImage = True
elif fileType == "zip":
allowedExtensions = ["zip"]
is_image = False
if file_type == "image":
allowed_extensions = ["jpg", "jpeg", "png"]
is_image = True
elif file_type == "zip":
allowed_extensions = ["zip"]
else:
raise Exception("Invalid fileType")
ext = get_extension(file.filename)
if ext is None or not ext in allowedExtensions:
raise LogicError(400, "Please upload " + fileTypeDesc)
if ext is None or ext not in allowed_extensions:
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=file_type_desc))
if isImage and not isAllowedImage(file.stream.read()):
raise LogicError(400, "Uploaded image isn't actually an image")
if is_image and not is_allowed_image(file.stream.read()):
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
file.stream.seek(0)
filename = randomString(10) + "." + ext
filename = random_string(10) + "." + ext
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
file.save(filepath)

View File

@@ -1,3 +1,19 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
from app.tasks.emails import send_user_email
@@ -9,6 +25,7 @@ def _has_newline(line):
return True
return False
def _is_bad_subject(subject):
"""Copied from: flask_mail.py class Message def has_bad_headers"""
if _has_newline(subject):
@@ -32,9 +49,11 @@ class FlaskMailSubjectFormatter(logging.Formatter):
s = self.formatMessage(record)
return s
class FlaskMailTextFormatter(logging.Formatter):
pass
class FlaskMailHTMLFormatter(logging.Formatter):
def formatException(self, exc_info):
formatted_exception = logging.Handler.formatException(self, exc_info)
@@ -64,16 +83,21 @@ class FlaskMailHandler(logging.Handler):
def getSubject(self, record):
fmt = FlaskMailSubjectFormatter(self.subject_template)
subject = fmt.format(record)
# Since templates can cause header problems, and we rather have a incomplete email then an error, we fix this
# Since templates can cause header problems, and we rather have an incomplete email then an error, we fix this
if _is_bad_subject(subject):
subject="FlaskMailHandler log-entry from ContentDB [original subject is replaced, because it would result in a bad header]"
return subject
def emit(self, record):
subject = self.getSubject(record)
text = self.format(record) if self.formatter else None
html = "<pre>{}</pre>".format(text)
if "The recipient has exceeded message rate limit. Try again later" in subject:
return
for email in self.send_to:
send_user_email.delay(email, self.getSubject(record), text, html)
send_user_email.delay(email, "en", subject, text, html)
def build_handler(app):

204
app/markdown.py Normal file
View File

@@ -0,0 +1,204 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from functools import partial
import bleach
from bleach import Cleaner
from bleach.linkifier import LinkifyFilter
from bs4 import BeautifulSoup
from markdown import Markdown
from flask import url_for
from jinja2.utils import markupsafe
from markdown.extensions import Extension
from markdown.inlinepatterns import SimpleTagInlineProcessor
from markdown.inlinepatterns import Pattern
from markdown.extensions.codehilite import CodeHiliteExtension
from xml.etree import ElementTree
# Based on
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
#
# License: MIT
ALLOWED_TAGS = {
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
"ul", "ol", "li",
"p",
"br",
"pre",
"code",
"blockquote",
"strong",
"em",
"a",
"img",
"table", "thead", "tbody", "tr", "th", "td",
"div", "span", "del", "s",
}
ALLOWED_CSS = [
"highlight", "codehilite",
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
]
def allow_class(_tag, name, value):
return name == "class" and value in ALLOWED_CSS
ALLOWED_ATTRIBUTES = {
"h1": ["id"],
"h2": ["id"],
"h3": ["id"],
"h4": ["id"],
"a": ["href", "title", "data-username"],
"img": ["src", "title", "alt"],
"code": allow_class,
"div": allow_class,
"span": allow_class,
}
ALLOWED_PROTOCOLS = {"http", "https", "mailto"}
md = None
def linker_callback(attrs, new=False):
if new:
text = attrs.get("_text")
if not (text.startswith("http://") or text.startswith("https://")):
return None
return attrs
def render_markdown(source):
html = md.convert(source)
cleaner = Cleaner(
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS,
filters=[partial(LinkifyFilter,
callbacks=[linker_callback] + bleach.linkifier.DEFAULT_CALLBACKS,
skip_tags={"pre", "code"})])
return cleaner.clean(html)
class DelInsExtension(Extension):
def extendMarkdown(self, md):
del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del")
md.inlinePatterns.register(del_proc, "del", 200)
ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins")
md.inlinePatterns.register(ins_proc, "ins", 200)
RE_PARTS = dict(
USER=r"[A-Za-z0-9._-]*\b",
REPO=r"[A-Za-z0-9_]+\b"
)
class MentionPattern(Pattern):
ANCESTOR_EXCLUDES = ("a",)
def __init__(self, config, md):
MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS)
super(MentionPattern, self).__init__(MENTION_RE, md)
self.config = config
def handleMatch(self, m):
from app.models import User
label = m.group(2)
user = m.group(3)
package_name = m.group(4)
if package_name:
el = ElementTree.Element("a")
el.text = label
el.set("href", url_for("packages.view", author=user, name=package_name))
return el
else:
if User.query.filter_by(username=user).count() == 0:
return None
el = ElementTree.Element("a")
el.text = label
el.set("href", url_for("users.profile", username=user))
el.set("data-username", user)
return el
class MentionExtension(Extension):
def __init__(self, *args, **kwargs):
super(MentionExtension, self).__init__(*args, **kwargs)
def extendMarkdown(self, md):
md.ESCAPED_CHARS.append("@")
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", CodeHiliteExtension(guess_lang=False), "toc", DelInsExtension(), MentionExtension()]
MARKDOWN_EXTENSION_CONFIG = {
"fenced_code": {},
"tables": {}
}
def init_markdown(app):
global md
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
extension_configs=MARKDOWN_EXTENSION_CONFIG,
output_format="html")
@app.template_filter()
def markdown(source):
return markupsafe.Markup(render_markdown(source))
def get_headings(html: str):
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all(["h1", "h2", "h3"])
root = []
stack = []
for heading in headings:
this = {"link": heading.get("id") or "", "text": heading.text, "children": []}
this_level = int(heading.name[1:]) - 1
while this_level <= len(stack):
stack.pop()
if len(stack) > 0:
stack[-1]["children"].append(this)
else:
root.append(this)
stack.append(this)
return root
def get_user_mentions(html: str) -> set:
soup = BeautifulSoup(html, "html.parser")
links = soup.select("a[data-username]")
return set([x.get("data-username") for x in links])

View File

@@ -31,6 +31,7 @@ make_searchable(db.metadata)
from .packages import *
from .users import *
from .threads import *
from .collections import *
class APIToken(db.Model):
@@ -47,7 +48,44 @@ class APIToken(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
def canOperateOnPackage(self, package):
client_id = db.Column(db.String(24), db.ForeignKey("oauth_client.id"), nullable=True)
client = db.relationship("OAuthClient", foreign_keys=[client_id], back_populates="tokens")
auth_code = db.Column(db.String(34), unique=True, nullable=True)
scope_user_email = db.Column(db.Boolean, nullable=False, default=False)
scope_package = db.Column(db.Boolean, nullable=False, default=False)
scope_package_release = db.Column(db.Boolean, nullable=False, default=False)
scope_package_screenshot = db.Column(db.Boolean, nullable=False, default=False)
def get_scopes(self) -> set[str]:
ret = set()
if self.scope_user_email:
ret.add("user:email")
if self.scope_package:
ret.add("package")
if self.scope_package_release:
ret.add("package:release")
if self.scope_package_screenshot:
ret.add("package:screenshot")
return ret
def set_scopes(self, v: set[str]):
def pop(key: str):
if key in v:
v.remove(key)
return True
self.scope_user_email = pop("user:email")
self.scope_package = pop("package")
self.scope_package_release = pop("package:release") or self.scope_package
self.scope_package_screenshot = pop("package:screenshot") or self.scope_package
return v
def can_operate_on_package(self, package):
if (self.client is not None and
not (self.scope_package or self.scope_package_release or self.scope_package_screenshot)):
return False
if self.package and self.package != package:
return False
@@ -63,16 +101,16 @@ class AuditSeverity(enum.Enum):
def __str__(self):
return self.name
def getTitle(self):
def get_title(self):
return self.name.replace("_", " ").title()
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
return [(choice, choice.get_title()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == AuditSeverity else AuditSeverity[item]
return item if type(item) == AuditSeverity else AuditSeverity[item.upper()]
class AuditLogEntry(db.Model):
@@ -95,6 +133,8 @@ class AuditLogEntry(db.Model):
def __init__(self, causer, severity, title, url, package=None, description=None):
if len(title) > 100:
if description is None:
description = title[99:]
title = title[:99] + ""
self.causer = causer
@@ -104,6 +144,20 @@ class AuditLogEntry(db.Model):
self.package = package
self.description = description
def check_perm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to AuditLogEntry.check_perm()")
if perm == Permission.VIEW_AUDIT_DESCRIPTION:
return user.rank.at_least(UserRank.APPROVER if self.package is not None else UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to audit log entries".format(perm.name))
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
"minetest.net", "dropboxusercontent.com", "4shared.com",
@@ -115,10 +169,10 @@ class ForumTopic(db.Model):
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User")
author = db.relationship("User", back_populates="forum_topics")
wip = db.Column(db.Boolean, server_default="0")
discarded = db.Column(db.Boolean, server_default="0")
wip = db.Column(db.Boolean, default=False, nullable=False)
discarded = db.Column(db.Boolean, default=False, nullable=False)
type = db.Column(db.Enum(PackageType), nullable=False)
title = db.Column(db.String(200), nullable=False)
@@ -130,7 +184,7 @@ class ForumTopic(db.Model):
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def getRepoURL(self):
def get_repo_url(self):
if self.link is None:
return None
@@ -140,11 +194,11 @@ class ForumTopic(db.Model):
return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
def getAsDictionary(self):
def as_dict(self):
return {
"author": self.author.username,
"name": self.name,
"type": self.type.toName(),
"type": self.type.to_name(),
"title": self.title,
"id": self.topic_id,
"link": self.link,
@@ -155,17 +209,17 @@ class ForumTopic(db.Model):
"created_at": self.created_at.isoformat(),
}
def checkPerm(self, user, perm):
def check_perm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to ForumTopic.checkPerm()")
raise Exception("Unknown permission given to ForumTopic.check_perm()")
if perm == Permission.TOPIC_DISCARD:
return self.author == user or user.rank.atLeast(UserRank.EDITOR)
return self.author == user or user.rank.at_least(UserRank.EDITOR)
else:
raise Exception("Permission {} is not related to topics".format(perm.name))

106
app/models/collections.py Normal file
View File

@@ -0,0 +1,106 @@
# ContentDB
# Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import url_for, current_app
from . import db, Permission, User, UserRank
class CollectionPackage(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
package = db.relationship("Package", foreign_keys=[package_id])
collection_id = db.Column(db.Integer, db.ForeignKey("collection.id"), primary_key=True)
collection = db.relationship("Collection", back_populates="items", foreign_keys=[collection_id])
order = db.Column(db.Integer, nullable=False, default=0)
description = db.Column(db.String, nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
collection_description_nonempty = db.CheckConstraint("description = NULL OR description != ''")
def as_dict(self):
return {
"package": self.package.as_short_dict(current_app.config["BASE_URL"]),
"order": self.order,
"description": self.description,
"created_at": self.created_at.isoformat(),
}
class Collection(db.Model):
id = db.Column(db.Integer, primary_key=True)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="collections", foreign_keys=[author_id])
name = db.Column(db.Unicode(100), nullable=False)
title = db.Column(db.Unicode(100), nullable=False)
short_description = db.Column(db.Unicode(200), nullable=False)
long_description = db.Column(db.UnicodeText, nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
private = db.Column(db.Boolean, nullable=False, default=False)
packages = db.relationship("Package", secondary=CollectionPackage.__table__, backref="collections")
items = db.relationship("CollectionPackage", back_populates="collection", order_by=db.asc("order"),
cascade="all, delete, delete-orphan")
collection_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$' AND name != '_game'")
__table_args__ = (db.UniqueConstraint("author_id", "name", name="_collection_uc"),)
def get_url(self, endpoint, **kwargs):
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
def as_short_dict(self):
return {
"author": self.author.username,
"name": self.name,
"title": self.title,
"short_description": self.short_description,
"created_at": self.created_at.isoformat(),
"private": self.private,
"package_count": len(self.packages)
}
def as_dict(self):
return {
"author": self.author.username,
"name": self.name,
"title": self.title,
"short_description": self.short_description,
"long_description": self.long_description,
"created_at": self.created_at.isoformat(),
"private": self.private,
}
def check_perm(self, user: User, perm):
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Collection.check_perm()")
if not user.is_authenticated:
return perm == Permission.VIEW_COLLECTION and not self.private
can_view = not self.private or self.author == user or user.rank.at_least(UserRank.MODERATOR)
if perm == Permission.VIEW_COLLECTION:
return can_view
elif perm == Permission.EDIT_COLLECTION:
return can_view and (self.author == user or user.rank.at_least(UserRank.EDITOR))
else:
raise Exception("Permission {} is not related to collections".format(perm.name))

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from typing import Tuple, List
from flask import url_for
from . import db
from .users import Permission, UserRank
from .users import Permission, UserRank, User
from .packages import Package
watchers = db.Table("watchers",
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
@@ -55,42 +55,76 @@ class Thread(db.Model):
watchers = db.relationship("User", secondary=watchers, backref="watching")
def getViewURL(self):
return url_for("threads.view", id=self.id, _external=False)
first_reply = db.relationship("ThreadReply", uselist=False, foreign_keys="ThreadReply.thread_id",
lazy=True, order_by=db.asc("id"), viewonly=True,
primaryjoin="Thread.id==ThreadReply.thread_id")
def getSubscribeURL(self):
def get_description(self):
comment = self.first_reply.comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
if len(comment) > 100:
return comment[:97] + "..."
else:
return comment
def get_view_url(self, absolute=False):
if absolute:
from app.utils import abs_url_for
return abs_url_for("threads.view", id=self.id)
else:
return url_for("threads.view", id=self.id, _external=False)
def get_subscribe_url(self):
return url_for("threads.subscribe", id=self.id)
def getUnsubscribeURL(self):
def get_unsubscribe_url(self):
return url_for("threads.unsubscribe", id=self.id)
def checkPerm(self, user, perm):
if not user.is_authenticated:
return perm == Permission.SEE_THREAD and not self.private
def check_perm(self, user, perm):
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Thread.checkPerm()")
raise Exception("Unknown permission given to Thread.check_perm()")
if not user.is_authenticated:
return perm == Permission.SEE_THREAD and not self.private
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
if self.package:
isMaintainer = isMaintainer or user in self.package.maintainers
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.EDITOR)
canSee = not self.private or isMaintainer or user.rank.at_least(UserRank.APPROVER) or user in self.watchers
if perm == Permission.SEE_THREAD:
return canSee
elif perm == Permission.COMMENT_THREAD:
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
return canSee and (not self.locked or user.rank.at_least(UserRank.MODERATOR))
elif perm == Permission.LOCK_THREAD or perm == Permission.DELETE_THREAD:
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.LOCK_THREAD:
return user.rank.at_least(UserRank.MODERATOR)
elif perm == Permission.DELETE_THREAD:
from app.utils.models import get_system_user
return (self.author == get_system_user() and self.package and
user in self.package.maintainers) or user.rank.at_least(UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to threads".format(perm.name))
def get_visible_to(self) -> list[User]:
retval = {
self.author.username: self.author
}
for user in self.watchers:
retval[user.username] = user
if self.package:
for user in self.package.maintainers:
retval[user.username] = user
return list(retval.values())
def get_latest_reply(self):
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
@@ -106,22 +140,27 @@ class ThreadReply(db.Model):
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
is_status_update = db.Column(db.Boolean, server_default="0", nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def checkPerm(self, user, perm):
def get_url(self, absolute=False):
return self.thread.get_view_url(absolute) + "#reply-" + str(self.id)
def check_perm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
raise Exception("Unknown permission given to ThreadReply.check_perm()")
if perm == Permission.EDIT_REPLY:
return user == self.author and user.rank.atLeast(UserRank.MEMBER) and not self.thread.locked
return user.rank.at_least(UserRank.NEW_MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
elif perm == Permission.DELETE_REPLY:
return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
return user.rank.at_least(UserRank.MODERATOR) and self.thread.first_reply != self
else:
raise Exception("Permission {} is not related to threads".format(perm.name))
@@ -138,17 +177,94 @@ class PackageReview(db.Model):
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
recommends = db.Column(db.Boolean, nullable=False)
rating = db.Column(db.Integer, nullable=False)
thread = db.relationship("Thread", uselist=False, back_populates="review")
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
def asSign(self):
return 1 if self.recommends else -1
score = db.Column(db.Integer, nullable=False, default=1)
def getEditURL(self):
return self.package.getReviewURL()
def get_totals(self, current_user = None) -> Tuple[int,int,bool]:
votes: List[PackageReviewVote] = self.votes
pos = sum([ 1 for vote in votes if vote.is_positive ])
neg = sum([ 1 for vote in votes if not vote.is_positive])
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
return pos, neg, user_vote.is_positive if user_vote else None
def getDeleteURL(self):
def as_dict(self, include_package=False):
from app.utils import abs_url_for
pos, neg, _user = self.get_totals()
ret = {
"is_positive": self.rating > 3,
"rating": self.rating,
"user": {
"username": self.author.username,
"display_name": self.author.display_name,
},
"created_at": self.created_at.isoformat(),
"votes": {
"helpful": pos,
"unhelpful": neg,
},
"title": self.thread.title,
"comment": self.thread.first_reply.comment,
"thread": {
"id": self.thread.id,
"url": abs_url_for("threads.view", id=self.thread.id),
},
}
if include_package:
ret["package"] = self.package.as_key_dict()
return ret
def as_weight(self):
"""
From (1, 5) to (-1 to 1)
"""
return (self.rating - 3.0) / 2.0
def get_edit_url(self):
return self.package.get_url("packages.review")
def get_delete_url(self):
return url_for("packages.delete_review",
author=self.package.author.username,
name=self.package.name)
name=self.package.name,
reviewer=self.author.username)
def get_vote_url(self, next_url=None):
return url_for("packages.review_vote",
author=self.package.author.username,
name=self.package.name,
review_id=self.id,
r=next_url)
def update_score(self):
(pos, neg, _) = self.get_totals()
self.score = 3 * (pos - neg) + 1
def check_perm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to PackageReview.check_perm()")
if perm == Permission.DELETE_REVIEW:
return user == self.author or user.rank.at_least(UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to reviews".format(perm.name))
class PackageReviewVote(db.Model):
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
is_positive = db.Column(db.Boolean, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)

View File

@@ -14,10 +14,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import enum
from flask import url_for
from flask_login import UserMixin
from sqlalchemy import desc, text
@@ -31,18 +31,19 @@ class UserRank(enum.Enum):
NEW_MEMBER = 2
MEMBER = 3
TRUSTED_MEMBER = 4
EDITOR = 5
BOT = 6
MODERATOR = 7
ADMIN = 8
APPROVER = 5
EDITOR = 6
BOT = 7
MODERATOR = 8
ADMIN = 9
def atLeast(self, min):
def at_least(self, min):
return self.value >= min.value
def getTitle(self):
def get_title(self):
return self.name.replace("_", " ").title()
def toName(self):
def to_name(self):
return self.name.lower()
def __str__(self):
@@ -50,23 +51,22 @@ class UserRank(enum.Enum):
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
return [(choice, choice.get_title()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == UserRank else UserRank[item]
return item if type(item) == UserRank else UserRank[item.upper()]
class Permission(enum.Enum):
VIEW_PACKAGE = "VIEW_PACKAGE"
EDIT_PACKAGE = "EDIT_PACKAGE"
APPROVE_CHANGES = "APPROVE_CHANGES"
DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE"
DELETE_RELEASE = "DELETE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
REIMPORT_META = "REIMPORT_META"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
APPROVE_RELEASE = "APPROVE_RELEASE"
APPROVE_NEW = "APPROVE_NEW"
@@ -87,27 +87,34 @@ class Permission(enum.Enum):
TOPIC_DISCARD = "TOPIC_DISCARD"
CREATE_TOKEN = "CREATE_TOKEN"
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
DELETE_REVIEW = "DELETE_REVIEW"
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
VIEW_AUDIT_DESCRIPTION = "VIEW_AUDIT_DESCRIPTION"
EDIT_COLLECTION = "EDIT_COLLECTION"
VIEW_COLLECTION = "VIEW_COLLECTION"
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
# See Package.check_perm for package-specific contexts
def check(self, user):
if not user.is_authenticated:
return False
if self == Permission.APPROVE_NEW or \
self == Permission.APPROVE_CHANGES or \
self == Permission.APPROVE_RELEASE or \
self == Permission.APPROVE_SCREENSHOT or \
self == Permission.EDIT_TAGS or \
self == Permission.CREATE_TAG or \
self == Permission.SEE_THREAD:
return user.rank.atLeast(UserRank.EDITOR)
return user.rank.at_least(UserRank.APPROVER)
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
return user.rank.at_least(UserRank.EDITOR)
else:
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
raise Exception("Non-global permission checked globally. Use Package.check_perm or User.check_perm instead.")
@staticmethod
def checkPerm(user, perm):
def check_perm(user, perm):
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
@@ -123,6 +130,8 @@ def display_name_default(context):
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
# User authentication information
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
password = db.Column(db.String(255), nullable=True, server_default=None)
@@ -142,7 +151,9 @@ class User(db.Model, UserMixin):
# User email information
email = db.Column(db.String(255), nullable=True, unique=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
locale = db.Column(db.String(10), nullable=True, default=None)
# User information
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
@@ -155,108 +166,153 @@ class User(db.Model, UserMixin):
# Content
notifications = db.relationship("Notification", foreign_keys="Notification.user_id",
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id",
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user",
cascade="all, delete, delete-orphan")
cascade="all, delete, delete-orphan")
email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id",
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer",
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title"))
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title"))
clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
def get_dict(self):
from app.utils.flask import abs_url_for
return {
"username": self.username,
"display_name": self.display_name,
"rank": self.rank.name.lower(),
"profile_pic_url": self.profile_pic,
"website_url": self.website_url,
"donate_url": self.donate_url,
"connections": {
"github": self.github_username,
"forums": self.forums_username,
},
"links": {
"api_packages": abs_url_for("api.packages", author=self.username),
"profile": abs_url_for("users.profile", username=self.username),
}
}
def __init__(self, username=None, active=False, email=None, password=None):
self.username = username
self.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
self.display_name = username
self.is_active = active
self.email = email
self.password = password
self.rank = UserRank.NOT_JOINED
def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self) or \
Permission.APPROVE_CHANGES.check(self)
def can_access_todo_list(self):
return Permission.APPROVE_NEW.check(self) or Permission.APPROVE_RELEASE.check(self)
def isClaimed(self):
return self.rank.atLeast(UserRank.NEW_MEMBER)
def getProfilePicURL(self):
def get_profile_pic_url(self):
if self.profile_pic:
return self.profile_pic
elif self.rank == UserRank.BOT:
return "/static/bot_avatar.png"
else:
return gravatar(self.email or "")
return gravatar(self.email or f"{self.username}@content.minetest.net")
def checkPerm(self, user, perm):
def check_perm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to User.checkPerm()")
raise Exception("Unknown permission given to User.check_perm()")
# Members can edit their own packages, and editors can edit any packages
if perm == Permission.CHANGE_AUTHOR:
return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_USERNAMES:
return user.rank.atLeast(UserRank.MODERATOR)
return user.rank.at_least(UserRank.EDITOR)
elif perm == Permission.CHANGE_USERNAMES:
return user.rank.at_least(UserRank.MODERATOR)
elif perm == Permission.CHANGE_RANK:
return user.rank.at_least(UserRank.MODERATOR) and not self.rank.at_least(user.rank)
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
return user == self or user.rank.atLeast(UserRank.ADMIN)
elif perm == Permission.CREATE_TOKEN:
return user == self or (user.rank.at_least(UserRank.MODERATOR) and not self.rank.at_least(user.rank))
elif perm == Permission.CHANGE_DISPLAY_NAME:
return user.rank.at_least(UserRank.NEW_MEMBER if user == self else UserRank.MODERATOR)
elif perm == Permission.CREATE_TOKEN or perm == Permission.CREATE_OAUTH_CLIENT:
if user == self:
return user.rank.atLeast(UserRank.MEMBER)
return user.rank.at_least(UserRank.NEW_MEMBER)
else:
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
else:
raise Exception("Permission {} is not related to users".format(perm.name))
def canCommentRL(self):
def can_comment_ratelimit(self):
from app.models import ThreadReply
factor = 1
if self.rank.atLeast(UserRank.ADMIN):
if self.rank.at_least(UserRank.ADMIN):
return True
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor *= 2
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
factor = 3
elif self.rank.at_least(UserRank.MEMBER):
factor = 2
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > one_min_ago).count() >= 3 * factor:
.filter(ThreadReply.created_at > one_min_ago).count() >= 2 * factor:
return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > hour_ago).count() >= 20 * factor:
.filter(ThreadReply.created_at > hour_ago).count() >= 10 * factor:
return False
return True
def canOpenThreadRL(self):
def can_open_thread_ratelimit(self):
from app.models import Thread
factor = 1
if self.rank.atLeast(UserRank.ADMIN):
if self.rank.at_least(UserRank.ADMIN):
return True
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor *= 5
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
factor = 5
elif self.rank.at_least(UserRank.MEMBER):
factor = 2
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return Thread.query.filter_by(author=self) \
.filter(Thread.created_at > hour_ago).count() < 2 * factor
return Thread.query.filter_by(author=self)\
.filter(Thread.created_at > hour_ago).count() < 2 * factor
def can_review_ratelimit(self):
from app.models import PackageReview
factor = 1
if self.rank.at_least(UserRank.ADMIN):
return True
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
factor *= 5
five_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=5)
if PackageReview.query.filter_by(author=self) \
.filter(PackageReview.created_at > five_mins_ago).count() > 2 * factor:
return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return PackageReview.query.filter_by(author=self) \
.filter(PackageReview.created_at > hour_ago).count() < 10 * factor
def __eq__(self, other):
if other is None:
@@ -269,13 +325,15 @@ class User(db.Model, UserMixin):
return self.id == other.id
def can_see_edit_profile(self, current_user):
return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
self.checkPerm(current_user, Permission.CHANGE_RANK)
return self.check_perm(current_user, Permission.CHANGE_USERNAMES) or \
self.check_perm(current_user, Permission.CHANGE_EMAIL) or \
self.check_perm(current_user, Permission.CHANGE_RANK)
def can_delete(self):
from app.models import ForumTopic
return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0
return self.packages.count() == 0 and \
ForumTopic.query.filter_by(author=self).count() == 0 and \
self.rank != UserRank.BANNED
class UserEmailVerification(db.Model):
@@ -285,6 +343,7 @@ class UserEmailVerification(db.Model):
token = db.Column(db.String(32), nullable=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications")
is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
class EmailSubscription(db.Model):
@@ -298,6 +357,11 @@ class EmailSubscription(db.Model):
self.blacklisted = False
self.token = None
@property
def url(self):
from app.utils import abs_url_for
return abs_url_for('users.unsubscribe', token=self.token)
class NotificationType(enum.Enum):
# Package / release / etc
@@ -331,10 +395,10 @@ class NotificationType(enum.Enum):
OTHER = 0
def getTitle(self):
def get_title(self):
return self.name.replace("_", " ").title()
def toName(self):
def to_name(self):
return self.name.lower()
def get_description(self):
@@ -369,11 +433,11 @@ class NotificationType(enum.Enum):
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
return [(choice, choice.get_title()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == NotificationType else NotificationType[item]
return item if type(item) == NotificationType else NotificationType[item.upper()]
class Notification(db.Model):
@@ -451,18 +515,55 @@ class UserNotificationPreferences(db.Model):
self.pref_other = 0
def get_can_email(self, notification_type):
return getattr(self, "pref_" + notification_type.toName()) == 2
return getattr(self, "pref_" + notification_type.to_name()) == 2
def set_can_email(self, notification_type, value):
value = 2 if value else 0
setattr(self, "pref_" + notification_type.toName(), value)
setattr(self, "pref_" + notification_type.to_name(), value)
def get_can_digest(self, notification_type):
return getattr(self, "pref_" + notification_type.toName()) >= 1
return getattr(self, "pref_" + notification_type.to_name()) >= 1
def set_can_digest(self, notification_type, value):
if self.get_can_email(notification_type):
return
value = 1 if value else 0
setattr(self, "pref_" + notification_type.toName(), value)
setattr(self, "pref_" + notification_type.to_name(), value)
class UserBan(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="ban")
message = db.Column(db.UnicodeText, nullable=False)
banned_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
banned_by = db.relationship("User", foreign_keys=[banned_by_id])
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=True, default=None)
@property
def has_expired(self):
return self.expires_at and datetime.datetime.now() > self.expires_at
class OAuthClient(db.Model):
__tablename__ = "oauth_client"
id = db.Column(db.String(24), primary_key=True)
title = db.Column(db.String(64), unique=True, nullable=False)
description = db.Column(db.String(300), nullable=True)
secret = db.Column(db.String(32), nullable=False)
redirect_url = db.Column(db.String(128), nullable=False)
approved = db.Column(db.Boolean, nullable=False, default=False)
verified = db.Column(db.Boolean, nullable=False, default=False)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="clients")
tokens = db.relationship("APIToken", back_populates="client", lazy="dynamic", cascade="all, delete, delete-orphan")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)

4
app/public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Disallow: /packages/*/*/download/
Disallow: /packages/*/*/releases/*/download/
Disallow: /report/

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 B

Some files were not shown because too many files have changed in this diff Show More