Compare commits

...

105 Commits

Author SHA1 Message Date
rubenwardy
1ffbe93e0f Add funding.json 2025-11-06 12:22:18 +00:00
rubenwardy
0902d39970 Drop support for docker-compose v1 2025-11-01 18:27:32 +00:00
rubenwardy
8d5ba2af72 Remove unnecessary $ 2025-10-05 16:39:00 +01:00
rubenwardy
34948770ce Improve package/content technical name mismatch error 2025-10-05 16:35:08 +01:00
rubenwardy
8bafaed671 Link checker: display original href rather than resolved URL 2025-10-05 16:29:53 +01:00
rubenwardy
a604b3cd09 Move likely spammer job to worker 2025-09-23 19:35:15 +01:00
rubenwardy
ad41bc01b9 Add action to delete likely spammers 2025-09-23 19:30:43 +01:00
rubenwardy
4db70bf401 Add spam honeypot field to register form 2025-09-23 18:59:05 +01:00
rubenwardy
b88cc1366f Allow approvers and editors to see the audit log (package events only) 2025-09-23 17:59:45 +01:00
rubenwardy
feeed21b94 Hide website and donate links on the profiles of New Members 2025-09-23 16:35:46 +01:00
rubenwardy
dfa4e5a7a3 Add spam to ReportCategory 2025-09-23 16:25:39 +01:00
rubenwardy
28e5f44a30 Update translations 2025-09-23 10:00:42 +01:00
Filipino Student
b03b5b1adb Added translation using Weblate (Tagalog)
Co-authored-by: Filipino Student <hamen27545@artvara.com>
2025-09-23 10:53:55 +02:00
Ritwik
b7c6c3f338 Translated using Weblate (Hindi)
Currently translated at 0.5% (7 of 1200 strings)

Co-authored-by: Ritwik <ritwikraghav14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hi/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Pexauteau Santander
8568830cf6 Translated using Weblate (Slovak)
Currently translated at 82.5% (991 of 1200 strings)

Co-authored-by: Pexauteau Santander <pexauteau@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sk/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
qwoper tite
1ac3bd1da8 Translated using Weblate (Filipino)
Currently translated at 1.1% (14 of 1200 strings)

Added translation using Weblate (Filipino)

Co-authored-by: qwoper tite <yipoli8514@inupup.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fil/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Daan sfs
9778f8be53 Translated using Weblate (Dutch)
Currently translated at 43.1% (518 of 1200 strings)

Co-authored-by: Daan sfs <xdaan736@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/nl/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Nicolae Crefelean
7e47287b23 Translated using Weblate (Romanian)
Currently translated at 6.9% (83 of 1200 strings)

Co-authored-by: Nicolae Crefelean <kneekoo@yahoo.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ro/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Artur Ajvazjan
7d8e45f64f Added translation using Weblate (Armenian)
Co-authored-by: Artur Ajvazjan <arturaiwasan@gmail.com>
2025-09-23 10:53:55 +02:00
Zaman Hüseynli
8f77197cde Translated using Weblate (Azerbaijani)
Currently translated at 0.1% (1 of 1200 strings)

Added translation using Weblate (Azerbaijani)

Co-authored-by: Zaman Hüseynli <zamanhuseynli23@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/az/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Денис
1f24fe0843 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Денис <denissavchenko0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Gabriel
c15e1eb042 Translated using Weblate (Romanian)
Currently translated at 6.9% (83 of 1200 strings)

Co-authored-by: Gabriel <gabrielpopa154@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ro/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Killercat103
1e6499d23a Translated using Weblate (Kurdish (Northern))
Currently translated at 0.1% (1 of 1200 strings)

Added translation using Weblate (Kurdish (Northern))

Co-authored-by: Killercat103 <killercat103@densphere.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/kmr/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Тарас Арт
19257d4dc0 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Тарас Арт <fromkaniv@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Денис
762562f837 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Денис <denissavchenko0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
GiorgeGi
75192abb3a Translated using Weblate (Greek)
Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 46.4% (557 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 35.3% (424 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 35.3% (424 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 35.3% (424 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 35.3% (424 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 23.8% (286 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 23.8% (286 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 23.8% (286 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 23.8% (286 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 22.6% (272 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 22.6% (272 of 1200 strings)

Co-authored-by: GiorgeGi <giorgegi729@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/el/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
FedonT
6276dfb614 Translated using Weblate (Greek)
Currently translated at 19.3% (232 of 1200 strings)

Translated using Weblate (Greek)

Currently translated at 19.2% (231 of 1200 strings)

Co-authored-by: FedonT <phaidtheodcoc@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/el/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
GiorgeGi
33860b84cf Translated using Weblate (Greek)
Currently translated at 19.2% (231 of 1200 strings)

Co-authored-by: GiorgeGi <giorgegi729@gmail.com>
Co-authored-by: GiorgeGi GiorgeGi <giorgegi729@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/el/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
nauta-turbidus
607065d94c Translated using Weblate (Polish)
Currently translated at 84.5% (1014 of 1200 strings)

Co-authored-by: nauta-turbidus <wiktor-t@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pl/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
BlackImpostor
ac02e2eb2e Translated using Weblate (Russian)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: BlackImpostor <SkyBuilderOFFICAL@yandex.ru>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Kisbenedek Márton
ce5ae3fe40 Translated using Weblate (Hungarian)
Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 51.5% (619 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Translated using Weblate (Hungarian)

Currently translated at 45.5% (546 of 1200 strings)

Co-authored-by: Kisbenedek Márton <martonkisbenedek@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hu/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Matin Sohrablu
fb87b51a23 Translated using Weblate (Persian)
Currently translated at 18.4% (221 of 1200 strings)

Co-authored-by: Matin Sohrablu <1matin21.1234@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fa/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
தமிழ்நேரம்
7070644842 Translated using Weblate (Tamil)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ta/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Wuzzy
ced9f320a4 Translated using Weblate (German)
Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (German)

Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Wuzzy <Wuzzy@disroot.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/de/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Rudra Harsh
fb8264bf7d Translated using Weblate (Hindi)
Currently translated at 0.5% (6 of 1200 strings)

Co-authored-by: Rudra Harsh <harshrudra020@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/hi/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
TheOnlyJoeEnderman
cfa1bee2d8 Translated using Weblate (Japanese)
Currently translated at 37.9% (455 of 1200 strings)

Co-authored-by: TheOnlyJoeEnderman <joeenderman1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ja/
Translation: Luanti/ContentDB
2025-09-23 10:53:55 +02:00
Тарас Арт
fa11b0ffa8 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (1198 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (1198 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Co-authored-by: Тарас Арт <fromkaniv@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Luanti/ContentDB
2025-09-23 10:53:54 +02:00
Ian Pedras
c3889d64a3 Translated using Weblate (Portuguese)
Currently translated at 55.0% (661 of 1200 strings)

Co-authored-by: Ian Pedras <ian@pedras.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/pt/
Translation: Luanti/ContentDB
2025-09-23 10:53:54 +02:00
Zacharias Tyllström
8b69d552fd Translated using Weblate (Swedish)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Zacharias Tyllström <zacharias.tyllstrom@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/sv/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
ninjum
0a1ff05c39 Translated using Weblate (Galician)
Currently translated at 9.2% (111 of 1200 strings)

Translated using Weblate (Galician)

Currently translated at 9.0% (109 of 1200 strings)

Co-authored-by: ninjum <ninhum@gmx.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/gl/
Translation: Luanti/ContentDB
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Jun Nogata
2b33a89107 Translated using Weblate (Japanese)
Currently translated at 33.4% (401 of 1200 strings)

Translated using Weblate (Japanese)

Currently translated at 31.9% (383 of 1200 strings)

Translated using Weblate (Japanese)

Currently translated at 31.8% (382 of 1200 strings)

Translated using Weblate (Japanese)

Currently translated at 30.9% (371 of 1200 strings)

Translated using Weblate (Japanese)

Currently translated at 29.5% (355 of 1200 strings)

Translated using Weblate (Japanese)

Currently translated at 26.4% (317 of 1200 strings)

Co-authored-by: Jun Nogata <nogajun@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ja/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Siber
bde76b5b46 Translated using Weblate (Turkish)
Currently translated at 100.0% (1200 of 1200 strings)

Translated using Weblate (Turkish)

Currently translated at 99.6% (1196 of 1200 strings)

Co-authored-by: Siber <siber@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/tr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Денис
7c4e6aece8 Translated using Weblate (Ukrainian)
Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Co-authored-by: Денис <denissavchenko0@gmail.com>
Co-authored-by: Денис Савченко <denissavchenko0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Tanavit MINETEST
e0e50a78ed Translated using Weblate (French)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Tanavit MINETEST <tanavit@posto.ovh>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
3raven
930a460ef3 Translated using Weblate (French)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: 3raven <elise_declerck@laposte.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Tanavit MINETEST
f7d81f9fba Translated using Weblate (French)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Tanavit MINETEST <tanavit@posto.ovh>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
3raven
25998fdcc4 Translated using Weblate (French)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: 3raven <elise_declerck@laposte.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Tanavit MINETEST
617e7f043b Translated using Weblate (French)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Tanavit MINETEST <tanavit@posto.ovh>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
3raven
ced7abd27f Translated using Weblate (French)
Currently translated at 99.6% (1196 of 1200 strings)

Co-authored-by: 3raven <elise_declerck@laposte.net>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Tanavit MINETEST
f29a1be1eb Translated using Weblate (French)
Currently translated at 99.6% (1196 of 1200 strings)

Co-authored-by: Tanavit MINETEST <tanavit@posto.ovh>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/fr/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Тарас Арт
0704ffb1a2 Translated using Weblate (Ukrainian)
Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 96.7% (1161 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 95.7% (1149 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 94.9% (1139 of 1200 strings)

Translated using Weblate (Ukrainian)

Currently translated at 91.5% (1099 of 1200 strings)

Co-authored-by: Тарас Арт <fromkaniv@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/uk/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Gg Gg
9f6c295484 Added translation using Weblate (Slovenian)
Co-authored-by: Gg Gg <veselsem0@gmail.com>
2025-09-23 10:53:54 +02:00
Nana_M
8936bdca81 Translated using Weblate (Russian)
Currently translated at 100.0% (1200 of 1200 strings)

Co-authored-by: Nana_M <sab.pyrope@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/ru/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
Matyáš Pilz
f86b1343b7 Translated using Weblate (Czech)
Currently translated at 99.5% (1195 of 1200 strings)

Translated using Weblate (Czech)

Currently translated at 99.5% (1195 of 1200 strings)

Co-authored-by: Matyáš Pilz <matys.pilz@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/minetest/contentdb/cs/
Translation: Minetest/ContentDB
2025-09-23 10:53:54 +02:00
rubenwardy
d6e39fb896 Allow editors to see reports
Some reports are about packages and not users
2025-09-23 09:52:39 +01:00
rubenwardy
4004c16504 Fix import error caused by 864add055b 2025-09-12 22:08:53 +01:00
rubenwardy
66b935037c Update private thread text 2025-09-12 21:29:50 +01:00
rubenwardy
4c78e098cf Update report closure reasons to improve clarity 2025-09-10 15:08:14 +01:00
ROllerozxa
864add055b Remove Codeberg-specific ratelimit when checking Git repos (#609) 2025-09-10 14:11:57 +01:00
rubenwardy
e44a5545a3 Add report attachments 2025-09-01 18:16:42 +01:00
rubenwardy
30aecb8565 Adjust codeberg ratelimit again 2025-09-01 08:33:02 +01:00
rubenwardy
79354453e5 Adjust codeberg ratelimit 2025-08-29 10:56:59 +01:00
rubenwardy
d6e4abec73 Fix codeberg queue ratelimit 2025-08-27 18:39:24 +01:00
rubenwardy
be03243250 Add reminder to comment in thread after invalid report resolution 2025-08-27 16:51:21 +01:00
rubenwardy
92f935c6de Add ability to search audit log by URL 2025-08-27 16:46:04 +01:00
rubenwardy
0a52e26cd0 Add report edit form 2025-08-27 16:34:11 +01:00
rubenwardy
6bf94e558a Add ability to reopen reports 2025-08-27 14:22:18 +01:00
rubenwardy
9ec215c3d4 Add last_checked_at to update config 2025-08-26 21:38:36 +01:00
rubenwardy
704c6be1c4 Add dedicated celery task for codeberg 2025-08-26 20:50:05 +01:00
rubenwardy
0adf02bf99 Reduce check_update_config ratelimit further 2025-08-26 20:33:22 +01:00
rubenwardy
8631425ff7 Add "Outdated/invalid review" report category 2025-08-26 20:26:56 +01:00
rubenwardy
b8e25f8565 Rename "Quick resolve" -> "Close report" 2025-08-26 19:35:08 +01:00
rubenwardy
394b1fe33d Disable email requirement for admins 2025-08-26 19:29:17 +01:00
rubenwardy
038e65bfe3 Add backlink to report on thread page 2025-08-26 19:29:09 +01:00
rubenwardy
6e2d8b1974 Add threads to reports 2025-08-26 19:14:47 +01:00
rubenwardy
310f1baa09 Prevent mentioned users being added to private threads 2025-08-26 18:47:02 +01:00
rubenwardy
acf9e16234 Add reports list to editor todo page 2025-08-26 18:29:14 +01:00
rubenwardy
741bd23144 Update report form text 2025-08-26 18:19:24 +01:00
rubenwardy
bdf1c2df6e Fix report category form validation 2025-08-26 18:01:11 +01:00
rubenwardy
8d1268bd19 Add report categories 2025-08-26 15:46:41 +01:00
rubenwardy
8db72faf3c Use random string ids for reports 2025-08-26 15:26:07 +01:00
rubenwardy
e4c061858e Add report received page 2025-08-26 15:21:14 +01:00
rubenwardy
0653ed2183 Small tweaks to reporting interface 2025-08-26 15:04:01 +01:00
rubenwardy
a9f82b6e1b Add reports to database 2025-08-26 14:53:44 +01:00
rubenwardy
80d06d154a Adjust check_update_config ratelimit 2025-08-25 21:49:16 +01:00
rubenwardy
6265c0665b Replace 'Minetest' with 'Luanti' in remaining places 2025-08-25 14:21:43 +01:00
rubenwardy
1c2a56e784 Fix failing CI 2025-08-25 13:53:31 +01:00
rubenwardy
e03c8f04e1 Fix crash in approval stats 2025-08-24 22:10:56 +01:00
rubenwardy
bd23a99aec Update storage table titles 2025-08-24 22:08:14 +01:00
rubenwardy
26272ce793 Add 60/m ratelimit to check_update_config task 2025-08-24 21:40:44 +01:00
rubenwardy
66f918c1bf Fix crash on create package error due to missing rollback 2025-08-16 16:45:30 +01:00
rubenwardy
a6fccc7c58 Fix unknown error on setting null package field 2025-07-23 22:11:18 +01:00
rubenwardy
ae05c10e7c Remove margin above first item in release notes 2025-07-17 23:09:22 +01:00
rubenwardy
14d8ef6cb1 Add height limit to release notes side card 2025-07-17 23:05:03 +01:00
rubenwardy
bc62c2b1be Increase zipgrep per-zip timeout 2025-07-05 22:07:58 +01:00
rubenwardy
dedafe9c71 Require screenshot for all packages 2025-07-04 21:37:52 +01:00
rubenwardy
e754d8d80d Update package inclusion policy (#601)
Fixes #566, fixes #222
2025-07-03 12:16:11 +01:00
rubenwardy
cbb59e5e55 Fix GitLab webhook repo not matching with capital letters 2025-07-02 23:01:57 +01:00
rubenwardy
1928a2302c Improve storage page queries 2025-07-02 21:06:09 +01:00
rubenwardy
6b3a2a0fe7 Fix storage page by storing file sizes in the db 2025-07-02 20:47:30 +01:00
rubenwardy
52802f44f6 Update Python dependencies 2025-07-02 20:12:02 +01:00
rubenwardy
f01adc4cb4 Fix failing CI 2025-07-02 20:02:27 +01:00
rubenwardy
c21dc5313d Improve header anchor link styling 2025-07-01 21:05:13 +01:00
Wuzzy
5243176d74 Use the Luanti name in places where forgotten (#600) 2025-07-01 20:47:47 +01:00
rubenwardy
c495fcbd1a Reject new packages with depends.txt or description.txt 2025-07-01 20:18:29 +01:00
149 changed files with 60790 additions and 17073 deletions

View File

@@ -1,7 +1,7 @@
# ContentDB
![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
![Build Status](https://github.com/luanti-org/contentdb/actions/workflows/test.yml/badge.svg)
A content database for Minetest mods, games, and more.\
A content database for Luanti mods, games, and more.\
Developed by rubenwardy, license AGPLv3.0+.
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
@@ -82,7 +82,7 @@ Package "1" --> "*" Release
Package "1" --> "*" Dependency
Package "1" --> "*" Tag
Package "1" --> "*" MetaPackage : provides
Release --> MinetestVersion
Release --> LuantiVersion
Package --> License
Dependency --> Package
Dependency --> MetaPackage

View File

@@ -113,7 +113,7 @@ pgettext("tags", "For mods created for the Discord \"Weekly Challenges\" modding
# NOTE: tags: title for less_than_px
pgettext("tags", "<16px")
# NOTE: tags: description for less_than_px
pgettext("tags", "Less than 16px")
pgettext("tags", "For less than 16px texture packs ")
# NOTE: tags: title for library
pgettext("tags", "API / Library")
# NOTE: tags: description for library

View File

@@ -20,16 +20,16 @@ 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 sqlalchemy import or_, and_, not_, func
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, Thread, AuditLogEntry
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, Thread, AuditLogEntry, ReportAttachment
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, \
import_languages, check_all_zip_files
from app.tasks.usertasks import import_github_user_ids
from app.tasks.pkgtasks import notify_about_git_forum_links, clear_removed_packages, check_package_for_broken_links
from app.tasks.usertasks import import_github_user_ids, do_delete_likely_spammers
from app.tasks.pkgtasks import notify_about_git_forum_links, clear_removed_packages, check_package_for_broken_links, update_file_size_bytes
from app.utils import add_notification, get_system_user
actions = {}
@@ -68,9 +68,10 @@ def clean_uploads():
release_urls = get_filenames_from_column(PackageRelease.url)
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
attachment_urls = get_filenames_from_column(ReportAttachment.url)
pp_urls = get_filenames_from_column(User.profile_pic)
db_urls = release_urls.union(screenshot_urls).union(pp_urls)
db_urls = release_urls.union(screenshot_urls).union(pp_urls).union(attachment_urls)
unreachable = existing_uploads.difference(db_urls)
import sys
@@ -322,6 +323,13 @@ def do_check_all_zip_files():
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("Update file_size_bytes")
def do_update_file_size_bytes():
task_id = uuid()
update_file_size_bytes.apply_async((), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("DANGER: Delete less popular removed packages")
def del_less_popular_removed_packages():
task_id = uuid()
@@ -417,3 +425,10 @@ def delete_empty_threads():
def check_for_broken_links():
for package in Package.query.filter_by(state=PackageState.APPROVED).all():
check_package_for_broken_links.delay(package.id)
@action("DANGER: Delete likely spammers")
def delete_likely_spammers():
task_id = uuid()
do_delete_likely_spammers.apply_async((), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))

View File

@@ -21,9 +21,10 @@ 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, \
get_int_or_abort
from sqlalchemy import func
from . import bp
from .actions import actions
from app.models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType, PackageAlias
from app.models import UserRank, Package, db, PackageState, PackageRelease, PackageScreenshot, User, AuditSeverity, NotificationType, PackageAlias
from ...querybuilder import QueryBuilder
@@ -182,6 +183,17 @@ def transfer():
return render_template("admin/transfer.html", form=form)
def sum_file_sizes(clazz):
ret = {}
for entry in (db.session
.query(clazz.package_id, func.sum(clazz.file_size_bytes))
.select_from(clazz)
.group_by(clazz.package_id)
.all()):
ret[entry[0]] = entry[1]
return ret
@bp.route("/admin/storage/")
@rank_required(UserRank.EDITOR)
def storage():
@@ -192,15 +204,20 @@ def storage():
show_all = len(packages) < 100
min_size = get_int_or_abort(request.args.get("min_size"), 0 if show_all else 50)
package_size_releases = sum_file_sizes(PackageRelease)
package_size_screenshots = sum_file_sizes(PackageScreenshot)
data = []
for package in packages:
size_releases = sum([x.file_size_bytes for x in package.releases])
size_screenshots = sum([x.file_size_bytes for x in package.screenshots])
size_releases = package_size_releases.get(package.id, 0)
size_screenshots = package_size_screenshots.get(package.id, 0)
size_total = size_releases + size_screenshots
if size_total < min_size * 1024 * 1024:
continue
latest_release = package.releases.first()
size_latest = latest_release.file_size_bytes if latest_release else 0
size_total = size_releases + size_screenshots
if size_total > min_size*1024*1024:
data.append([package, size_total, size_releases, size_screenshots, size_latest])
data.append([package, size_total, size_releases, size_screenshots, size_latest])
data.sort(key=lambda x: x[1], reverse=True)
return render_template("admin/storage.html", data=data)

View File

@@ -15,7 +15,11 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, request, abort
from flask_babel import lazy_gettext
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import Optional, Length
from app.models import db, AuditLogEntry, UserRank, User, Permission
from app.utils import rank_required, get_int_or_abort
@@ -23,26 +27,40 @@ from app.utils import rank_required, get_int_or_abort
from . import bp
class AuditForm(FlaskForm):
username = StringField(lazy_gettext("Username"), [Optional(), Length(0, 25)])
q = StringField(lazy_gettext("Query"), [Optional(), Length(0, 300)])
url = StringField(lazy_gettext("URL"), [Optional(), Length(0, 300)])
submit = SubmitField(lazy_gettext("Search"), name=None)
@bp.route("/admin/audit/")
@rank_required(UserRank.MODERATOR)
@rank_required(UserRank.APPROVER)
def audit():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at))
if "username" in request.args:
user = User.query.filter_by(username=request.args.get("username")).first()
if not user:
abort(404)
form = AuditForm(request.args)
username = form.username.data
q = form.q.data
url = form.url.data
if username:
user = User.query.filter_by(username=username).first_or_404()
query = query.filter_by(causer=user)
if "q" in request.args:
q = request.args["q"]
if q:
query = query.filter(AuditLogEntry.title.ilike(f"%{q}%"))
if url:
query = query.filter(AuditLogEntry.url.ilike(f"%{url}%"))
if not current_user.rank.at_least(UserRank.MODERATOR):
query = query.filter(AuditLogEntry.package)
pagination = query.paginate(page=page, per_page=num)
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
return render_template("admin/audit.html", log=pagination.items, pagination=pagination, form=form)
@bp.route("/admin/audit/<int:id_>/")

View File

@@ -23,14 +23,14 @@ from wtforms.validators import InputRequired, Length
from app.utils import rank_required, add_audit_log
from . import bp
from app.models import UserRank, MinetestRelease, db, AuditSeverity
from app.models import UserRank, LuantiRelease, 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())
versions=LuantiRelease.query.order_by(db.asc(LuantiRelease.id)).all())
class VersionForm(FlaskForm):
@@ -45,14 +45,14 @@ class VersionForm(FlaskForm):
def create_edit_version(name=None):
version = None
if name is not None:
version = MinetestRelease.query.filter_by(name=name).first()
version = LuantiRelease.query.filter_by(name=name).first()
if version is None:
abort(404)
form = VersionForm(formdata=request.form, obj=version)
if form.validate_on_submit():
if version is None:
version = MinetestRelease(form.name.data)
version = LuantiRelease(form.name.data)
db.session.add(version)
flash("Created version " + form.name.data, "success")

View File

@@ -29,12 +29,12 @@ from app import csrf
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, \
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
LuantiRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \
PackageAlias, Language
from app.querybuilder import QueryBuilder
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date, cached, \
cors_allowed
from app.utils.minetest_hypertext import html_to_minetest, package_info_as_hypertext, package_reviews_as_hypertext
from app.utils.luanti_hypertext import html_to_luanti, package_info_as_hypertext, package_reviews_as_hypertext
from . import bp
from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
@@ -102,7 +102,7 @@ def package_view_client(package: Package):
protocol_version = request.args.get("protocol_version")
engine_version = request.args.get("engine_version")
if protocol_version or engine_version:
version = MinetestRelease.get(engine_version, get_int_or_abort(protocol_version))
version = LuantiRelease.get(engine_version, get_int_or_abort(protocol_version))
else:
version = None
@@ -116,7 +116,7 @@ def package_view_client(package: Package):
page_url = package.get_url("packages.view", absolute=True)
if data["long_description"] is not None:
html = render_markdown(data["long_description"])
data["long_description"] = html_to_minetest(html, page_url, formspec_version, include_images)
data["long_description"] = html_to_luanti(html, page_url, formspec_version, include_images)
data["info_hypertext"] = package_info_as_hypertext(package, formspec_version)
@@ -153,7 +153,7 @@ def package_hypertext(package):
include_images = is_yes(request.args.get("include_images", "true"))
html = render_markdown(package.desc if package.desc else "")
page_url = package.get_url("packages.view", absolute=True)
return jsonify(html_to_minetest(html, page_url, formspec_version, include_images))
return jsonify(html_to_luanti(html, page_url, formspec_version, include_images))
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
@@ -636,14 +636,14 @@ def versions():
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))
rel = LuantiRelease.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])
for rel in LuantiRelease.query.all() if rel.get_actual() is not None])
@bp.route("/api/languages/")
@@ -835,7 +835,7 @@ def hypertext():
if request.content_type == "text/markdown":
html = render_markdown(html)
return jsonify(html_to_minetest(html, "", formspec_version, include_images))
return jsonify(html_to_luanti(html, "", formspec_version, include_images))
@bp.route("/api/collections/")
@@ -886,9 +886,9 @@ def collection_view(token, author, name):
@cached(300)
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)
engine_version = request.args.get("engine_version")
if protocol_version or engine_version:
version = LuantiRelease.get(engine_version, protocol_version)
else:
version = None

View File

@@ -20,7 +20,7 @@ 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, do_set_cover_image
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
from app.models import APIToken, Package, LuantiRelease, PackageScreenshot
def error(code: int, msg: str):
@@ -39,7 +39,7 @@ def guard(f):
def api_create_vcs_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason="API"):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
@@ -55,7 +55,7 @@ def api_create_vcs_release(token: APIToken, package: Package, name: str, title:
def api_create_zip_release(token: APIToken, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash: str = None):
min_v: LuantiRelease = None, max_v: LuantiRelease = 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")

View File

@@ -28,7 +28,7 @@ from sqlalchemy.sql.expression import func
PKGS_PER_ROW = 4
# GAMEJAM_BANNER = "https://jam.minetest.net/img/banner.png"
# GAMEJAM_BANNER = "https://jam.luanti.org/img/banner.png"
#
# class GameJam:
# cover_image = type("", (), dict(url=GAMEJAM_BANNER))()
@@ -40,7 +40,7 @@ PKGS_PER_ROW = 4
# def get_url(self, _name):
# return "/gamejam/"
#
# title = "Minetest Game Jam 2023: \"Unexpected\""
# title = "Luanti Game Jam 2023: \"Unexpected\""
# author = None
#
# short_desc = "The game jam has finished! It's now up to the community to play and rate the games."
@@ -51,7 +51,7 @@ PKGS_PER_ROW = 4
@bp.route("/gamejam/")
def gamejam():
return redirect("https://jam.minetest.net/")
return redirect("https://jam.luanti.org/")
@bp.route("/")

View File

@@ -23,7 +23,7 @@ from wtforms.validators import Optional
from wtforms_sqlalchemy.fields import QuerySelectMultipleField, QuerySelectField
from . import bp
from ...models import PackageType, Tag, db, ContentWarning, License, Language, MinetestRelease, Package, PackageState
from ...models import PackageType, Tag, db, ContentWarning, License, Language, LuantiRelease, Package, PackageState
def make_label(obj: Tag | ContentWarning):
@@ -75,7 +75,7 @@ class AdvancedSearchForm(FlaskForm):
get_pk=lambda a: a.id, get_label=lambda a: a.title)
hide = SelectMultipleField(lazy_gettext("Hide Tags and Content Warnings"), [Optional()])
engine_version = QuerySelectField(lazy_gettext("Luanti Version"),
query_factory=lambda: MinetestRelease.query.order_by(db.asc(MinetestRelease.id)),
query_factory=lambda: LuantiRelease.query.order_by(db.asc(LuantiRelease.id)),
allow_blank=True, blank_value="",
get_pk=lambda a: a.value, get_label=lambda a: a.name)
sort = SelectField(lazy_gettext("Sort by"), [Optional()], choices=[

View File

@@ -233,7 +233,7 @@ class PackageForm(FlaskForm):
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)])
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
dev_state = SelectField(lazy_gettext("Maintenance State"), [DataRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=make_label)
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=make_label)
@@ -306,10 +306,6 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
"translation_url": form.translation_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)
@@ -322,13 +318,14 @@ def handle_create_edit(package: typing.Optional[Package], form: PackageForm, aut
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
db.session.rollback()
@bp.route("/packages/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
@login_required
def create_edit(author=None, name=None):
if current_user.email is None:
if current_user.email is None and not current_user.rank.at_least(UserRank.ADMIN):
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
return redirect(url_for("users.email_notifications"))

View File

@@ -25,7 +25,7 @@ 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, \
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, LuantiRelease, \
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
from app.rediscache import has_key, set_temp_key, make_download_key
from app.tasks.importtasks import check_update_config
@@ -42,11 +42,11 @@ def list_releases(package):
def get_mt_releases(is_max):
query = MinetestRelease.query.order_by(db.asc(MinetestRelease.id))
query = LuantiRelease.query.order_by(db.asc(LuantiRelease.id))
if is_max:
query = query.limit(query.count() - 1)
else:
query = query.filter(MinetestRelease.name != "0.4.17")
query = query.filter(LuantiRelease.name != "0.4.17")
return query
@@ -85,7 +85,7 @@ class EditPackageReleaseForm(FlaskForm):
@login_required
@is_package_page
def create_release(package):
if current_user.email is None:
if current_user.email is None and not current_user.rank.at_least(UserRank.ADMIN):
flash(gettext("You must add an email address to your account and confirm it before you can manage packages"), "danger")
return redirect(url_for("users.email_notifications"))
@@ -128,9 +128,9 @@ 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():
user_agent = request.headers.get("User-Agent") or ""
is_minetest = user_agent.startswith("Luanti") or user_agent.startswith("Minetest")
is_luanti = user_agent.startswith("Luanti") or user_agent.startswith("Minetest")
reason = request.args.get("reason")
PackageDailyStats.update(package, is_minetest, reason)
PackageDailyStats.update(package, is_luanti, reason)
key = make_download_key(ip, release.package)
if not has_key(key):

View File

@@ -19,8 +19,9 @@ 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_wtf.file import FileRequired
from wtforms import StringField, SubmitField, BooleanField, FileField
from wtforms.validators import InputRequired, Length, DataRequired, Optional
from wtforms.validators import Length, DataRequired, Optional
from wtforms_sqlalchemy.fields import QuerySelectField
from app.logic.LogicError import LogicError
@@ -32,7 +33,7 @@ from app.utils import is_package_page
class CreateScreenshotForm(FlaskForm):
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
file_upload = FileField(lazy_gettext("File Upload"), [InputRequired()])
file_upload = FileField(lazy_gettext("File Upload"), [FileRequired()])
submit = SubmitField(lazy_gettext("Save"))

View File

@@ -14,24 +14,30 @@
# 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 import Blueprint, request, render_template, url_for, abort, flash
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 wtforms import TextAreaField, SubmitField, URLField, StringField, SelectField, FileField
from wtforms.validators import InputRequired, Length, Optional, DataRequired
from app.models import User, UserRank
from app.tasks.emails import send_user_email
from app.logic.uploads import upload_file
from app.models import User, UserRank, Report, db, AuditSeverity, ReportCategory, Thread, Permission, ReportAttachment
from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_no, abs_url_samesite, normalize_line_endings
from app.utils import (is_no, abs_url_samesite, normalize_line_endings, rank_required, add_audit_log, abs_url_for,
random_string, add_replies)
bp = Blueprint("report", __name__)
class ReportForm(FlaskForm):
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)], filters=[normalize_line_endings])
category = SelectField(lazy_gettext("Category"), [DataRequired()], choices=ReportCategory.choices(with_none=True), coerce=ReportCategory.coerce)
url = URLField(lazy_gettext("URL"), [Optional()])
title = StringField(lazy_gettext("Subject / Title"), [InputRequired(), Length(10, 300)])
message = TextAreaField(lazy_gettext("Message"), [Optional(), Length(0, 10000)], filters=[normalize_line_endings])
file_upload = FileField(lazy_gettext("Image Upload"), [Optional()])
submit = SubmitField(lazy_gettext("Report"))
@@ -46,25 +52,131 @@ def report():
url = abs_url_samesite(url)
form = ReportForm(formdata=request.form) if current_user.is_authenticated else None
form = ReportForm() if current_user.is_authenticated else None
if form and request.method == "GET":
form.message.data = request.args.get("message", "")
try:
form.category.data = ReportCategory.coerce(request.args.get("category"))
except KeyError:
pass
form.url.data = url
form.title.data = request.args.get("title", "")
if form and form.validate_on_submit():
report = Report()
report.id = random_string(8)
report.user = current_user if current_user.is_authenticated else None
form.populate_obj(report)
if current_user.is_authenticated:
user_info = f"{current_user.username}"
thread = Thread()
thread.title = f"Report: {form.title.data}"
thread.author = current_user
thread.private = True
thread.watchers.extend(User.query.filter(User.rank >= UserRank.MODERATOR).all())
db.session.add(thread)
db.session.flush()
report.thread = thread
add_replies(thread, current_user, f"**{report.category.title} report created**\n\n{form.message.data}")
else:
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
ip_addr = request.headers.get("X-Forwarded-For") or request.remote_addr
report.message = ip_addr + "\n\n" + report.message
text = f"{url}\n\n{form.message.data}"
db.session.add(report)
db.session.flush()
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)
if form.file_upload.data:
atmt = ReportAttachment()
report.attachments.add(atmt)
uploaded_url, _ = upload_file(form.file_upload.data, "image", lazy_gettext("a PNG, JPEG, or WebP image file"))
atmt.url = uploaded_url
db.session.add(atmt)
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
if current_user.is_authenticated:
add_audit_log(AuditSeverity.USER, current_user, f"New report: {report.title}",
url_for("report.view", rid=report.id))
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
db.session.commit()
return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
abs_url = abs_url_for("report.view", rid=report.id)
msg = f"**New Report**\nReport on `{report.url}`\n\n{report.title}\n\nView: {abs_url}"
post_discord_webhook.delay(None if is_anon else current_user.username, msg, True)
return redirect(url_for("report.report_received", rid=report.id))
return render_template("report/report.html", form=form, url=url, is_anon=is_anon, noindex=url is not None)
@bp.route("/report/received/")
def report_received():
rid = request.args.get("rid")
report = Report.query.get_or_404(rid)
return render_template("report/report_received.html", report=report)
@bp.route("/admin/reports/")
@rank_required(UserRank.EDITOR)
def list_all():
reports = Report.query.order_by(db.asc(Report.is_resolved), db.asc(Report.created_at)).all()
return render_template("report/list.html", reports=reports)
@bp.route("/admin/reports/<rid>/", methods=["GET", "POST"])
def view(rid: str):
report = Report.query.get_or_404(rid)
if not report.check_perm(current_user, Permission.SEE_REPORT):
abort(404)
if request.method == "POST":
if report.is_resolved:
if "reopen" in request.form:
report.is_resolved = False
url = url_for("report.view", rid=report.id)
add_audit_log(AuditSeverity.MODERATION, current_user, f"Reopened report \"{report.title}\"", url)
if report.thread:
add_replies(report.thread, current_user, f"Reopened report", is_status_update=True)
db.session.commit()
else:
if "completed" in request.form:
outcome = "as completed"
elif "removed" in request.form:
outcome = "as content removed"
elif "invalid" in request.form:
outcome = "without action"
if report.thread:
flash("Make sure to comment why the report is invalid in the thread", "warning")
else:
abort(400)
report.is_resolved = True
url = url_for("report.view", rid=report.id)
add_audit_log(AuditSeverity.MODERATION, current_user, f"Report closed {outcome} \"{report.title}\"", url)
if report.thread:
add_replies(report.thread, current_user, f"Closed report {outcome}", is_status_update=True)
db.session.commit()
return render_template("report/view.html", report=report)
@bp.route("/admin/reports/<rid>/edit/", methods=["GET", "POST"])
def edit(rid: str):
report = Report.query.get_or_404(rid)
if not report.check_perm(current_user, Permission.SEE_REPORT):
abort(404)
form = ReportForm(request.form, obj=report)
form.submit.label.text = lazy_gettext("Save")
if form.validate_on_submit():
form.populate_obj(report)
url = url_for("report.view", rid=report.id)
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited report \"{report.title}\"", url)
db.session.commit()
return redirect(url_for("report.view", rid=report.id))
return render_template("report/edit.html", report=report, form=form)

View File

@@ -254,6 +254,9 @@ def view(id):
if mentioned is None:
continue
if not thread.check_perm(mentioned, Permission.SEE_THREAD):
continue
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)

View File

@@ -20,7 +20,7 @@ from flask_login import current_user, login_required
from sqlalchemy import or_, and_
from app.models import Package, PackageState, PackageScreenshot, PackageUpdateConfig, ForumTopic, db, \
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, MinetestRelease
PackageRelease, Permission, UserRank, License, MetaPackage, Dependency, AuditLogEntry, Tag, LuantiRelease, Report
from app.querybuilder import QueryBuilder
from app.utils import get_int_or_abort, is_yes, rank_required
from . import bp
@@ -83,11 +83,13 @@ def view_editor():
.order_by(db.desc(AuditLogEntry.created_at)) \
.limit(20).all()
reports = Report.query.filter_by(is_resolved=False).order_by(db.asc(Report.created_at)).all() if current_user.rank.at_least(UserRank.EDITOR) else None
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)
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log, reports=reports)
@bp.route("/todo/tags/")
@@ -170,7 +172,7 @@ def screenshots():
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()
current_stable = LuantiRelease.query.filter(~LuantiRelease.name.like("%-dev")).order_by(db.desc(LuantiRelease.id)).first()
query = db.session.query(Package) \
.filter(~Package.releases.any(or_(PackageRelease.max_rel==None, PackageRelease.max_rel == current_stable))) \

View File

@@ -104,6 +104,7 @@ class RegisterForm(FlaskForm):
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()])
first_name = StringField("First name", [])
submit = SubmitField(lazy_gettext("Register"))
@@ -117,6 +118,8 @@ def handle_register(form):
return user
elif user is None:
return
elif form.first_name.data != "":
abort(500)
user.password = make_flask_login_password(form.password.data)

View File

@@ -20,6 +20,7 @@ from app.models import Package, APIToken, Permission, PackageState
def get_packages_for_vcs_and_token(token: APIToken, repo_url: str) -> list[Package]:
repo_url = repo_url.replace("https://", "").replace("http://", "").lower()
if token.package:
packages = [token.package]
if not token.package.check_perm(token.owner, Permission.APPROVE_RELEASE):

View File

@@ -38,7 +38,7 @@ def webhook_impl():
if token is None:
return error(403, "Invalid authentication")
packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"].replace("https://", "").replace("http://", ""))
packages = get_packages_for_vcs_and_token(token, json["project"]["web_url"])
for package in packages:
#
# Check event

View File

@@ -16,7 +16,7 @@
import datetime
from .models import User, UserRank, MinetestRelease, Tag, License, Notification, NotificationType, Package, \
from .models import User, UserRank, LuantiRelease, Tag, License, Notification, NotificationType, Package, \
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
from .utils import make_flask_login_password
@@ -35,12 +35,12 @@ def populate(session):
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))
session.add(LuantiRelease("None", 0))
session.add(LuantiRelease("0.4.16/17", 32))
session.add(LuantiRelease("5.0", 37))
session.add(LuantiRelease("5.1", 38))
session.add(LuantiRelease("5.2", 39))
session.add(LuantiRelease("5.3", 39))
tags = {}
for tag in ["Inventory", "Mapgen", "Building",
@@ -69,8 +69,8 @@ def populate_test_data(session):
licenses = { x.name : x for x in License.query.all() }
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()
v51 = MinetestRelease.query.filter_by(protocol=38).first()
v4 = LuantiRelease.query.filter_by(protocol=32).first()
v51 = LuantiRelease.query.filter_by(protocol=38).first()
ez = User("Shara")
ez.github_username = "Ezhh"

View File

@@ -10,8 +10,8 @@ as it was submitted as university coursework. To learn about the history and dev
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://github.com/luanti-org/contentdb/" class="btn btn-primary me-1">Source code</a>
<a href="https://github.com/luanti-org/contentdb/issues/" class="btn btn-secondary me-1">Issue tracker</a>
<a href="{{ admin_contact_url }}" class="btn btn-secondary me-1">Contact admin</a>
{% if monitoring_url -%}
<a href="{{ monitoring_url }}" class="btn btn-secondary">Stats / monitoring</a>

View File

@@ -3,7 +3,7 @@ title: API
## Resources
* [How the Luanti client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
* [How the Luanti client uses the API](https://github.com/luanti-org/contentdb/blob/master/docs/luanti_client.md)
## Responses and Error Handling
@@ -131,7 +131,7 @@ curl -X DELETE https://content.luanti.org/api/delete-token/ \
* `<neutral>` with a thumbs up icon.
* `<thumbsdown>` with a thumbs up icon.
* GET `/api/packages/<author>/<name>/hypertext/`
* Converts the long description to [Luanti Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
* Converts the long description to [Luanti Markup Language](https://github.com/luanti-org/luanti/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.
@@ -577,7 +577,7 @@ Supported query parameters:
* 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 [Luanti Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
* Converts HTML or Markdown to [Luanti Markup Language](https://github.com/luanti-org/luanti/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`.

View File

@@ -68,7 +68,7 @@ can also translate your ContentDB page. See Edit Package > Translation for more
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
{{ _("Translation - Luanti Modding Book") }}
</a>
<a class="btn btn-primary" href="https://api.minetest.net/translations/#translating-content-meta">
<a class="btn btn-primary" href="https://api.luanti.org/translations/#translating-content-meta">
{{ _("Translating content meta - lua_api.md") }}
</a>
</p>

View File

@@ -141,7 +141,7 @@ permanent bans.
## Where can I get help?
[Join](https://www.minetest.net/get-involved/) IRC, Matrix, or Discord to ask for help.
[Join](https://www.luanti.org/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

@@ -33,4 +33,4 @@ downloaded from that IP.
You can see all scores using the [scores REST API](/api/scores/), or by
using the [Prometheus metrics](/help/metrics/) endpoint.
Consider [suggesting improvements](https://github.com/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).
Consider [suggesting improvements](https://github.com/luanti-org/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).

View File

@@ -1,22 +1,5 @@
title: Package Inclusion Policy and Guidance
## 0. Overview
ContentDB is for the community, and as such listings should be useful to the
community. To help with this, there are a few rules to improve the quality of
the listings and to combat abuse.
* **No inappropriate content.** <sup>2.1</sup>
* **Content must be playable/useful, but not necessarily finished.** <sup>2.2</sup>
* **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>
## 1. General
@@ -26,35 +9,53 @@ including ones not covered by this document, and to ban users who abuse this ser
## 2. Accepted Content
### 2.1. Acceptable Content
### 2.1. Mature Content
Sexually-orientated content is not permitted.
If in doubt at what this means, [contact us by raising a report](/report/).
See the [Terms of Service](/terms/) for a full list of prohibited content.
Content which depicts or encourages the use of illegal drugs (under the laws of the United Kingdom) is not permitted.
Other mature content is permitted providing that it is labelled with the applicable
[content warning](/help/content_flags/).
Mature content is permitted providing that it is labelled correctly.
See [Content Flags](/help/content_flags/).
### 2.2. Useful Content / State of Completion
### 2.2. State of Completion
ContentDB is for playable and useful content - content which is sufficiently
complete to be useful to end-users.
ContentDB should only currently contain playable content - content which is
sufficiently complete to be useful to end-users. It's fine to add stuff which is
still a Work in Progress (WIP) as long as it adds sufficient 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.
It's fine to add stuff which is still a Work in Progress (WIP) as long as it
adds sufficient value. You must make sure to mark Work in Progress stuff as
such in the "maintenance status" dropdown, 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.
fine and encouraged. ContentDB isn't just for player-facing things and adding
libraries allows Luanti to automatically install dependencies.
### 2.3. Language
We require packages to be in English with (optional) client-side translations for
other languages. This is because Luanti currently requires English as the base language
([Issue to change this](https://github.com/luanti-org/luanti/issues/6503)).
Your package's title and short description must be in English. You can use client-side
translations to [translate content meta](https://api.luanti.org/translations/#translating-content-meta).
### 2.4. Attempt to contribute before forking
You should attempt to contribute upstream before forking a package. If you choose
to fork, you should have a justification (different objectives, maintainer is unavailable, etc).
You should use a different title and make it clear in the long description what the
benefit of your fork is over the original package.
### 2.5. Copyright and trademarks
Your package must not violate copyright or trademarks. You should avoid the use of
trademarks in the package's title or short description. If you do use a trademark,
ensure that you phrase it in a way that does not imply official association or
endorsement.
## 3. Technical Names
### 3.1 Right to a name
### 3.1. Right to a Name
A package uses a name when it has that name or contains a mod that uses that name.
@@ -72,23 +73,46 @@ to change the name of the package, or your package won't be accepted.
We reserve the right to issue exceptions for this where we feel necessary.
### 3.2. Mod Forks and Reimplementations
### 3.2. Forks and Reimplementations
An exception to the above is that mods are allowed to have the same name as a
mod if it's a fork of that mod (or a close reimplementation). In real terms, it
should be possible to use the new mod as a drop-in replacement.
must be possible to use the new mod as a drop-in replacement.
We reserve the right to decide whether a mod counts as a fork or
reimplementation of the mod that owns the name.
### 3.3. Game Mod Namespacing
New mods introduced by a game must have a unique common prefix to avoid conflicts with
other games and standalone mods. For example, the NodeCore game's first-party mods all
start with `nc_`: `nc_api`, `nc_doors`.
You may include existing or standard mods in your game without renaming them to use the
namespace. For example, NodeCore could include the `awards` mod without needing to rename it.
Standalone mods may not use a game's namespace unless they have been given permission by
the game's author.
The exception given by 3.2 also applies to game namespaces - you may use another game's
prefix if your game is a fork.
## 4. Licenses
### 4.1. Allowed Licenses
### 4.1. License file
You must have a LICENSE, LICENSE.txt, or LICENSE.md file describing the licensing of your package.
Please ensure that you correctly credit any resources (code, assets, or otherwise)
that you have used in your package. For help on doing copyright correctly, see
the [Copyright help page](/help/copyright/).
that you have used in your package.
You may use lowercase or include a suffix in the filename (ie: `license-code.txt`). If
you are making a game or modpack, your top level license file may just be a summary or
refer to the license files of individual components.
For help on doing copyright correctly, see the [Copyright help page](/help/copyright/).
### 4.2. Allowed Licenses
**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.
@@ -98,13 +122,13 @@ 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. We tend to reject custom/untested licenses, and
reserve the right to decide whether a license should be included.
get around to adding it. We 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).
### 4.2. Recommended Licenses
### 4.3. Recommended Licenses
It is highly recommended that you use a Free and Open Source software (FOSS)
license. FOSS licenses result in a sharing community and will increase the
@@ -152,10 +176,14 @@ 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.
1. We require all packages to have at least one screenshot. For packages without visual
content, we recommend making a symbolic image with icons, graphics, or text to depict
the package.
2. **Screenshots must depict the actual content of the package in some way, and
2. **Screenshots must not violate copyright.** This means don't just copy images
from Google search, see [the copyright guide](/help/copyright/).
3. **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
@@ -171,20 +199,9 @@ Doing so may result in temporary or permanent suspension from ContentDB.
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
4. **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

View File

@@ -2,7 +2,7 @@ title: Privacy Policy
---
Last Updated: 2024-04-30
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
([View updates](https://github.com/luanti-org/contentdb/commits/master/app/flatpages/privacy_policy.md))
## What Information is Collected

View File

@@ -96,7 +96,7 @@ def _get_approval_statistics(entries: list[AuditLogEntry], start_date: Optional[
(end_date is None or entry.created_at <= end_date)))
info.is_in_range = info.is_in_range or is_in_range
new_state = get_state(entry.title)
new_state = get_state(entry.title.replace("", "") + (entry.description or ""))
if new_state == info.state:
continue

View File

@@ -174,12 +174,6 @@ class GameSupport:
def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
if package.id_ in visited:
# first_idx = visited.index(package.id_)
# visited = visited[first_idx:]
# err = f"Dependency cycle detected: {' -> '.join(visited)} -> {package.id_}"
# for id_ in visited:
# package2 = self.get(id_)
# package2.add_error(err)
return None
if package.type == PackageType.GAME:

View File

@@ -46,6 +46,9 @@ class PackageValidationNote:
self.buttons.append((url, label))
return self
def __repr__(self):
return str(self.message)
def is_package_name_taken(normalised_name: str) -> bool:
return Package.query.filter(
@@ -107,8 +110,7 @@ def validate_package_for_approval(package: Package) -> List[PackageValidationNot
# Don't bother validating any more until we have a release
return retval
if (package.type == PackageType.GAME or package.type == PackageType.TXP) and \
package.screenshots.count() == 0:
if package.screenshots.count() == 0:
danger(lazy_gettext("You need to add at least one screenshot."))
missing_deps = package.get_missing_hard_dependencies_query().all()

View File

@@ -69,6 +69,19 @@ ALLOWED_FIELDS = {
"translation_url": str,
}
NULLABLE = {
"tags",
"content_warnings",
"repo",
"website",
"issue_tracker",
"issueTracker",
"forums",
"video_url",
"donate_url",
"translation_url",
}
ALIASES = {
"short_description": "short_desc",
"issue_tracker": "issueTracker",
@@ -86,11 +99,13 @@ def is_int(val):
def validate(data: dict):
for key, value in data.items():
if value is not None:
if value is None:
check(key in NULLABLE, f"{key} must not be null")
else:
typ = ALLOWED_FIELDS.get(key)
check(typ is not None, key + " is not a known field")
check(typ is not None, f"{key} is not a known field")
if typ != AnyType:
check(isinstance(value, typ), key + " must be a " + typ.__name__)
check(isinstance(value, typ), f"{key} must be a " + typ.__name__)
if "name" in data:
name = data["name"]
@@ -102,8 +117,8 @@ def validate(data: dict):
value = data.get(key)
if value is not None:
check(value.startswith("http://") or value.startswith("https://"),
key + " must start with http:// or https://")
check(validators.url(value), key + " must be a valid URL")
f"{key} must start with http:// or https://")
check(validators.url(value), f"{key} must be a valid URL")
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
@@ -121,6 +136,9 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
for alias, to in ALIASES.items():
if alias in data:
if to in data and data[to] != data[alias]:
raise LogicError(403, f"Aliased field ({alias}) does not match new field ({to})")
data[to] = data[alias]
validate(data)
@@ -169,7 +187,6 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
package.provides.append(m)
if "tags" in data:
old_tags = list(package.tags)
package.tags.clear()
for tag_id in (data["tags"] or []):
if is_int(tag_id):
@@ -193,7 +210,10 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
package.content_warnings.append(warning)
was_modified = was_new
if not was_new:
if was_new:
msg = f"Created package {package.author.username}/{package.name}"
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package)
else:
after_dict = package.as_dict("/")
diff = diff_dictionaries(before_dict, after_dict)
was_modified = len(diff) > 0

View File

@@ -23,7 +23,7 @@ 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.models import PackageRelease, db, Permission, User, Package, LuantiRelease
from app.tasks.importtasks import make_vcs_release, check_zip_release
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none, normalize_line_endings
@@ -42,7 +42,7 @@ def check_can_create_release(user: User, package: Package, name: str):
def do_create_vcs_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], ref: str,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None):
check_can_create_release(user, package, name)
rel = PackageRelease()
@@ -70,7 +70,7 @@ def do_create_vcs_release(user: User, package: Package, name: str, title: Option
def do_create_zip_release(user: User, package: Package, name: str, title: Optional[str], release_notes: Optional[str], file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None,
min_v: LuantiRelease = None, max_v: LuantiRelease = None, reason: str = None,
commit_hash: str = None):
check_can_create_release(user, package, name)

View File

@@ -17,7 +17,7 @@
import imghdr
import os
from flask_babel import lazy_gettext
from flask_babel import lazy_gettext, LazyString
from app import app
from app.logic.LogicError import LogicError
@@ -35,7 +35,7 @@ def is_allowed_image(data):
return imghdr.what(None, data) in ALLOWED_IMAGES
def upload_file(file, file_type, file_type_desc):
def upload_file(file, file_type: str, file_type_desc: LazyString | str, length: int=10):
if not file or file is None or file.filename == "":
raise LogicError(400, "Expected file")
@@ -62,7 +62,7 @@ def upload_file(file, file_type, file_type_desc):
file.stream.seek(0)
filename = random_string(10) + "." + ext
filename = random_string(length) + "." + ext
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
file.save(filepath)

View File

@@ -15,7 +15,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Sequence
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from jinja2.utils import markupsafe
from markdown_it import MarkdownIt
@@ -58,7 +57,7 @@ def render_code(self, tokens: Sequence[Token], idx, options, env):
gfm_like.make()
md = MarkdownIt("gfm-like", {"highlight": highlight_code})
md.use(anchors_plugin, permalink=True, permalinkSymbol="#", max_level=6)
md.use(anchors_plugin, permalink=True, permalinkSymbol="🔗", max_level=6)
md.add_render_rule("fence", render_code)
init_mention(md)
@@ -107,7 +106,7 @@ def get_user_mentions(html: str) -> set:
return set([x.get("data-username") for x in links])
def get_links(html: str, url: str) -> set:
def get_links(html: str) -> set:
soup = BeautifulSoup(html, "html.parser")
links = soup.select("a[href]")
return set([urljoin(url, x.get("href")) for x in links])
return set([x.get("href") for x in links])

View File

@@ -13,8 +13,7 @@
#
# 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 LazyString
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy_searchable import make_searchable
@@ -125,13 +124,115 @@ class AuditLogEntry(db.Model):
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)
return (self.package and user in self.package.maintainers) or 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))
class ReportCategory(enum.Enum):
ACCOUNT_DELETION = "account_deletion"
COPYRIGHT = "copyright"
USER_CONDUCT = "user_conduct"
SPAM = "spam"
ILLEGAL_HARMFUL = "illegal_harmful"
REVIEW = "review"
APPEAL = "appeal"
OTHER = "other"
def __str__(self):
return self.name
@property
def title(self) -> LazyString:
if self == ReportCategory.ACCOUNT_DELETION:
return lazy_gettext("Account deletion")
elif self == ReportCategory.COPYRIGHT:
return lazy_gettext("Copyright infringement / DMCA")
elif self == ReportCategory.USER_CONDUCT:
return lazy_gettext("User behaviour, bullying, or abuse")
elif self == ReportCategory.SPAM:
return lazy_gettext("Spam")
elif self == ReportCategory.ILLEGAL_HARMFUL:
return lazy_gettext("Illegal or harmful content")
elif self == ReportCategory.REVIEW:
return lazy_gettext("Outdated/invalid review")
elif self == ReportCategory.APPEAL:
return lazy_gettext("Appeal")
elif self == ReportCategory.OTHER:
return lazy_gettext("Other")
else:
raise Exception("Unknown report category")
@classmethod
def get(cls, name):
try:
return ReportCategory[name.upper()]
except KeyError:
return None
@classmethod
def choices(cls, with_none):
ret = [(choice, choice.title) for choice in cls]
if with_none:
ret.insert(0, (None, ""))
return ret
@classmethod
def coerce(cls, item):
if item is None or (isinstance(item, str) and item.upper() == "NONE"):
return None
return item if type(item) == ReportCategory else ReportCategory[item.upper()]
class Report(db.Model):
id = db.Column(db.String(24), primary_key=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="reports")
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True)
thread = db.relationship("Thread", foreign_keys=[thread_id])
category = db.Column(db.Enum(ReportCategory), nullable=False)
url = db.Column(db.String, nullable=True)
title = db.Column(db.Unicode(300), nullable=False)
message = db.Column(db.UnicodeText, nullable=False)
is_resolved = db.Column(db.Boolean, nullable=False, default=False)
attachments = db.relationship("ReportAttachment", back_populates="report", lazy="dynamic", cascade="all, delete, delete-orphan")
def check_perm(self, user, perm):
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Report.check_perm()")
if not user.is_authenticated:
return False
if perm == Permission.SEE_REPORT:
return user.rank.at_least(UserRank.EDITOR)
else:
raise Exception("Permission {} is not related to reports".format(perm.name))
class ReportAttachment(db.Model):
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
report_id = db.Column(db.String(24), db.ForeignKey("report.id"), nullable=False)
report = db.relationship("Report", foreign_keys=[report_id], back_populates="attachments")
url = db.Column(db.String(100), nullable=False)
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
"minetest.net", "dropboxusercontent.com", "4shared.com",
"minetest.net", "luanti.org", "dropboxusercontent.com", "4shared.com",
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",
"imageshack.com", "imgur.com"]

View File

@@ -1043,7 +1043,7 @@ class Tag(db.Model):
}
class MinetestRelease(db.Model):
class LuantiRelease(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
protocol = db.Column(db.Integer, nullable=False, default=0)
@@ -1067,11 +1067,11 @@ class MinetestRelease(db.Model):
}
@classmethod
def get(cls, version: typing.Optional[str], protocol_num: typing.Optional[str]) -> typing.Optional["MinetestRelease"]:
def get(cls, version: typing.Optional[str], protocol_num: typing.Optional[str]) -> typing.Optional["LuantiRelease"]:
if version:
parts = version.strip().split(".")
if len(parts) >= 2:
query = MinetestRelease.query.filter(func.replace(MinetestRelease.name, "-dev", "") == "{}.{}".format(parts[0], parts[1]))
query = LuantiRelease.query.filter(func.replace(LuantiRelease.name, "-dev", "") == "{}.{}".format(parts[0], parts[1]))
if protocol_num:
query = query.filter_by(protocol=protocol_num)
@@ -1081,9 +1081,9 @@ class MinetestRelease(db.Model):
if protocol_num:
# Find the closest matching release
return MinetestRelease.query.order_by(db.desc(MinetestRelease.protocol),
db.desc(MinetestRelease.id)) \
.filter(MinetestRelease.protocol <= protocol_num).first()
return LuantiRelease.query.order_by(db.desc(LuantiRelease.protocol),
db.desc(LuantiRelease.id)) \
.filter(LuantiRelease.protocol <= protocol_num).first()
return None
@@ -1103,6 +1103,7 @@ class PackageRelease(db.Model):
commit_hash = db.Column(db.String(41), nullable=True, default=None)
downloads = db.Column(db.Integer, nullable=False, default=0)
release_notes = db.Column(db.UnicodeText, nullable=True, default=None)
file_size_bytes = db.Column(db.Integer, nullable=False, default=0)
@property
def summary(self) -> str:
@@ -1113,11 +1114,11 @@ class PackageRelease(db.Model):
return self.release_notes.split("\n")[0]
min_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
min_rel = db.relationship("MinetestRelease", foreign_keys=[min_rel_id])
min_rel_id = db.Column(db.Integer, db.ForeignKey("luanti_release.id"), nullable=True, server_default=None)
min_rel = db.relationship("LuantiRelease", foreign_keys=[min_rel_id])
max_rel_id = db.Column(db.Integer, db.ForeignKey("minetest_release.id"), nullable=True, server_default=None)
max_rel = db.relationship("MinetestRelease", foreign_keys=[max_rel_id])
max_rel_id = db.Column(db.Integer, db.ForeignKey("luanti_release.id"), nullable=True, server_default=None)
max_rel = db.relationship("LuantiRelease", foreign_keys=[max_rel_id])
# If the release is approved, then the task_id must be null and the url must be present
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
@@ -1126,14 +1127,14 @@ class PackageRelease(db.Model):
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
@property
def file_size_bytes(self):
def calculate_file_size_bytes(self):
path = self.file_path
if not os.path.isfile(path):
return 0
self.file_size_bytes = 0
return
file_stats = os.stat(path)
return file_stats.st_size
self.file_size_bytes = file_stats.st_size
@property
def file_size(self):
@@ -1263,6 +1264,8 @@ class PackageScreenshot(db.Model):
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
file_size_bytes = db.Column(db.Integer, nullable=False, default=0)
def is_very_small(self):
return self.width < 720 or self.height < 405
@@ -1276,14 +1279,14 @@ class PackageScreenshot(db.Model):
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
@property
def file_size_bytes(self):
def calculate_file_size_bytes(self):
path = self.file_path
if not os.path.isfile(path):
return 0
self.file_size_bytes = 0
return
file_stats = os.stat(path)
return file_stats.st_size
self.file_size_bytes = file_stats.st_size
@property
def file_size(self):
@@ -1368,6 +1371,8 @@ class PackageUpdateConfig(db.Model):
# Set to now when an outdated notification is sent. Set to None when a release is created
outdated_at = db.Column(db.DateTime, nullable=True, default=None)
last_checked_at = db.Column(db.DateTime, nullable=True, default=None)
trigger = db.Column(db.Enum(PackageUpdateTrigger), nullable=False, default=PackageUpdateTrigger.COMMIT)
ref = db.Column(db.String(41), nullable=True, default=None)
@@ -1438,7 +1443,7 @@ class PackageDailyStats(db.Model):
reason_update = db.Column(db.Integer, nullable=False, default=0)
@staticmethod
def update(package: Package, is_minetest: bool, reason: str):
def update(package: Package, is_luanti: bool, reason: str):
date = datetime.datetime.utcnow().date()
to_update = dict()
@@ -1446,7 +1451,7 @@ class PackageDailyStats(db.Model):
"package_id": package.id, "date": date
}
field_platform = "platform_minetest" if is_minetest else "platform_other"
field_platform = "platform_minetest" if is_luanti else "platform_other"
to_update[field_platform] = getattr(PackageDailyStats, field_platform) + 1
kwargs[field_platform] = 1

View File

@@ -57,6 +57,8 @@ class Thread(db.Model):
watchers = db.relationship("User", secondary=watchers, backref="watching")
report = db.relationship("Report", foreign_keys="Report.thread_id", back_populates="thread", lazy="dynamic")
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")

View File

@@ -96,6 +96,7 @@ class Permission(enum.Enum):
CHANGE_USERNAMES = "CHANGE_USERNAMES"
CHANGE_RANK = "CHANGE_RANK"
CHANGE_EMAIL = "CHANGE_EMAIL"
LINK_TO_WEBSITE = "LINK_TO_WEBSITE"
SEE_THREAD = "SEE_THREAD"
CREATE_THREAD = "CREATE_THREAD"
COMMENT_THREAD = "COMMENT_THREAD"
@@ -114,6 +115,7 @@ class Permission(enum.Enum):
EDIT_COLLECTION = "EDIT_COLLECTION"
VIEW_COLLECTION = "VIEW_COLLECTION"
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
SEE_REPORT = "SEE_REPORT"
# Only return true if the permission is valid for *all* contexts
# See Package.check_perm for package-specific contexts
@@ -210,6 +212,7 @@ class User(db.Model, UserMixin):
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")
reports = db.relationship("Report", back_populates="user", lazy="dynamic", cascade="all")
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
@@ -287,6 +290,8 @@ class User(db.Model, UserMixin):
return user.rank.at_least(UserRank.NEW_MEMBER)
else:
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank)
elif perm == Permission.LINK_TO_WEBSITE:
return user.rank.at_least(UserRank.MEMBER)
else:
raise Exception("Permission {} is not related to users".format(perm.name))

119
app/public/funding.json Normal file
View File

@@ -0,0 +1,119 @@
{
"version": "v1.0.0",
"entity": {
"type": "organisation",
"role": "maintainer",
"name": "Luanti",
"email": "rw@rubenwardy.com",
"description": "Luanti (formerly Minetest) is an open-source voxel game creation platform",
"webpageUrl": {
"url": "https://www.luanti.org"
}
},
"projects": [
{
"guid": "luanti",
"name": "Luanti",
"description": "Luanti (formerly Minetest) is an open-source voxel game creation platform",
"webpageUrl": {
"url": "https://www.luanti.org"
},
"repositoryUrl": {
"url": "https://github.com/luanti-org/luanti"
},
"licenses": [
"spdx:LGPL-2.1",
"spdx:CC-BY-SA-3.0",
"spdx:MIT",
"spdx:Apache-2.0"
],
"tags": [
"lua",
"voxel",
"game"
]
},
{
"guid": "contentdb",
"name": "Luanti ContentDB",
"description": "A content database for Luanti mods, games, and more.",
"webpageUrl": {
"url": "https://content.luanti.org/about/"
},
"repositoryUrl": {
"url": "https://github.com/luanti-org/contentdb"
},
"licenses": [
"spdx:AGPL-3.0",
"spdx:CC-BY-SA-4.0"
],
"tags": [
"python",
"flask",
"luanti",
"minetest"
]
}
],
"funding": {
"channels": [
{
"guid": "open-collective",
"type": "other",
"address": "https://opencollective.com/luanti",
"description": "Recurring and one-time donations to Luanti"
}
],
"plans": [
{
"guid": "oc-eur-backer",
"status": "active",
"name": "Luanti backer",
"description": "Become a backer for €5 per month and help Luanti development",
"amount": 5,
"currency": "EUR",
"frequency": "monthly",
"channels": [
"open-collective"
]
},
{
"guid": "oc-eur-supporter",
"status": "active",
"name": "Luanti supporter",
"description": "Become a supporter for €5 per month and help Luanti development",
"amount": 100,
"currency": "EUR",
"frequency": "monthly",
"channels": [
"open-collective"
]
},
{
"guid": "oc-eur-custom",
"status": "active",
"name": "Luanti custom one-off",
"description": "You may donate any amount you're comfortable with",
"amount": 0,
"currency": "EUR",
"frequency": "one-time",
"channels": [
"open-collective"
]
},
{
"guid": "fosdem",
"status": "active",
"name": "FOSDEM",
"description": "It costs us €3000 to attend FOSDEM",
"amount": 3000,
"currency": "EUR",
"frequency": "one-time",
"channels": [
"open-collective"
]
}
],
"history": []
}
}

View File

@@ -68,8 +68,9 @@ window.addEventListener("load", () => {
}
setupHints("short_desc", {
"short_desc_mods": (val) => val.indexOf("minetest") >= 0 || val.indexOf("mod") >= 0 ||
val.indexOf("modpack") >= 0 || val.indexOf("mod pack") >= 0,
"short_desc_mods": (val) => val.indexOf("luanti") >= 0 || val.indexOf("minetest") >= 0 ||
val.indexOf("mod") >= 0 || val.indexOf("modpack") >= 0 ||
val.indexOf("mod pack") >= 0,
});
setupHints("desc", {
@@ -85,7 +86,8 @@ window.addEventListener("load", () => {
"desc_page_topic": (val) => {
const topicId = document.getElementById("forums").value;
const r = new RegExp(`forum\\.minetest\\.net\\/viewtopic\\.php\\?[a-z0-9=&]*t=${topicId}`);
return topicId && r.test(val);
const r2 = new RegExp(`forum\\.luanti\\.org\\/viewtopic\\.php\\?[a-z0-9=&]*t=${topicId}`);
return topicId && (r.test(val) || r2.test(val));
},
"desc_page_repo": (val) => {
const repoUrl = document.getElementById("repo").value.replace(".git", "");

View File

@@ -84,7 +84,7 @@ window.addEventListener("load", () => {
bar.setAttribute("aria-valuenow", current);
bar.setAttribute("aria-valuemax", total);
const packages = running.map(x => `${x.author}/${x.name}`).join(", ");
const packages = (running ?? []).map(x => `${x.author}/${x.name}`).join(", ");
document.getElementById("status").textContent = `Status: in progress (${current} / ${total})\n\n${packages}`;
} else {
progress.classList.add("d-none");
@@ -98,6 +98,9 @@ window.addEventListener("load", () => {
pollTask(`/tasks/${taskId}/`, true, onProgress)
.then(function() { location.reload() })
.catch(function() { location.reload() })
.catch(function(e) {
console.error(e);
location.reload();
});
}
});

View File

@@ -22,7 +22,7 @@ from sqlalchemy.orm import subqueryload
from sqlalchemy.sql.expression import func
from sqlalchemy_searchable import search
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, \
from .models import db, PackageType, Package, ForumTopic, License, LuantiRelease, PackageRelease, User, Tag, \
ContentWarning, PackageState, PackageDevState
from .utils import is_yes, get_int_or_abort
@@ -49,7 +49,7 @@ class QueryBuilder:
hide_wip: bool
hide_nonfree: bool
show_added: bool
version: Optional[MinetestRelease]
version: Optional[LuantiRelease]
has_lang: Optional[str]
@property
@@ -163,12 +163,12 @@ class QueryBuilder:
self.author = args.get("author")
protocol_version = get_int_or_abort(args.get("protocol_version"))
minetest_version = args.get("engine_version")
if minetest_version == "":
minetest_version = None
engine_version = args.get("engine_version")
if engine_version == "":
engine_version = None
if protocol_version or minetest_version:
self.version = MinetestRelease.get(minetest_version, protocol_version)
if protocol_version or engine_version:
self.version = LuantiRelease.get(engine_version, protocol_version)
else:
self.version = None

View File

@@ -283,3 +283,7 @@ blockquote {
.form-group {
margin-bottom: 1rem !important;
}
input[name="first_name"] {
display: none;
}

View File

@@ -57,12 +57,19 @@ h1, h2, h3, h4, h5, h6 {
content: "";
clear: both;
}
.header-anchor {
transition: opacity 0.15s ease-in-out;
opacity: 0.25;
margin: 0 0 0 0.25em;
font-size: 60%;
}
&:hover .header-anchor {
opacity: 0.9;
}
}
.header-anchor {
float: right;
opacity: 0.8;
}
.badge-notify {
background:yellow; /* #00bc8c;*/

View File

@@ -92,3 +92,12 @@
max-height: 1em;
filter: none !important;
}
.release-notes-body {
max-height: 20em;
overflow: hidden auto;
> *:first-child {
margin-top: 0;
}
}

View File

@@ -31,13 +31,13 @@ from sqlalchemy import and_
from sqlalchemy.dialects.postgresql import insert
from app.models import AuditSeverity, db, NotificationType, PackageRelease, MetaPackage, Dependency, PackageType, \
MinetestRelease, Package, PackageState, PackageScreenshot, PackageUpdateTrigger, PackageUpdateConfig, \
LuantiRelease, Package, PackageState, PackageScreenshot, PackageUpdateTrigger, PackageUpdateConfig, \
PackageGameSupport, PackageTranslation, Language
from app.tasks import celery, TaskError
from app.utils import random_string, post_bot_message, add_system_notification, add_system_audit_log, \
get_games_from_list, add_audit_log
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir, get_release_notes
from .minetestcheck import build_tree, MinetestCheckError, ContentType, PackageTreeNode
from .luanticheck import build_tree, LuantiCheckError, ContentType, PackageTreeNode
from .webhooktasks import post_discord_webhook
from app import app
from app.logic.LogicError import LogicError
@@ -51,7 +51,7 @@ def get_meta(urlstr, author):
with clone_repo(urlstr, recursive=True) as repo:
try:
tree = build_tree(repo.working_tree_dir, author=author, repo=urlstr)
except MinetestCheckError as err:
except LuantiCheckError as err:
raise TaskError(str(err))
result = {"name": tree.name, "type": tree.type.name}
@@ -71,8 +71,6 @@ def get_meta(urlstr, author):
data = json.loads(f.read())
for key, value in data.items():
result[key] = value
except LogicError as e:
raise TaskError(e.message)
except JSONDecodeError as e:
raise TaskError("Whilst reading .cdb.json: " + str(e))
except IOError:
@@ -115,7 +113,9 @@ def post_release_check_update(self, release: PackageRelease, path):
author=release.package.author.username, name=release.package.name)
if tree.name is not None and release.package.name != tree.name and tree.type == ContentType.MOD:
raise MinetestCheckError(f"Expected {tree.relative} to have technical name {release.package.name}, instead has name {tree.name}")
raise LuantiCheckError(f"Package name ({release.package.name}) does not match the name of the content in "
f"the release ({tree.name}). Either change the package name on ContentDB or the "
f"name in the .conf of the content. Then make a new release")
cache = {}
def get_meta_packages(names):
@@ -124,6 +124,9 @@ def post_release_check_update(self, release: PackageRelease, path):
provides = tree.get_mod_names()
package = release.package
if not package.approved:
tree.check_for_legacy_files()
old_provided_names = set([x.name for x in package.provides])
package.provides.clear()
@@ -164,10 +167,10 @@ def post_release_check_update(self, release: PackageRelease, path):
# Raise error on unresolved game dependencies
if package.type == PackageType.GAME and len(depends) > 0:
deps = ", ".join(depends)
raise MinetestCheckError("Game has unresolved hard dependencies: " + deps)
raise LuantiCheckError("Game has unresolved hard dependencies: " + deps)
if package.state != PackageState.APPROVED and tree.find_license_file() is None:
raise MinetestCheckError(
raise LuantiCheckError(
"You need to add a LICENSE.txt/.md or COPYING file to your package. See the 'Copyright Guide' for more info")
# Add dependencies
@@ -182,17 +185,17 @@ def post_release_check_update(self, release: PackageRelease, path):
# Update min/max
if tree.meta.get("min_minetest_version"):
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
release.min_rel = LuantiRelease.get(tree.meta["min_minetest_version"], None)
if tree.meta.get("max_minetest_version"):
release.max_rel = MinetestRelease.get(tree.meta["max_minetest_version"], None)
release.max_rel = LuantiRelease.get(tree.meta["max_minetest_version"], None)
try:
with open(os.path.join(tree.baseDir, ".cdb.json"), "r") as f:
data = json.loads(f.read())
do_edit_package(package.author, package, False, False, data, "Post release hook")
except LogicError as e:
raise TaskError(e.message)
raise TaskError("Whilst applying .cdb.json: " + e.message)
except JSONDecodeError as e:
raise TaskError("Whilst reading .cdb.json: " + str(e))
except IOError:
@@ -231,7 +234,7 @@ def post_release_check_update(self, release: PackageRelease, path):
return tree
except (MinetestCheckError, TaskError, LogicError) as err:
except (LuantiCheckError, TaskError, LogicError) as err:
db.session.rollback()
error_message = err.value if hasattr(err, "value") else str(err)
@@ -334,6 +337,7 @@ def check_zip_release(self, id, path):
post_release_check_update(self, release, temp)
release.task_id = None
release.calculate_file_size_bytes()
release.approve(release.package.author)
db.session.commit()
@@ -375,7 +379,7 @@ def import_languages(self, id, path):
strict=False)
update_translations(release.package, tree)
db.session.commit()
except (MinetestCheckError, TaskError, LogicError) as err:
except (LuantiCheckError, TaskError, LogicError) as err:
db.session.rollback()
task_url = url_for('tasks.check', id=self.request.id)
@@ -413,6 +417,7 @@ def make_vcs_release(self, id, branch):
release.url = "/uploads/" + filename
release.task_id = None
release.calculate_file_size_bytes()
release.approve(release.package.author)
db.session.commit()
@@ -475,13 +480,11 @@ def check_update_config_impl(package):
if config.last_commit == commit:
if tag and config.last_tag != tag:
config.last_tag = tag
db.session.commit()
return
if not config.last_commit:
config.last_commit = commit
config.last_tag = tag
db.session.commit()
return
if package.releases.filter_by(commit_hash=commit).count() > 0:
@@ -500,8 +503,6 @@ def check_update_config_impl(package):
msg = "Created release {} (Git Update Detection)".format(rel.title)
add_system_audit_log(AuditSeverity.NORMAL, msg, package.get_url("packages.view"), package)
db.session.commit()
make_vcs_release.apply_async((rel.id, commit), task_id=rel.task_id)
elif config.outdated_at is None:
@@ -528,10 +529,9 @@ def check_update_config_impl(package):
config.last_commit = commit
config.last_tag = tag
db.session.commit()
@celery.task(bind=True)
@celery.task(bind=True, rate_limit="60/m")
def check_update_config(self, package_id):
package: Package = Package.query.get(package_id)
if package is None:
@@ -542,6 +542,9 @@ def check_update_config(self, package_id):
err = None
try:
check_update_config_impl(package)
package.update_config.last_checked_at = datetime.datetime.now()
db.session.commit()
except GitCommandError as e:
# This is needed to stop the backtrace being weird
err = e.stderr

View File

@@ -17,7 +17,7 @@
from enum import Enum
class MinetestCheckError(Exception):
class LuantiCheckError(Exception):
def __init__(self, value):
self.value = value
@@ -43,14 +43,14 @@ class ContentType(Enum):
if self == ContentType.MOD:
if not other.is_mod_like():
raise MinetestCheckError("Expected a mod or modpack, found " + other.value)
raise LuantiCheckError("Expected a mod or modpack, found " + other.value)
elif self == ContentType.TXP:
if other != ContentType.UNKNOWN and other != ContentType.TXP:
raise MinetestCheckError("expected a " + self.value + ", found a " + other.value)
raise LuantiCheckError("expected a " + self.value + ", found a " + other.value)
elif other != self:
raise MinetestCheckError("Expected a " + self.value + ", found a " + other.value)
raise LuantiCheckError("Expected a " + self.value + ", found a " + other.value)
from .tree import PackageTreeNode, get_base_dir

View File

@@ -20,12 +20,12 @@ import re
import glob
from typing import Optional
from . import MinetestCheckError, ContentType
from . import LuantiCheckError, ContentType
from .config import parse_conf
from .translation import Translation, parse_tr
basenamePattern = re.compile("^([a-z0-9_]+)$")
licensePattern = re.compile("^(licen[sc]e|copying)(.[^/\n]+)?$", re.IGNORECASE)
licensePattern = re.compile("^licen[sc]e[^/.]*(\.(txt|md))?$", re.IGNORECASE)
DISALLOWED_NAMES = {
"core", "minetest", "group", "table", "string", "lua", "luajit", "assert", "debug",
@@ -73,10 +73,10 @@ def check_name_list(key: str, value: list[str], relative: str, allow_star: bool
if dep == "*" and allow_star:
continue
elif " " in dep:
raise MinetestCheckError(
raise LuantiCheckError(
f"Invalid {key} name '{dep}' at {relative}, did you forget a comma?")
else:
raise MinetestCheckError(
raise LuantiCheckError(
f"Invalid {key} name '{dep}' at {relative}, names must only contain a-z0-9_.")
@@ -90,6 +90,8 @@ class PackageTreeNode:
children: list
type: ContentType
strict: bool
has_legacy_depends: bool
has_legacy_description: bool
def __init__(self, base_dir: str, relative: str,
author: Optional[str] = None,
@@ -103,6 +105,8 @@ class PackageTreeNode:
self.meta = {}
self.children = []
self.strict = strict
self.has_legacy_depends = False
self.has_legacy_description = False
# Detect type
self.type = detect_type(base_dir)
@@ -110,14 +114,14 @@ class PackageTreeNode:
if self.type == ContentType.GAME:
if not os.path.isdir(os.path.join(base_dir, "mods")):
raise MinetestCheckError("Game at {} does not have a mods/ folder".format(self.relative))
raise LuantiCheckError("Game at {} does not have a mods/ folder".format(self.relative))
self._add_children_from_mod_dir("mods")
elif self.type == ContentType.MOD:
if self.name and not basenamePattern.match(self.name):
raise MinetestCheckError(f"Invalid base name for mod {self.name} at {self.relative}, names must only contain a-z0-9_.")
raise LuantiCheckError(f"Invalid base name for mod {self.name} at {self.relative}, names must only contain a-z0-9_.")
if self.name and self.name in DISALLOWED_NAMES:
raise MinetestCheckError(f"Forbidden mod name '{self.name}' used at {self.relative}")
raise LuantiCheckError(f"Forbidden mod name '{self.name}' used at {self.relative}")
self._check_dir_casing(["textures", "media", "sounds", "models", "locale"])
elif self.type == ContentType.MODPACK:
@@ -135,7 +139,7 @@ class PackageTreeNode:
for dir in next(os.walk(self.baseDir))[1]:
lowercase = dir.lower()
if lowercase != dir and lowercase in dirs:
raise MinetestCheckError(f"Incorrect case, {dir} should be {lowercase} at {self.relative}{dir}")
raise LuantiCheckError(f"Incorrect case, {dir} should be {lowercase} at {self.relative}{dir}")
def get_readme_path(self):
for filename in os.listdir(self.baseDir):
@@ -169,12 +173,12 @@ class PackageTreeNode:
for key, value in conf.items():
result[key] = value
except SyntaxError as e:
raise MinetestCheckError("Error while reading {}: {}".format(meta_file_rel , e.msg))
raise LuantiCheckError("Error while reading {}: {}".format(meta_file_rel , e.msg))
except IOError:
pass
if self.strict and "release" in result:
raise MinetestCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel))
raise LuantiCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel))
# description.txt
if "description" not in result:
@@ -184,6 +188,11 @@ class PackageTreeNode:
except IOError:
pass
if os.path.isfile(self.baseDir + "/depends.txt"):
self.has_legacy_depends = True
if os.path.isfile(self.baseDir + "/description.txt"):
self.has_legacy_description = True
# Read dependencies
if "depends" in result or "optional_depends" in result:
result["depends"] = get_csv_line(result.get("depends"))
@@ -260,11 +269,11 @@ class PackageTreeNode:
if not entry.startswith('.') and os.path.isdir(path):
child = PackageTreeNode(path, relative + entry + "/", name=entry, strict=self.strict)
if not child.type.is_mod_like():
raise MinetestCheckError("Expecting mod or modpack, found {} at {} inside {}" \
raise LuantiCheckError("Expecting mod or modpack, found {} at {} inside {}" \
.format(child.type.value, child.relative, self.type.value))
if child.name is None:
raise MinetestCheckError("Missing base name for mod at {}".format(self.relative))
raise LuantiCheckError("Missing base name for mod at {}".format(self.relative))
self.children.append(child)
@@ -304,6 +313,16 @@ class PackageTreeNode:
def get(self, key: str, default=None):
return self.meta.get(key, default)
def check_for_legacy_files(self):
if self.has_legacy_depends:
raise LuantiCheckError("Found depends.txt at {}. Delete this file and use depends in mod.conf instead" \
.format(self.relative))
if self.has_legacy_description:
raise LuantiCheckError("Found description.txt at {}. Delete this file and use description in {} instead" \
.format(self.relative, self.get_meta_file_name()))
for child in self.children:
child.check_for_legacy_files()
def validate(self):
for child in self.children:
child.validate()
@@ -329,6 +348,6 @@ class PackageTreeNode:
ret.append(parse_tr(name))
except SyntaxError as e:
relative_path = os.path.join(self.relative, os.path.relpath(name, self.baseDir))
raise MinetestCheckError(f"Syntax error whilst reading {relative_path}: {e}")
raise LuantiCheckError(f"Syntax error whilst reading {relative_path}: {e}")
return ret

View File

@@ -19,7 +19,7 @@ import random
import re
import sys
from time import sleep
from urllib.parse import urlparse
from urllib.parse import urlparse, urljoin
from typing import Optional
import requests
@@ -28,7 +28,7 @@ from app import app
from sqlalchemy import or_, and_
from app.markdown import get_links, render_markdown
from app.models import Package, db, PackageState, AuditLogEntry, AuditSeverity
from app.models import db, Package, PackageState, PackageRelease, PackageScreenshot, AuditLogEntry, AuditSeverity
from app.tasks import celery, TaskError
from app.utils import post_bot_message, post_to_approval_thread, get_system_user, add_audit_log
@@ -131,6 +131,7 @@ def _url_exists(url: str) -> str:
def _check_for_dead_links(package: Package) -> dict[str, str]:
ignored_urls = set(app.config.get("LINK_CHECKER_IGNORED_URLS", ""))
base_url = package.get_url("packages.view", absolute=True)
links: set[Optional[str]] = {
package.repo,
package.website,
@@ -142,7 +143,7 @@ def _check_for_dead_links(package: Package) -> dict[str, str]:
}
if package.desc:
links.update(get_links(render_markdown(package.desc), package.get_url("packages.view", absolute=True)))
links.update(get_links(render_markdown(package.desc)))
print(f"Checking {package.title} ({len(links)} links) for broken links", file=sys.stderr)
@@ -152,14 +153,15 @@ def _check_for_dead_links(package: Package) -> dict[str, str]:
if link is None:
continue
url = urlparse(link)
abs_link = urljoin(base_url, link)
url = urlparse(abs_link)
if url.scheme != "http" and url.scheme != "https":
continue
if url.hostname in ignored_urls:
continue
res = _url_exists(link)
res = _url_exists(abs_link)
if res != "":
bad_urls[link] = res
@@ -209,3 +211,34 @@ def check_package_for_broken_links(package_id: int):
if msg:
post_bot_message(package, "Broken links", msg)
db.session.commit()
@celery.task(bind=True)
def update_file_size_bytes(self):
releases = PackageRelease.query.filter_by(file_size_bytes=0).all()
screenshots = PackageScreenshot.query.filter_by(file_size_bytes=0).all()
total = len(releases) + len(screenshots)
self.update_state(state="PROGRESS", meta={
"current": 0,
"total": total,
})
for i, release in enumerate(releases):
release.calculate_file_size_bytes()
if i % 100 == 0:
self.update_state(state="PROGRESS", meta={
"current": i + 1,
"total": total,
})
for i, ss in enumerate(screenshots):
ss.calculate_file_size_bytes()
if i % 100 == 0:
self.update_state(state="PROGRESS", meta={
"current": i + len(releases) + 1,
"total": total,
})
db.session.commit()

View File

@@ -20,7 +20,7 @@ import os
import sys
from flask import url_for
from sqlalchemy import or_, and_
from sqlalchemy import or_, and_, not_, func
from app import app
from app.models import User, db, UserRank, ThreadReply, Package, NotificationType
@@ -149,3 +149,37 @@ def import_github_user_ids():
db.session.commit()
print(f"Updated {count} users", file=sys.stderr)
@celery.task()
def do_delete_likely_spammers():
query = (User.query.filter(
and_(
User.rank == UserRank.NEW_MEMBER,
or_(
func.replace(User.website_url, ".", "").regexp_match(
func.concat("https?://[^/]*", User.username, ".*")),
),
or_(
User.website_url.ilike("%bet%"),
User.website_url.ilike("%win%"),
User.website_url.ilike("%88%"),
User.website_url.ilike("%luck%"),
User.website_url.ilike("%sport%"),
User.website_url.ilike("%lottery%"),
User.website_url.ilike("%casino%"),
User.website_url.ilike("%vip%"),
User.website_url.ilike("%assignment%"),
),
~User.packages.any(),
~User.replies.any(),
~User.reports.any(),
not_(or_(
User.website_url.ilike("%.github.io%"),
User.website_url.ilike("%.neocities.org%"),
)),
)))
for user in query.all():
db.session.delete(user)
db.session.commit()

View File

@@ -49,7 +49,7 @@ def search_in_releases(self, query: str, file_filter: str, types: List[str]):
handle = Popen(["zipgrep", query, release.file_path, file_filter], stdout=PIPE, encoding="UTF-8")
try:
handle.wait(timeout=15)
handle.wait(timeout=45)
except TimeoutExpired:
print(f"[Zipgrep] Timeout for {package.name}", file=sys.stderr)
handle.kill()

View File

@@ -26,7 +26,7 @@ from . import app, utils
from app.markdown import get_headings
from .models import Permission, Package, PackageState, PackageRelease
from .utils import abs_url_for, url_set_query, url_set_anchor, url_current
from .utils.minetest_hypertext import normalize_whitespace as do_normalize_whitespace
from .utils.luanti_hypertext import normalize_whitespace as do_normalize_whitespace
@app.context_processor

View File

@@ -7,6 +7,14 @@ Audit Log
{% block content %}
<h1>Audit Log</h1>
{% from "macros/forms.html" import render_field, render_submit_field %}
<form method="GET" action="">
{{ render_field(form.username) }}
{{ render_field(form.q) }}
{{ render_field(form.url) }}
{{ render_submit_field(form.submit) }}
</form>
{% from "macros/pagination.html" import render_pagination %}
{% from "macros/audit_log.html" import render_audit_log %}

View File

@@ -13,14 +13,20 @@
<div class="list-group">
<a class="list-group-item list-group-item-action" href="{{ url_for('users.list_all') }}">
<i class="fas fa-users me-2"></i>
User list
{{ _("User list") }}
</a>
{% if current_user.rank.at_least(current_user.rank.MODERATOR) %}
{% if current_user.rank.at_least(current_user.rank.APPROVER) %}
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.audit') }}">
<i class="fas fa-user-clock me-2"></i>
{{ _("Audit Log") }}
</a>
{% endif %}
{% if current_user.rank.at_least(current_user.rank.EDITOR) %}
<a class="list-group-item list-group-item-action" href="{{ url_for('report.list_all') }}">
<i class="fas fa-user-clock me-2"></i>
Reports
</a>
{% endif %}
</div>
<h3>Packages</h3>

View File

@@ -18,16 +18,16 @@
{{ _("Package") }}
</div>
<div class="col-2 text-center">
Latest release / MB
Latest release (MB)
</div>
<div class="col-2 text-center">
Releases / MB
Releases (MB)
</div>
<div class="col-2 text-center">
Screenshots / MB
Screenshots (MB)
</div>
<div class="col-2 text-center">
Total / MB
Total (MB)
</div>
</div>
</div>

View File

@@ -16,7 +16,7 @@
{%- endif %}
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css?v=4">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=55">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=59">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
{% if noindex -%}
@@ -274,7 +274,7 @@
<li class="list-inline-item"><a href="{{ url_for('collections.list_all') }}">{{ _("Collections") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('donate.donate') }}">{{ _("Support Creators") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('translate.translate') }}">{{ _("Translate Packages") }}</a></li>
<li class="list-inline-item"><a href="https://github.com/minetest/contentdb">{{ _("Source Code") }}</a></li>
<li class="list-inline-item"><a href="https://github.com/luanti-org/contentdb">{{ _("Source Code") }}</a></li>
</ul>
<form method="POST" action="{{ url_for('set_nonfree') }}" class="my-3">

View File

@@ -176,11 +176,6 @@
<input class="btn btn-primary" name="btn_submit" type="submit" value="Comment" />
</form>
{% endif %}
{% if thread.private %}
<p class="text-muted card-body my-0 pt-0">
{{ _("You can add someone to a private thread by writing @username.") }}
</p>
{% endif %}
</div>
</div>
</div>

View File

@@ -18,10 +18,10 @@
{{ form_scripts() }}
{{ easymde_scripts() }}
{% if enable_wizard %}
<script src="/static/js/polltask.js?v=3"></script>
<script src="/static/js/polltask.js?v=4"></script>
<script src="/static/js/package_create.js"></script>
{% endif %}
<script src="/static/js/package_edit.js?v=3"></script>
<script src="/static/js/package_edit.js?v=4"></script>
{% endblock %}
{% block content %}
@@ -82,7 +82,7 @@
</div>
{{ render_field(form.short_desc, class_="pkg_meta") }}
<p class="form-text text-warning d-none" id="short_desc_mods">
{{ _("Tip: Don't include <i>Minetest</i>, <i>mod</i>, or <i>modpack</i> anywhere in the short description. It is unnecessary and wastes characters.") }}
{{ _("Tip: Don't include <i>Luanti</i>, <i>Minetest</i>, <i>mod</i>, or <i>modpack</i> anywhere in the short description. It is unnecessary and wastes characters.") }}
</p>
{{ render_field(form.dev_state, class_="pkg_meta", hint=_("Please choose 'Work in Progress' if your package is unstable, and shouldn't be recommended to all players")) }}

View File

@@ -52,7 +52,7 @@
pattern="[A-Za-z0-9/._-]+") }}
{% endif %}
{{ render_field(form.file_upload, fieldclass="form-control-file", class_="mt-3", accept=".zip") }}
{{ render_field(form.file_upload, class_="mt-3", accept=".zip") }}
<p>
{{ _("Take a look at the <a href='/help/package_config/'>Package Configuration and Releases Guide</a> for

View File

@@ -16,7 +16,7 @@
{{ form.hidden_tag() }}
{{ render_field(form.title) }}
{{ render_field(form.file_upload, fieldclass="form-control-file", accept="image/png,image/jpeg,image/webp") }}
{{ render_field(form.file_upload, accept="image/png,image/jpeg,image/webp") }}
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}

View File

@@ -8,7 +8,7 @@
{% set translations = package.translations.all() %}
{% set num = translations | length + 1 %}
<a class="btn btn-secondary float-end" href="https://api.minetest.net/translations/#translating-content-meta">
<a class="btn btn-secondary float-end" href="https://api.luanti.org/translations/#translating-content-meta">
{{ _("Help") }}
</a>
@@ -22,7 +22,7 @@
<a class="btn btn-primary me-2" href="https://rubenwardy.com/minetest_modding_book/en/quality/translations.html">
{{ _("Translation - Luanti Modding Book") }}
</a>
<a class="btn btn-primary" href="https://api.minetest.net/translations/#translating-content-meta">
<a class="btn btn-primary" href="https://api.luanti.org/translations/#translating-content-meta">
{{ _("Translating content meta - lua_api.md") }}
</a>
</p>

View File

@@ -12,7 +12,7 @@
{{ form.hidden_tag() }}
{{ render_field(form.title) }}
{{ render_field(form.file_upload, fieldclass="form-control-file", accept="image/png,image/jpeg,image/webp") }}
{{ render_field(form.file_upload, accept="image/png,image/jpeg,image/webp") }}
{{ render_checkbox_field
{{ render_submit_field(form.submit) }}
</form>

View File

@@ -98,7 +98,7 @@
<h2>{% if review_thread.private %}&#x1f512;{% endif %} {{ review_thread.title }}</h2>
{% if review_thread.private %}
<p><i>
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
{{ _("This thread is only visible to its creator, package maintainers, and users of Approver rank or above.") }}
</i></p>
{% endif %}
@@ -397,7 +397,7 @@
<h3 id="release_notes" class="card-header">
{{ _("Release notes for %(title)s", title=release.title) }}
</h3>
<div class="card-body markdown">
<div class="card-body markdown release-notes-body">
{{ release.release_notes | markdown }}
</div>
</div>

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title -%}
Edit report
{%- endblock %}
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field, easymde_scripts %}
{% block scriptextra %}
{{ easymde_scripts() }}
{% endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ render_field(form.category) }}
{{ render_field(form.url) }}
{{ render_field(form.title) }}
{{ render_field(form.message, class_="m-0", fieldclass="form-control markdown", data_enter_submit="1") }}
{{ render_submit_field(form.submit) }}
</form>
{% endblock %}

View File

@@ -1,50 +0,0 @@
{% extends "base.html" %}
{% block title -%}
{{ _("Report") }}
{%- endblock %}
{% block content %}
<h1>{{ _("Report") }}</h1>
{% if not form %}
<p>
{{ _("Due to spam, we no longer accept reports from anonymous users on this form.") }}
{{ _("Please sign in or contact the admin using the link below.") }}
</p>
<p>
<a href="{{ url_for('users.login') }}" class="btn btn-primary me-2">Login</a>
<a href="{{ admin_contact_url }}" class="btn btn-secondary">Contact the admin</a>
</p>
{% else %}
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %}
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{% if url %}
<p>
URL: <code>{{ url }}</code>
</p>
{% endif %}
{{ render_field(form.message, hint=_("What are you reporting? Why are you reporting it?")) }}
{{ render_submit_field(form.submit) }}
<p class="mt-5 text-muted">
{{ _("Reports will be shared with ContentDB staff.") }}
{% if is_anon %}
{{ _("Only the admin will be able to see who made the report.") }}
{% endif %}
</p>
<p class="alert alert-info">
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
</p>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title -%}
Reports
{%- endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
<nav class="list-group">
{% for report in reports %}
<a class="list-group-item list-group-item-action" href="{{ url_for('report.view', rid=report.id) }}">
<div class="row">
<div class="col">
{% if report.is_resolved %}
<span class="badge bg-secondary me-3">
Closed
</span>
{% else %}
<span class="badge bg-info me-3">
Open
</span>
{% endif %}
{{ report.title }}
{% if report.user %}
by {{ report.user.display_name }}
{% endif %}
</div>
<div class="col-auto">
{{ report.created_at | timedelta }} ago
</div>
</div>
</a>
{% else %}
<span>
No reports.
</span>
{% endfor %}
</nav>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title -%}
{{ _("Report") }}
{%- endblock %}
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field, easymde_scripts %}
{% block scriptextra %}
{{ easymde_scripts() }}
{% endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
{% if not form %}
<p>
{{ _("Due to spam, we no longer accept reports from anonymous users on this form.") }}
{{ _("Please sign in or contact the admin using the link below.") }}
</p>
<p>
<a href="{{ url_for('users.login') }}" class="btn btn-primary me-2">Login</a>
<a href="{{ admin_contact_url }}" class="btn btn-secondary">Contact the admin</a>
</p>
{% else %}
<p class="text-muted">
{{ _("The full report will be visible to all ContentDB staff members, including editors and moderators.") }}
{{ _("If you are reporting something by another user, we may discuss the report with them but will not disclose who reported it.") }}
{{ _("If you are reporting something by an editor or moderator, then you should email the admin or a moderator directly to hide your identity.") }}
</p>
<form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ render_field(form.category) }}
{{ render_field(form.url) }}
{{ render_field(form.title) }}
{{ render_field(form.message, class_="m-0", fieldclass="form-control markdown", data_enter_submit="1") }}
{{ render_field(form.file_upload, accept="image/png,image/jpeg,image/webp", hint=_("Optional, usually not required")) }}
{{ render_submit_field(form.submit) }}
<p class="alert alert-info mt-5">
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
</p>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title -%}
{{ _("We have received your report") }}
{%- endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
<p>
{{ _("We aim to resolve your report quickly.") }}
</p>
<p>
{{ _("If the report is about illegal or harmful content, we aim to resolve within 48 hours.") }}
{{ _("If we find the content to be infringing, we will remove it and may warn or suspend the user.") }}
</p>
{% if report.thread %}
<p>
{{ _("A private thread has been created for this report. You can use it to communicate with ContentDB staff and receive updates about the report.") }}
</p>
{% else %}
<p>
{{ _("Due to limited resources, we may not contact you further about the report unless we need clarification.") }}
</p>
<p>
{{ _("For future reference, use report id: %(report_id)s.", report_id=report.id) }}
</p>
{% endif %}
<p>
{% if report.thread %}
<a class="btn bg-primary btn-large me-2" href="{{ url_for('threads.view', id=report.thread.id) }}">{{ _("View thread") }}</a>
{% endif %}
<a class="btn bg-primary btn-large" href="{{ url_for('homepage.home') }}">{{ _("Back to home") }}</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title -%}
{{ report.title }}
{%- endblock %}
{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field, easymde_scripts %}
{% block scriptextra %}
{{ easymde_scripts() }}
{% endblock %}
{% block content %}
{% set url = url_for("report.view", rid=report.id) %}
<p class="float-end">
<a class="btn bg-secondary me-2" href="{{ url_for('admin.audit', url=url) }}">View audit log</a>
<a class="btn bg-secondary" href="{{ url_for('report.edit', rid=report.id) }}">{{ _("Edit") }}</a>
</p>
<p>
<a class="btn bg-secondary" href="{{ url_for('report.list_all') }}">Back to reports</a>
</p>
<h1>
{% if report.is_resolved %}
<span class="badge bg-secondary me-3">
Closed
</span>
{% else %}
<span class="badge bg-info me-3">
Open
</span>
{% endif %}
{{ self.title() }}
</h1>
<article class="row">
<div class="col-md-9">
<div class="card">
<div class="card-body markdown">
{{ report.message | markdown }}
</div>
</div>
</div>
<aside class="col-md-3 info-sidebar">
<dl>
<dt>Category</dt>
<dd>
{{ report.category.title }}
</dd>
</dl>
<dl>
<dt>URL</dt>
<dd>
<a href="{{ report.url }}">{{ report.url }}</a>
</dd>
</dl>
<dl>
<dt>Created At</dt>
<dd>
{{ report.created_at | full_datetime }}
</dd>
</dl>
<dl>
<dt>Reporter</dt>
<dd>
{% if report.user %}
<a href="{{ url_for('users.profile', username=report.user.username) }}">
{{ report.user.username }}
</a>
{% else %}
Anonymous
{% endif %}
</dd>
</dl>
</aside>
</article>
{% if report.attachments %}
<article>
<h2>Attachments</h2>
<ul>
{% for attachment in report.attachments %}
<li><a href="{{ attachment.url }}">{{ attachment.url }}</a></li>
{% endfor %}
</ul>
</article>
{% endif %}
<article>
<h2>{% if report.is_resolved %}Reopen report{% else %}Close report{% endif %}</h2>
<form method="POST" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if report.is_resolved %}
<button type="submit" class="btn bg-primary" name="reopen" value="true">{{ _("Reopen") }}</button>
{% else %}
<button type="submit" class="btn bg-primary" name="completed" value="true">{{ _("Completed (action taken)") }}</button>
<button type="submit" class="btn bg-primary" name="removed" value="true">{{ _("Content removed") }}</button>
<button type="submit" class="btn bg-primary" name="invalid" value="true">{{ _("Invalid / close with no action") }}</button>
{% endif %}
</form>
</article>
<article>
<h2>Thread</h2>
{% if report.thread %}
{% from "macros/threads.html" import render_thread %}
{{ render_thread(report.thread, current_user, form=False) }}
{% else %}
<p>
No thread.
</p>
{% endif %}
</article>
{% endblock %}

View File

@@ -20,7 +20,7 @@
{% if "error" in info or info.status == "FAILURE" or info.status == "REVOKED" %}
<pre style="white-space: pre-wrap; word-wrap: break-word;">{{ info.error }}</pre>
{% else %}
<script src="/static/js/polltask.js?v=3"></script>
<script src="/static/js/polltask.js?v=4"></script>
<noscript>
{{ _("Reload the page to check for updates.") }}
</noscript>

View File

@@ -106,7 +106,7 @@
<aside class="row">
<div class="col-md-9">
<i>
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
{{ _("This thread is only visible to its creator, package maintainers, and users of Approver rank or above.") }}
</i>
</div>
<div class="col-md-3">
@@ -128,6 +128,11 @@
</aside>
{% endif %}
{% set report = thread.report.first() %}
{% if report and report.check_perm(current_user, "SEE_REPORT") %}
<a class="btn bg-primary btn-large" href="{{ url_for('report.view', rid=report.id) }}">View report page</a>
{% endif %}
{% if thread.review and current_user == thread.package.author %}
{% set flag %}
<i class="fas fa-flag mx-2"></i>

View File

@@ -5,6 +5,28 @@
{% endblock %}
{% block content %}
{% if reports %}
<h2 class="mb-4">{{ _("Reports") }}</h2>
<nav class="list-group">
{% for report in reports %}
<a class="list-group-item list-group-item-action" href="{{ url_for('report.view', rid=report.id) }}">
<div class="row">
<div class="col">
<span class="badge bg-secondary me-3">{{ report.category.title }}</span>
{{ report.title }}
{% if report.user %}
by {{ report.user.display_name }}
{% endif %}
</div>
<div class="col-auto">
{{ report.created_at | timedelta }} ago
</div>
</div>
</a>
{% endfor %}
</nav>
{% endif %}
<h2 class="mb-4">{{ _("Approval Queue") }}</h2>
{% if can_approve_scn and screenshots %}
<div class="card my-4">

View File

@@ -61,7 +61,7 @@
{% block content %}
<a class="btn btn-secondary float-end" href="https://dev.minetest.net/Translation#Translating_mods_and_games">
<a class="btn btn-secondary float-end" href="https://dev.luanti.org/Translation#Translating_mods_and_games">
{{ _("How to translate a mod / game") }}
</a>

View File

@@ -87,7 +87,7 @@
{{ _("Please raise a report to request account deletion.") }}
</p>
<p>
<a class="btn btn-secondary" href="{{ url_for('report.report', url=url_current(), message="Delete my account") }}">{{ _("Report") }}</a>
<a class="btn btn-secondary" href="{{ url_for('report.report', url=url_current(), title='Delete my account', category='account_deletion') }}">{{ _("Report") }}</a>
</p>
{% endif %}

View File

@@ -88,7 +88,7 @@
</a>
{% endif %}
{% if user.website_url %}
{% if user.website_url and user.check_perm(user, "LINK_TO_WEBSITE") %}
<a class="btn" href="{{ user.website_url }}" rel="ugc">
<i class="fas fa-globe-europe"></i>
<span class="count">
@@ -97,7 +97,7 @@
</a>
{% endif %}
{% if user.donate_url %}
{% if user.donate_url and user.check_perm(user, "LINK_TO_WEBSITE") %}
<a class="btn" href="{{ user.donate_url }}" rel="ugc">
<i class="fas fa-donate"></i>
<span class="count">

View File

@@ -67,6 +67,13 @@
{{ render_field(form.donate_url, tabindex=233) }}
{% endif %}
{% if not user.check_perm(user, "LINK_TO_WEBSITE") %}
<p>
{{ _("Website URLs will not be shown whilst you are a 'New Member'.") }}
{{ _("To become a full member, create a review, comment, or package and wait 7 days.") }}
</p>
{% endif %}
<p>
{{ render_submit_field(form.submit, tabindex=280) }}
</p>

View File

@@ -34,6 +34,8 @@
</p>
{{ render_field(form.question, hint=_("Please prove that you are human")) }}
<input class="form-control" id="first_name" name="first_name" type="text" value="">
<p>
{{ _("By signing up, you agree to the <a href='/terms/' target='_blank'>Terms of Service</a> and <a href='/privacy_policy/' target='_blank'>Privacy Policy</a>.") }}
</p>

View File

@@ -1,7 +1,7 @@
from typing import List, Tuple, Optional
from app.default_data import populate_test_data
from app.models import db, License, PackageType, User, Package, PackageState, PackageRelease, MinetestRelease
from app.models import db, License, PackageType, User, Package, PackageState, PackageRelease, LuantiRelease
from .utils import parse_json, validate_package_list
from .utils import client # noqa
@@ -32,10 +32,10 @@ def make_package(name: str, versions: List[Tuple[Optional[str], Optional[str]]])
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
if minv:
rel.min_rel = MinetestRelease.query.filter_by(name=minv).first()
rel.min_rel = LuantiRelease.query.filter_by(name=minv).first()
assert rel.min_rel
if maxv:
rel.max_rel = MinetestRelease.query.filter_by(name=maxv).first()
rel.max_rel = LuantiRelease.query.filter_by(name=maxv).first()
assert rel.max_rel
rel.approved = True

View File

@@ -35,6 +35,7 @@ class MockEntry:
self.causer = causer
self.created_at = datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z")
self.title = title
self.description = None
self.package = MockPackage(package_id)

View File

@@ -204,10 +204,6 @@ def test_cycle():
support.on_update(modA)
assert support.all_errors == {
"author/mod_b: Dependency cycle detected: author/mod_a -> author/mod_b -> author/mod_a",
"author/mod_a: Dependency cycle detected: author/mod_a -> author/mod_b -> author/mod_a",
"author/mod_b: Dependency cycle detected: author/mod_b -> author/mod_a -> author/mod_b",
"author/mod_a: Dependency cycle detected: author/mod_b -> author/mod_a -> author/mod_b",
"author/mod_b: Unable to fulfill dependency mod_a",
"author/mod_a: Unable to fulfill dependency mod_b"
}
@@ -236,8 +232,6 @@ def test_cycle_fails_safely():
"author/mod_b: Unable to fulfill dependency mod_c",
"author/mod_d: Unable to fulfill dependency mod_b",
"author/mod_c: Unable to fulfill dependency mod_b",
"author/mod_c: Dependency cycle detected: author/mod_b -> author/mod_c -> author/mod_b",
"author/mod_b: Dependency cycle detected: author/mod_b -> author/mod_c -> author/mod_b"
}
@@ -382,10 +376,6 @@ def test_update_cycle():
support.on_update(game1)
assert support.all_errors == {
"author/mod_c: Dependency cycle detected: author/mod_a -> author/mod_c -> author/mod_a",
"author/mod_a: Dependency cycle detected: author/mod_a -> author/mod_c -> author/mod_a",
"author/mod_c: Dependency cycle detected: author/mod_c -> author/mod_a -> author/mod_c",
"author/mod_a: Dependency cycle detected: author/mod_c -> author/mod_a -> author/mod_c",
"author/mod_a: Unable to fulfill dependency mod_c",
"author/mod_c: Unable to fulfill dependency mod_a"
}

View File

@@ -84,6 +84,7 @@ def test_missing_hard_deps(get_forum_topic):
mock_package = MockPackageHelper(PackageType.MOD)
mock_package.add_release()
mock_package.add_missing_hard_deps()
mock_package.add_screenshot()
topic = MagicMock()
topic.author = mock_package.package.author
@@ -103,6 +104,7 @@ def test_requires_multiple_issues():
mock_package.add_release()
mock_package.set_license("Other", "Other")
mock_package.set_no_game_support()
mock_package.add_screenshot()
notes = package_approval.validate_package_for_approval(mock_package.package)
assert len(notes) == 5
@@ -120,6 +122,7 @@ def test_requires_multiple_issues():
def test_forum_topic_author_mismatch(get_forum_topic):
mock_package = MockPackageHelper()
mock_package.add_release()
mock_package.add_screenshot()
topic = MagicMock()
get_forum_topic.return_value = topic
@@ -136,6 +139,7 @@ def test_forum_topic_author_mismatch(get_forum_topic):
def test_passes(get_forum_topic):
mock_package = MockPackageHelper()
mock_package.add_release()
mock_package.add_screenshot()
topic = MagicMock()
topic.author = mock_package.package.author
@@ -168,8 +172,8 @@ def test_games_txp_must_have_unique_name(get_forum_topic):
@patch("app.logic.package_approval.get_conflicting_mod_names", MagicMock(return_value=set()))
@patch("app.logic.package_approval.count_packages_with_forum_topic", MagicMock(return_value=1))
@patch("app.logic.package_approval.get_forum_topic")
def test_games_txp_require_screenshots(get_forum_topic):
mock_package = MockPackageHelper(PackageType.GAME)
def test_require_screenshots(get_forum_topic):
mock_package = MockPackageHelper(PackageType.MOD)
mock_package.add_release()
topic = MagicMock()

View File

@@ -2,7 +2,7 @@ import os
import pytest
from app.tasks.minetestcheck.translation import parse_tr
from app.tasks.luanticheck.translation import parse_tr
def test_parses_tr():

View File

@@ -14,7 +14,7 @@
# 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 app.utils.minetest_hypertext import html_to_minetest
from app.utils.luanti_hypertext import html_to_luanti
conquer_html = """
@@ -74,7 +74,7 @@ page_url = "https://example.com/a/b/"
def test_conquer():
assert html_to_minetest(conquer_html, page_url)["body"].strip() == conquer_expected.strip()
assert html_to_luanti(conquer_html, page_url)["body"].strip() == conquer_expected.strip()
def test_images():
@@ -83,7 +83,7 @@ def test_images():
"""
expected = "<img name=image_0 width=128 height=128>"
result = html_to_minetest(html, page_url)
result = html_to_luanti(html, page_url)
assert result["body"].strip() == expected.strip()
assert len(result["images"]) == 1
assert result["images"]["image_0"] == "https://example.com/path/to/img.png"
@@ -95,7 +95,7 @@ def test_images_removed():
"""
expected = "<action name=image_0><u>Image: alt</u></action>"
result = html_to_minetest(html, page_url, 7, False)
result = html_to_luanti(html, page_url, 7, False)
assert result["body"].strip() == expected.strip()
assert len(result["images"]) == 0
assert result["links"]["image_0"] == "https://example.com/path/to/img.png"
@@ -112,7 +112,7 @@ def test_links_relative_absolute():
"<action name=link_1><u>Absolute</u></action> " \
"<action name=link_2><u>Other domain</u></action>"
result = html_to_minetest(html, page_url, 7, False)
result = html_to_luanti(html, page_url, 7, False)
assert result["body"].strip() == expected.strip()
assert result["links"]["link_0"] == "https://example.com/a/b/relative"
assert result["links"]["link_1"] == "https://example.com/absolute"
@@ -134,7 +134,7 @@ def test_bullets():
"<img name=blank.png width=32 height=1>• sub two\n\n" \
"<img name=blank.png width=16 height=1>• four\n"
result = html_to_minetest(html, page_url)
result = html_to_luanti(html, page_url)
assert result["body"].strip() == expected.strip()
@@ -158,7 +158,7 @@ def test_table():
expected = "<action name=link_0><u>(view table in browser)</u></action>\n\n" \
"<b>Heading</b>\n" \
"<action name=link_1><u>(view table in browser)</u></action>"
result = html_to_minetest(html, page_url)
result = html_to_luanti(html, page_url)
assert result["body"].strip() == expected.strip()
assert result["links"]["link_0"] == f"{page_url}#with-id"
assert result["links"]["link_1"] == f"{page_url}#heading"
@@ -170,7 +170,7 @@ def test_inline():
"""
expected = "<b>One <i>two</i> three</b>"
result = html_to_minetest(html, page_url)
result = html_to_luanti(html, page_url)
assert result["body"].strip() == expected.strip()
@@ -180,7 +180,7 @@ def test_escape():
"""
expected = r"<b>One <i>t\\w\<o\></i> three</b>"
result = html_to_minetest(html, page_url)
result = html_to_luanti(html, page_url)
assert result["body"].strip() == expected.strip()
@@ -190,5 +190,5 @@ def test_unknown_attr():
"""
expected = r"<action name=link_0><u>link</u></action>"
result = html_to_minetest(html, page_url)
result = html_to_luanti(html, page_url)
assert result["body"].strip() == expected.strip()

View File

@@ -32,8 +32,9 @@ def test_web_is_not_bot():
"Chrome/125.0.0.0 Safari/537.36").is_bot
def test_minetest_is_not_bot():
def test_luanti_is_not_bot():
assert not user_agents.parse("Minetest/5.5.1 (Linux/4.14.193+-ab49821 aarch64)").is_bot
assert not user_agents.parse("Luanti/5.12.0 (Linux/4.14.193+-ab49821 aarch64)").is_bot
def test_crawlers_are_bots():

View File

@@ -55,7 +55,7 @@ def make_indent(w):
return f"<img name=blank.png width={16*w} height=1>"
class MinetestHTMLParser(HTMLParser):
class LuantiHTMLParser(HTMLParser):
def __init__(self, page_url: str, include_images: bool, link_prefix: str):
super().__init__()
self.page_url = page_url
@@ -224,8 +224,8 @@ class MinetestHTMLParser(HTMLParser):
self.current_line += f"&{name};"
def html_to_minetest(html, page_url: str, formspec_version: int = 7, include_images: bool = True, link_prefix: str = "link_"):
parser = MinetestHTMLParser(page_url, include_images, link_prefix)
def html_to_luanti(html, page_url: str, formspec_version: int = 7, include_images: bool = True, link_prefix: str = "link_"):
parser = LuantiHTMLParser(page_url, include_images, link_prefix)
parser.feed(html)
parser.finish_line()
@@ -329,7 +329,7 @@ def package_reviews_as_hypertext(package: Package, formspec_version: int = 7):
for review in reviews:
review: PackageReview
html = render_markdown(review.thread.first_reply.comment)
content = html_to_minetest(html, package.get_url("packages.view", absolute=True),
content = html_to_luanti(html, package.get_url("packages.view", absolute=True),
formspec_version, False, f"review_{review.id}_")
links.update(content["links"])
comment_body = content["body"].rstrip()

View File

@@ -1,4 +1,3 @@
version: '3'
services:
db:
image: "postgres:14"

View File

@@ -1,6 +1,6 @@
# Minetest's use of the API
# Luanti's use of the API
This document explains how Minetest's ContentDB client interacts with ContentDB.
This document explains how Luanti's ContentDB client interacts with ContentDB.
This is useful both for implementing your own client for ContentDB to install mods,
or for implementing ContentDB compatible servers.
@@ -36,7 +36,7 @@ Example response:
`type` is one of `mod`, `game`, or `txp`.
`release` is the release ID. Newer releases have higher IDs.
Minetest compares this ID to a locally stored version to detect whether a package has updates.
Luanti compares this ID to a locally stored version to detect whether a package has updates.
Because the client specifies the engine version information, the response must contain a release
number and the package must be downloadable.
@@ -62,7 +62,7 @@ track the installed release to detect updates in the future.
### Short version
Minetest uses `/api/packages/<author>/<name>/dependencies/?only_hard=1` to find out the hard
Luanti uses `/api/packages/<author>/<name>/dependencies/?only_hard=1` to find out the hard
dependencies for a package.
Then, it resolves each dependency recursively.

View File

@@ -0,0 +1,26 @@
"""empty message
Revision ID: 1acc6e90bbac
Revises: 57b7fbc174cf
Create Date: 2025-08-26 20:23:29.086541
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1acc6e90bbac'
down_revision = '57b7fbc174cf'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('package_update_config', schema=None) as batch_op:
batch_op.add_column(sa.Column('last_checked_at', sa.DateTime(), nullable=True))
def downgrade():
with op.batch_alter_table('package_update_config', schema=None) as batch_op:
batch_op.drop_column('last_checked_at')

View File

@@ -0,0 +1,28 @@
"""empty message
Revision ID: 1e08d7e4c15d
Revises: 9689a71efe88
Create Date: 2025-08-26 14:43:30.501823
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1e08d7e4c15d'
down_revision = '9689a71efe88'
branch_labels = None
depends_on = None
def upgrade():
status = postgresql.ENUM('ACCOUNT_DELETION', 'COPYRIGHT', 'USER_CONDUCT', 'ILLEGAL_HARMFUL', 'APPEAL', 'OTHER', name='reportcategory')
status.create(op.get_bind())
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.add_column(sa.Column('category', sa.Enum('ACCOUNT_DELETION', 'COPYRIGHT', 'USER_CONDUCT', 'ILLEGAL_HARMFUL', 'APPEAL', 'OTHER', name='reportcategory'), nullable=False, server_default="OTHER"))
def downgrade():
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.drop_column('category')

View File

@@ -0,0 +1,31 @@
"""empty message
Revision ID: 242fd82077bb
Revises: 1acc6e90bbac
Create Date: 2025-09-01 10:00:39.263576
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '242fd82077bb'
down_revision = '1acc6e90bbac'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('report_attachment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('report_id', sa.String(length=24), nullable=False),
sa.Column('url', sa.String(length=100), nullable=False),
sa.ForeignKeyConstraint(['report_id'], ['report.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('report_attachment')

View File

@@ -0,0 +1,30 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '3052712496e4'
down_revision = '663521dfe86d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('report',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('thread_id', sa.Integer(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('title', sa.Unicode(length=300), nullable=False),
sa.Column('message', sa.UnicodeText(), nullable=False),
sa.Column('is_resolved', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('report')

View File

@@ -0,0 +1,24 @@
"""empty message
Revision ID: 57b7fbc174cf
Revises: 1e08d7e4c15d
Create Date: 2025-08-26 19:23:22.446424
"""
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '57b7fbc174cf'
down_revision = '1e08d7e4c15d'
branch_labels = None
depends_on = None
def upgrade():
op.execute(text("COMMIT"))
op.execute(text("ALTER TYPE reportcategory ADD VALUE 'REVIEW' AFTER 'ILLEGAL_HARMFUL'"))
def downgrade():
pass

View File

@@ -0,0 +1,17 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '663521dfe86d'
down_revision = 'c181c6c88bae'
branch_labels = None
depends_on = None
def upgrade():
op.rename_table("minetest_release", "luanti_release")
def downgrade():
op.rename_table("luanti_release", "minetest_release")

View File

@@ -0,0 +1,24 @@
"""empty message
Revision ID: 8f55dfbec825
Revises: 242fd82077bb
Create Date: 2025-09-23 15:21:06.445012
"""
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '8f55dfbec825'
down_revision = '242fd82077bb'
branch_labels = None
depends_on = None
def upgrade():
op.execute(text("COMMIT"))
op.execute(text("ALTER TYPE reportcategory ADD VALUE 'SPAM' AFTER 'USER_CONDUCT'"))
def downgrade():
pass

View File

@@ -0,0 +1,32 @@
"""empty message
Revision ID: 9689a71efe88
Revises: 3052712496e4
Create Date: 2025-08-26 14:24:02.045713
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '9689a71efe88'
down_revision = '3052712496e4'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.INTEGER(),
type_=sa.String(length=24),
existing_nullable=False)
def downgrade():
with op.batch_alter_table('report', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.String(length=24),
type_=sa.INTEGER(),
existing_nullable=False)

View File

@@ -0,0 +1,28 @@
"""empty message
Revision ID: c181c6c88bae
Revises: daa040b727b2
Create Date: 2025-07-02 17:21:33.554960
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c181c6c88bae'
down_revision = 'daa040b727b2'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('package_release',
sa.Column('file_size_bytes', sa.Integer(), nullable=False, server_default="0"))
op.add_column('package_screenshot',
sa.Column('file_size_bytes', sa.Integer(), nullable=False, server_default="0"))
def downgrade():
op.drop_column('package', 'file_size_bytes')
op.drop_column('package_screenshot', 'file_size_bytes')

View File

@@ -18,7 +18,7 @@ depends_on = None
def upgrade():
# Source: https://github.com/minetest/minetest/blob/master/builtin/mainmenu/settings/dlg_settings.lua#L156
# Source: https://github.com/luanti-org/luanti/blob/master/builtin/mainmenu/settings/dlg_settings.lua#L156
languages = {
"en": "English",
# "ar": "", blacklisted

View File

@@ -1,80 +1,83 @@
alembic==1.13.1
amqp==5.2.0
async-timeout==4.0.3
Babel==2.15.0
bcrypt==4.1.3
beautifulsoup4==4.12.3
billiard==4.2.0
bleach==6.1.0
blinker==1.8.2
celery==5.4.0
certifi==2024.2.2
charset-normalizer==3.3.2
click==8.1.7
alembic==1.16.2
amqp==5.3.1
async-timeout==5.0.1
babel==2.17.0
bcrypt==4.3.0
beautifulsoup4==4.13.4
billiard==4.2.1
bleach==6.2.0
blinker==1.9.0
celery==5.5.3
certifi==2025.6.15
charset-normalizer==3.4.2
click==8.2.1
click-didyoumean==0.3.1
click-plugins==1.1.1
click-plugins==1.1.1.2
click-repl==0.3.0
coverage==7.5.1
coverage==7.9.1
deep-compare==1.0.5
dnspython==2.6.1
email_validator==2.1.1
exceptiongroup==1.2.1
Flask==3.0.3
dnspython==2.7.0
email_validator==2.2.0
exceptiongroup==1.3.0
Flask==3.1.1
flask-babel==4.0.0
Flask-FlatPages==0.8.2
Flask-FlatPages==0.8.3
Flask-Login==0.6.3
Flask-Mail==0.10.0
Flask-Migrate==4.0.7
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1
Flask-WTF==1.2.2
git-archive-all==1.23.1
gitdb==4.0.11
gitdb==4.0.12
GitHub-Flask==3.2.0
GitPython==3.1.43
greenlet==3.0.3
idna==3.7
iniconfig==2.0.0
GitPython==3.1.44
greenlet==3.2.3
idna==3.10
iniconfig==2.1.0
itsdangerous==2.2.0
Jinja2==3.1.4
kombu==5.3.7
Jinja2==3.1.6
kombu==5.5.4
libsass==0.23.0
lxml==5.2.2
Mako==1.3.5
markdown-it-py==3.0.0
mdit-py-plugins==0.4.2
linkify-it-py==2.0.3
MarkupSafe==2.1.5
packaging==24.0
lxml==6.0.0
Mako==1.3.10
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdit-py-plugins==0.4.2
mdurl==0.1.2
packaging==25.0
passlib==1.7.4
pillow==10.3.0
pluggy==1.5.0
prompt-toolkit==3.0.43
psycopg2==2.9.9
Pygments==2.18.0
pytest==8.2.1
pytest-cov==5.0.0
pillow==11.3.0
pluggy==1.6.0
prompt_toolkit==3.0.51
psycopg2==2.9.10
Pygments==2.19.2
pytest==8.4.1
pytest-cov==6.2.1
python-dateutil==2.9.0.post0
pytz==2024.1
PyYAML==6.0.1
redis==5.0.4
requests==2.32.2
sentry-sdk[flask]==2.3.1
six==1.16.0
smmap==5.0.1
soupsieve==2.5
SQLAlchemy==2.0.30
pytz==2025.2
PyYAML==6.0.2
redis==5.3.0
requests==2.32.4
sentry-sdk[flask]==2.32.0
six==1.17.0
smmap==5.0.2
soupsieve==2.7
SQLAlchemy==2.0.41
sqlalchemy-searchable==2.1.0
SQLAlchemy-Utils==0.41.2
tomli==2.0.1
typing_extensions==4.11.0
tzdata==2024.1
ua-parser==0.18.0
urllib3==2.2.1
tomli==2.2.1
typing_extensions==4.14.0
tzdata==2025.2
ua-parser==1.0.1
ua-parser-builtins==0.18.0.post1
uc-micro-py==1.0.3
urllib3==2.5.0
user-agents==2.2.0
validators==0.28.1
validators==0.35.0
vine==5.1.0
wcwidth==0.2.13
webencodings==0.5.1
Werkzeug==3.0.3
WTForms==3.1.2
WTForms-SQLAlchemy==0.4.1
Werkzeug==3.1.3
WTForms==3.2.1
WTForms-SQLAlchemy==0.4.2

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