Compare commits

...

294 Commits

Author SHA1 Message Date
rubenwardy
d3bff6fda1 Add reserved namespaces policy
Fixes #222
2022-01-21 22:52:51 +00:00
rubenwardy
a48c0fb2b4 Fix unapproved content showing in "related"
Fixes #253
2022-01-21 22:49:12 +00:00
rubenwardy
3c944cbd72 Add moderator tools page
Fixes #341
2022-01-21 22:33:22 +00:00
rubenwardy
727db52c19 Limit visibility of unapproved packages to maintainers and approvers
Fixes #338
2022-01-21 21:48:15 +00:00
rubenwardy
80d534a53f Fix crash on invalid username in forums import 2022-01-21 21:20:04 +00:00
rubenwardy
fe2d08c395 Allow moderators to edit ThreadReplies 2022-01-21 14:30:12 +00:00
rubenwardy
97e2e1c16e Add abs_url_samesite 2022-01-21 14:23:27 +00:00
rubenwardy
a32b63f932 Use relative URLs in report, to ensure correct links 2022-01-21 14:17:50 +00:00
rubenwardy
e0421c1e57 Disable WIP markdown editor toolbar option 2022-01-21 13:59:13 +00:00
rubenwardy
f457f7f5d7 Fix accidental new line in thread <title> 2022-01-21 13:15:07 +00:00
rubenwardy
3ac2d937d7 Fix grammatically weird <title> on thread pages 2022-01-21 13:14:02 +00:00
rubenwardy
45eca10859 Fix "staff" typo 2022-01-21 02:53:46 +00:00
rubenwardy
38aa8fa03a Translated using Weblate (Malay)
Currently translated at 98.1% (684 of 697 strings)

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

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

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

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

Added translation using Weblate (Chinese (Simplified))

Added translation using Weblate (Chinese (Literary))

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

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

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

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

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

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

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

Added translation using Weblate (Turkish)

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

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

Added translation using Weblate (Chinese (Traditional))

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

Translated using Weblate (Malay)

Currently translated at 95.4% (653 of 684 strings)

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

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

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

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

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

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

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

Translated using Weblate (German)

Currently translated at 69.3% (455 of 656 strings)

Translated using Weblate (German)

Currently translated at 68.1% (447 of 656 strings)

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

Translated using Weblate (German)

Currently translated at 69.3% (455 of 656 strings)

Translated using Weblate (German)

Currently translated at 68.1% (447 of 656 strings)

Translated using Weblate (German)

Currently translated at 65.0% (427 of 656 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Added translation using Weblate (Russian)

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

Added translation using Weblate (German)

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

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

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

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

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

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

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

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

Translated using Weblate (Indonesian)

Currently translated at 57.2% (376 of 657 strings)

Added translation using Weblate (Indonesian)

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

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

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

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

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

Added translation using Weblate (Spanish)

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

Translated using Weblate (French)

Currently translated at 69.4% (223 of 321 strings)

Translated using Weblate (French)

Currently translated at 64.4% (207 of 321 strings)

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

Added translation using Weblate (Norwegian Bokmål)

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

Translated using Weblate (French)

Currently translated at 69.4% (223 of 321 strings)

Translated using Weblate (French)

Currently translated at 64.4% (207 of 321 strings)

Translated using Weblate (French)

Currently translated at 28.3% (91 of 321 strings)

Added translation using Weblate (French)

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

Translated using Weblate (Malay)

Currently translated at 0.0% (0 of 321 strings)

Added translation using Weblate (Malay)

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

5
.gitignore vendored
View File

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

View File

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

View File

@@ -16,7 +16,9 @@ COPY utils utils
COPY config.cfg config.cfg
COPY migrations migrations
COPY app app
COPY translations translations
RUN pybabel compile -d translations
RUN chown -R cdb:cdb /home/cdb
USER cdb

View File

@@ -1,5 +1,5 @@
# Content Database
[![Build status](https://gitlab.com/minetest/contentdb/badges/master/pipeline.svg)](https://gitlab.com/minetest/contentdb/pipelines)
![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
Content database for Minetest mods, games, and more.\
Developed by rubenwardy, license AGPLv3.0+.

View File

@@ -13,53 +13,58 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import *
from flask_gravatar import Gravatar
import flask_menu as menu
from flask_mail import Mail
from flask_github import GitHub
from flask_wtf.csrf import CSRFProtect
from flask_flatpages import FlatPages
from flask_babel import Babel
from flask_babel import Babel, gettext
from flask_login import logout_user, current_user, LoginManager
import os, redis
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
app = Flask(__name__, static_folder="public/static")
app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md"
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = ["fenced_code", "tables", "codehilite", 'toc']
app.config["FLATPAGES_EXTENSION_CONFIG"] = {
"fenced_code": {},
"tables": {},
"codehilite": {
"linenums": "True"
}
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
app.config["LANGUAGES"] = {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"id": "Bahasa Indonesia",
"ms": "Bahasa Melayu",
}
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
r = redis.Redis.from_url(app.config["REDIS_URL"])
menu.Menu(app=app)
github = GitHub(app)
csrf = CSRFProtect(app)
mail = Mail(app)
pages = FlatPages(app)
babel = Babel(app)
gravatar = Gravatar(app,
size=58,
size=64,
rating="g",
default="mp",
default="retro",
force_default=False,
force_lower=False,
use_ssl=True,
base_url=None)
init_markdown(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "users.login"
from .sass import sass
sass(app)
@@ -69,15 +74,9 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
app.logger.addHandler(build_handler(app))
from app.utils.markdown import init_app
init_app(app)
# @babel.localeselector
# def get_locale():
# return request.accept_languages.best_match(app.config["LANGUAGES"].keys())
from . import models, template_filters
@login_manager.user_loader
def load_user(user_id):
return models.User.query.filter_by(username=user_id).first()
@@ -90,7 +89,6 @@ create_blueprints(app)
def send_upload(path):
return send_from_directory(app.config["UPLOAD_DIR"], path)
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { "path": "help" })
@app.route("/<path:path>/")
def flatpage(path):
page = pages.get_or_404(path)
@@ -101,16 +99,57 @@ def flatpage(path):
def check_for_ban():
if current_user.is_authenticated:
if current_user.rank == models.UserRank.BANNED:
flash("You have been banned.", "danger")
flash(gettext("You have been banned."), "danger")
logout_user()
return redirect(url_for("users.login"))
elif current_user.rank == models.UserRank.NOT_JOINED:
current_user.rank = models.UserRank.MEMBER
models.db.session.commit()
from .utils import clearNotifications
from .utils import clearNotifications, is_safe_url
@app.before_request
def check_for_notifications():
if current_user.is_authenticated:
clearNotifications(request.path)
@app.errorhandler(404)
def page_not_found(e):
return render_template("404.html"), 404
@babel.localeselector
def get_locale():
locales = app.config["LANGUAGES"].keys()
if request:
locale = request.cookies.get("locale")
if locale in locales:
return locale
return request.accept_languages.best_match(locales)
return None
@app.route("/set-locale/", methods=["POST"])
@csrf.exempt
def set_locale():
locale = request.form.get("locale")
if locale not in app.config["LANGUAGES"].keys():
flash("Unknown locale {}".format(locale), "danger")
locale = None
next_url = request.form.get("r")
if next_url and is_safe_url(next_url):
resp = make_response(redirect(next_url))
else:
resp = make_response(redirect(url_for("homepage.home")))
if locale:
expire_date = datetime.datetime.now()
expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("locale", locale, expires=expire_date)
return resp

View File

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

View File

@@ -14,21 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from celery import group
from flask import *
from flask_login import current_user, login_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import InputRequired, Length
from app.models import *
from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
from app.utils import rank_required, addAuditLog, addNotification
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
from . import bp
from .actions import actions
from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
@bp.route("/admin/", methods=["GET", "POST"])
@@ -37,63 +31,7 @@ def admin_page():
if request.method == "POST":
action = request.form["action"]
if action == "delstuckreleases":
PackageRelease.query.filter(PackageRelease.task_id != None).delete()
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action == "checkreleases":
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = []
for release in releases:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
elif action == "reimportpackages":
tasks = []
for package in Package.query.filter(Package.state!=PackageState.DELETED).all():
release = package.releases.first()
if release:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
elif action == "importmodlist":
task = importTopicList.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
elif action == "checkusers":
task = checkAllForumAccounts.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
elif action == "importscreenshots":
packages = Package.query \
.filter(Package.state!=PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
.filter(PackageScreenshot.id==None) \
.all()
for package in packages:
importRepoScreenshot.delay(package.id)
return redirect(url_for("admin.admin_page"))
elif action == "restore":
if action == "restore":
package = Package.query.get(request.form["package"])
if package is None:
flash("Unknown package", "danger")
@@ -102,93 +40,16 @@ def admin_page():
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action == "recalcscores":
for p in Package.query.all():
p.recalcScore()
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action == "cleanuploads":
upload_dir = app.config['UPLOAD_DIR']
(_, _, filenames) = next(os.walk(upload_dir))
existing_uploads = set(filenames)
if len(existing_uploads) != 0:
def getURLsFromDB(column):
results = db.session.query(column).filter(column != None, column != "").all()
return set([os.path.basename(x[0]) for x in results])
release_urls = getURLsFromDB(PackageRelease.url)
screenshot_urls = getURLsFromDB(PackageScreenshot.url)
db_urls = release_urls.union(screenshot_urls)
unreachable = existing_uploads.difference(db_urls)
import sys
print("On Disk: ", existing_uploads, file=sys.stderr)
print("In DB: ", db_urls, file=sys.stderr)
print("Unreachable: ", unreachable, file=sys.stderr)
for filename in unreachable:
os.remove(os.path.join(upload_dir, filename))
flash("Deleted " + str(len(unreachable)) + " unreachable uploads", "success")
else:
flash("No downloads to create", "danger")
return redirect(url_for("admin.admin_page"))
elif action == "delmetapackages":
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
count = query.count()
query.delete(synchronize_session=False)
db.session.commit()
flash("Deleted " + str(count) + " unused meta packages", "success")
return redirect(url_for("admin.admin_page"))
elif action == "delremovedpackages":
query = Package.query.filter_by(state=PackageState.DELETED)
count = query.count()
for pkg in query.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.commit()
flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page"))
elif action == "addupdateconfig":
added = 0
for pkg in Package.query.filter(Package.repo != None, Package.releases.any(), Package.update_config == None).all():
pkg.update_config = PackageUpdateConfig()
pkg.update_config.auto_created = True
release: PackageRelease = pkg.releases.first()
if release and release.commit_hash:
pkg.update_config.last_commit = release.commit_hash
db.session.add(pkg.update_config)
added += 1
db.session.commit()
flash("Added {} update configs".format(added), "success")
return redirect(url_for("admin.admin_page"))
elif action == "runupdateconfig":
check_for_updates.delay()
flash("Started update configs", "success")
return redirect(url_for("admin.admin_page"))
elif action in actions:
ret = actions[action]["func"]()
if ret:
return ret
else:
flash("Unknown action: " + action, "danger")
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).all()
return render_template("admin/list.html", deleted_packages=deleted_packages)
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
class SwitchUserForm(FlaskForm):
username = StringField("Username")
@@ -228,9 +89,37 @@ def send_bulk_notification():
"Sent bulk notification", None, None, form.title.data)
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
addNotification(users, current_user, NotificationType.OTHER, form.title.data, form.url.data, None)
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
db.session.commit()
return redirect(url_for("admin.admin_page"))
return render_template("admin/send_bulk_notification.html", form=form)
@bp.route("/admin/restore/", methods=["GET", "POST"])
@rank_required(UserRank.EDITOR)
def restore():
if request.method == "POST":
target = request.form["submit"]
if "Review" in target:
target = PackageState.READY_FOR_REVIEW
elif "Changes" in target:
target = PackageState.CHANGES_NEEDED
else:
target = PackageState.WIP
package = Package.query.get(request.form["package"])
if package is None:
flash("Unknown package", "danger")
else:
package.state = target
addAuditLog(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
package.getURL("packages.view"), package)
db.session.commit()
return redirect(package.getURL("packages.view"))
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).join(Package.author).order_by(db.asc(User.username), db.asc(Package.name)).all()
return render_template("admin/restore.html", deleted_packages=deleted_packages)

View File

@@ -21,7 +21,7 @@ from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.utils.markdown import render_markdown
from app.markdown import render_markdown
from app.models import *
from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog

View File

@@ -18,10 +18,11 @@
from flask import *
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.fields.html5 import URLField
from wtforms.validators import *
from app.models import *
from app.utils import rank_required
from app.utils import rank_required, nonEmptyOrNone
from . import bp
@@ -33,6 +34,7 @@ def license_list():
class LicenseForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3,100)])
is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
submit = SubmitField("Save")
@bp.route("/licenses/new/", methods=["GET", "POST"])

View File

@@ -44,6 +44,7 @@ class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
is_protected = BooleanField("Is Protected")
submit = SubmitField("Save")
@bp.route("/tags/new/", methods=["GET", "POST"])
@@ -59,14 +60,16 @@ def create_edit_tag(name=None):
if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
abort(403)
form = TagForm(formdata=request.form, obj=tag)
form = TagForm( obj=tag)
if form.validate_on_submit():
if tag is None:
tag = Tag(form.title.data)
tag.description = form.description.data
tag.is_protected = form.is_protected.data
db.session.add(tag)
else:
form.populate_obj(tag)
db.session.commit()
if Permission.EDIT_TAGS.check(current_user):

View File

@@ -13,22 +13,40 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import math
from typing import List
from flask import request, jsonify, current_app, abort
import flask_sqlalchemy
from flask import request, jsonify, current_app
from flask_login import current_user, login_required
from sqlalchemy.orm import subqueryload, joinedload
from sqlalchemy.sql.expression import func
from app import csrf
from app.utils.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning
from app.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
from app.querybuilder import QueryBuilder
from app.utils import is_package_page
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
from . import bp
from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package
from functools import wraps
def cors_allowed(f):
@wraps(f)
def inner(*args, **kwargs):
res = f(*args, **kwargs)
res.headers["Access-Control-Allow-Origin"] = "*"
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return res
return inner
@bp.route("/api/packages/")
@cors_allowed
def packages():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
@@ -44,6 +62,7 @@ def packages():
@bp.route("/api/packages/<author>/<name>/")
@is_package_page
@cors_allowed
def package(package):
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@@ -52,6 +71,7 @@ def package(package):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def edit_package(token, package):
if not token:
error(401, "Authentication needed")
@@ -59,7 +79,7 @@ def edit_package(token, package):
return api_edit_package(token, package, request.json)
def resolve_package_deps(out, package, only_hard):
def resolve_package_deps(out, package, only_hard, depth=1):
id = package.getId()
if id in out:
return
@@ -67,6 +87,9 @@ def resolve_package_deps(out, package, only_hard):
ret = []
out[id] = ret
if package.type != PackageType.MOD:
return
for dep in package.dependencies:
if only_hard and dep.optional:
continue
@@ -74,12 +97,16 @@ def resolve_package_deps(out, package, only_hard):
if dep.package:
name = dep.package.name
fulfilled_by = [ dep.package.getId() ]
resolve_package_deps(out, dep.package, only_hard)
resolve_package_deps(out, dep.package, only_hard, depth)
elif dep.meta_package:
name = dep.meta_package.name
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
# TODO: resolve most likely candidate
if depth == 1 and not dep.optional:
most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), None)
if most_likely:
resolve_package_deps(out, most_likely, only_hard, depth + 1)
else:
raise Exception("Malformed dependency")
@@ -93,6 +120,7 @@ def resolve_package_deps(out, package, only_hard):
@bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page
@cors_allowed
def package_dependencies(package):
only_hard = request.args.get("only_hard")
@@ -103,6 +131,7 @@ def package_dependencies(package):
@bp.route("/api/topics/")
@cors_allowed
def topics():
qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True)
@@ -115,11 +144,11 @@ def topic_set_discard():
tid = request.args.get("tid")
discard = request.args.get("discard")
if tid is None or discard is None:
abort(400)
error(400, "Missing topic ID or discard bool")
topic = ForumTopic.query.get(tid)
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
abort(403)
error(403, "Permission denied, need: TOPIC_DISCARD")
topic.discarded = discard == "true"
db.session.commit()
@@ -129,6 +158,7 @@ def topic_set_discard():
@bp.route("/api/whoami/")
@is_api_authd
@cors_allowed
def whoami(token):
if token is None:
return jsonify({ "is_authenticated": False, "username": None })
@@ -142,8 +172,32 @@ def markdown():
return render_markdown(request.data.decode("utf-8"))
@bp.route("/api/releases/")
@cors_allowed
def list_all_releases():
query = PackageRelease.query.filter_by(approved=True) \
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
.order_by(db.desc(PackageRelease.releaseDate))
if "author" in request.args:
author = User.query.filter_by(username=request.args["author"]).first()
if author is None:
error(404, "Author not found")
query = query.filter(PackageRelease.package.has(author=author))
if "maintainer" in request.args:
maintainer = User.query.filter_by(username=request.args["maintainer"]).first()
if maintainer is None:
error(404, "Maintainer not found")
query = query.join(Package)
query = query.filter(Package.maintainers.any(id=maintainer.id))
return jsonify([ rel.getLongAsDictionary() for rel in query.limit(30).all() ])
@bp.route("/api/packages/<author>/<name>/releases/")
@is_package_page
@cors_allowed
def list_releases(package):
return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
@@ -152,6 +206,7 @@ def list_releases(package):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def create_release(token, package):
if not token:
error(401, "Authentication needed")
@@ -175,7 +230,9 @@ def create_release(token, package):
if file is None:
error(400, "Missing 'file' in multipart body")
return api_create_zip_release(token, package, data["title"], file)
commit_hash = data.get("commit")
return api_create_zip_release(token, package, data["title"], file, None, None, "API", commit_hash)
else:
error(400, "Unknown release-creation method. Specify the method or provide a file.")
@@ -183,6 +240,7 @@ def create_release(token, package):
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
@is_package_page
@cors_allowed
def release(package: Package, id: int):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
@@ -195,6 +253,7 @@ def release(package: Package, id: int):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def delete_release(token: APIToken, package: Package, id: int):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
@@ -217,6 +276,7 @@ def delete_release(token: APIToken, package: Package, id: int):
@bp.route("/api/packages/<author>/<name>/screenshots/")
@is_package_page
@cors_allowed
def list_screenshots(package):
screenshots = package.screenshots.all()
return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
@@ -226,6 +286,7 @@ def list_screenshots(package):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def create_screenshot(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
@@ -246,6 +307,7 @@ def create_screenshot(token: APIToken, package: Package):
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@is_package_page
@cors_allowed
def screenshot(package, id):
ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package:
@@ -258,6 +320,7 @@ def screenshot(package, id):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def delete_screenshot(token: APIToken, package: Package, id: int):
ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package:
@@ -286,6 +349,7 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def order_screenshots(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
@@ -303,7 +367,49 @@ def order_screenshots(token: APIToken, package: Package):
return api_order_screenshots(token, package, request.json)
@bp.route("/api/packages/<author>/<name>/reviews/")
@is_package_page
@cors_allowed
def list_reviews(package):
reviews = package.reviews
return jsonify([review.getAsDictionary() for review in reviews])
@bp.route("/api/reviews/")
@cors_allowed
def list_all_reviews():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 100)
query = PackageReview.query
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
if request.args.get("author"):
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
if request.args.get("is_positive"):
query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
q = request.args.get("q")
if q:
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
return jsonify({
"page": pagination.page,
"per_page": pagination.per_page,
"page_count": math.ceil(pagination.total / pagination.per_page),
"total": pagination.total,
"urls": {
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
},
"items": [review.getAsDictionary(True) for review in pagination.items],
})
@bp.route("/api/scores/")
@cors_allowed
def package_scores():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
@@ -313,26 +419,32 @@ def package_scores():
@bp.route("/api/tags/")
@cors_allowed
def tags():
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
@bp.route("/api/content_warnings/")
@cors_allowed
def content_warnings():
return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
@bp.route("/api/licenses/")
@cors_allowed
def licenses():
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
for license in License.query.order_by(db.asc(License.name)).all() ])
@bp.route("/api/homepage/")
@cors_allowed
def homepage():
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
featured = query.filter(Package.tags.any(name="featured")).order_by(
func.random()).limit(6).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
@@ -349,22 +461,65 @@ def homepage():
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
def mapPackages(packages):
return [pkg.getAsDictionaryKey() for pkg in packages]
def mapPackages(packages: List[Package]):
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
return {
return jsonify({
"count": count,
"downloads": downloads,
"featured": mapPackages(featured),
"new": mapPackages(new),
"updated": mapPackages(updated),
"pop_mod": mapPackages(pop_mod),
"pop_txp": mapPackages(pop_txp),
"pop_game": mapPackages(pop_gam),
"high_reviewed": mapPackages(high_reviewed)
}
})
@bp.route("/api/minetest_versions/")
@cors_allowed
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))
if rel is None:
error(404, "No releases found")
return jsonify(rel.getAsDictionary())
return jsonify([rel.getAsDictionary() \
for rel in MinetestRelease.query.all() if rel.getActual() is not None])
@bp.route("/api/dependencies/")
@cors_allowed
def all_deps():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
def format_pkg(pkg: Package):
return {
"type": pkg.type.toName(),
"author": pkg.author.username,
"name": pkg.name,
"provides": [x.name for x in pkg.provides],
"depends": [str(x) for x in pkg.dependencies if not x.optional],
"optional_depends": [str(x) for x in pkg.dependencies if x.optional],
}
page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
return jsonify({
"page": pagination.page,
"per_page": pagination.per_page,
"page_count": math.ceil(pagination.total / pagination.per_page),
"total": pagination.total,
"urls": {
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
},
"items": [format_pkg(pkg) for pkg in pagination.items],
})

View File

@@ -54,13 +54,13 @@ def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: s
def api_create_zip_release(token: APIToken, package: Package, title: str, file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash:str=None):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
rel = guard(do_create_zip_release)(token.owner, package, title, file, min_v, max_v, reason)
rel = guard(do_create_zip_release)(token.owner, package, title, file, min_v, max_v, reason, commit_hash)
return jsonify({
"success": True,
@@ -100,7 +100,7 @@ def api_edit_package(token: APIToken, package: Package, data: dict, reason: str
reason += ", token=" + token.name
package = guard(do_edit_package)(token.owner, package, False, data, reason)
package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
return jsonify({
"success": True,

View File

@@ -16,6 +16,7 @@
from flask import render_template, redirect, request, session, url_for, abort
from flask_babel import lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import *
@@ -29,10 +30,10 @@ from ..users.settings import get_setting_tabs
class CreateAPIToken(FlaskForm):
name = StringField("Name", [InputRequired(), Length(1, 30)])
package = QuerySelectField("Limit to package", allow_blank=True,
name = StringField(lazy_gettext("Name"), [InputRequired(), Length(1, 30)])
package = QuerySelectField(lazy_gettext("Limit to package"), allow_blank=True,
get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/user/tokens/")

View File

@@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint
from flask_babel import gettext
bp = Blueprint("github", __name__)
@@ -42,7 +43,7 @@ def view_permissions():
def callback(oauth_token):
next_url = request.args.get("next")
if oauth_token is None:
flash("Authorization failed [err=gh-oauth-login-failed]", "danger")
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login"))
# Get Github username
@@ -58,30 +59,28 @@ def callback(oauth_token):
if userByGithub is None:
current_user.github_username = username
db.session.commit()
flash("Linked github to account", "success")
flash(gettext("Linked GitHub to account"), "success")
return redirect(url_for("homepage.home"))
else:
flash("Github account is already associated with another user", "danger")
flash(gettext("GitHub account is already associated with another user"), "danger")
return redirect(url_for("homepage.home"))
# If not logged in, log in
else:
if userByGithub is None:
flash("Unable to find an account for that Github user", "danger")
flash(gettext("Unable to find an account for that GitHub user"), "danger")
return redirect(url_for("users.claim_forums"))
elif login_user_set_active(userByGithub, remember=True):
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
if not current_user.password:
return redirect(next_url or url_for("users.set_password", optional=True))
else:
return redirect(next_url or url_for("homepage.home"))
else:
flash("Authorization failed [err=gh-login-failed]", "danger")
ret = login_user_set_active(userByGithub, remember=True)
if ret is None:
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
return redirect(url_for("users.login"))
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
return ret
@bp.route("/github/webhook/", methods=["POST"])
@csrf.exempt
@@ -134,13 +133,27 @@ def webhook():
if event == "push":
ref = json["after"]
title = json["head_commit"]["message"].partition("\n")[0]
elif event == "create" and json["ref_type"] == "tag":
branch = json["ref"].replace("refs/heads/", "")
if branch not in [ "master", "main" ]:
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
elif event == "create":
ref_type = json.get("ref_type")
if ref_type != "tag":
return jsonify({
"success": False,
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
})
ref = json["ref"]
title = ref
elif event == "ping":
return jsonify({ "success": True, "message": "Ping successful" })
else:
return error(400, "Unsupported event. Only 'push', `create:tag`, and 'ping' are supported.")
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release

View File

@@ -14,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 flask import Blueprint, request
from flask import Blueprint, request, jsonify
bp = Blueprint("gitlab", __name__)
@@ -53,11 +53,18 @@ def webhook_impl():
if event == "push":
ref = json["after"]
title = ref[:5]
branch = json["ref"].replace("refs/heads/", "")
if branch not in ["master", "main"]:
return jsonify({"success": False,
"message": "Webhook ignored, as it's not on the master/main branch"})
elif event == "tag_push":
ref = json["ref"]
title = ref.replace("refs/tags/", "")
else:
return error(400, "Unsupported event. Only 'push' and 'tag_push' are supported.")
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release

View File

@@ -1,14 +1,13 @@
from flask import Blueprint, render_template
from flask import Blueprint, render_template, redirect
bp = Blueprint("homepage", __name__)
from app.models import *
import flask_menu as menu
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func
@bp.route("/")
@menu.register_menu(bp, ".", "Home")
def home():
def join(query):
return query.options(
@@ -18,6 +17,8 @@ def home():
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
featured = query.filter(Package.tags.any(name="featured")).order_by(func.random()).limit(6).all()
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
@@ -39,5 +40,5 @@ def home():
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
return render_template("index.html", count=count, downloads=downloads, tags=tags,
return render_template("index.html", count=count, downloads=downloads, tags=tags, featured=featured,
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews)

View File

@@ -50,9 +50,9 @@ def generate_metrics(full=False):
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
ret = ""
ret += write_single_stat("contentdb_packages", "Total packages", "counter", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "counter", users)
ret += write_single_stat("contentdb_downloads", "Total downloads", "counter", downloads)
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users)
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads)
if full:
scores = Package.query.join(User).with_entities(User.username, Package.name, Package.score) \

View File

@@ -15,7 +15,54 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint
from flask_babel import gettext
from app.models import User, Package, Permission
bp = Blueprint("packages", __name__)
def get_package_tabs(user: User, package: Package):
if package is None or not package.checkPerm(user, Permission.EDIT_PACKAGE):
return []
return [
{
"id": "edit",
"title": gettext("Edit Details"),
"url": package.getURL("packages.create_edit")
},
{
"id": "releases",
"title": gettext("Releases"),
"url": package.getURL("packages.list_releases")
},
{
"id": "screenshots",
"title": gettext("Screenshots"),
"url": package.getURL("packages.screenshots")
},
{
"id": "maintainers",
"title": gettext("Maintainers"),
"url": package.getURL("packages.edit_maintainers")
},
{
"id": "audit",
"title": gettext("Audit Log"),
"url": package.getURL("packages.audit")
},
{
"id": "share",
"title": gettext("Share and Badges"),
"url": package.getURL("packages.share")
},
{
"id": "remove",
"title": gettext("Remove"),
"url": package.getURL("packages.remove")
}
]
from . import packages, screenshots, releases, reviews

View File

@@ -17,9 +17,8 @@
from urllib.parse import quote as urlescape
import flask_menu as menu
from celery import uuid
from flask import render_template, flash
from flask import render_template
from flask_babel import lazy_gettext, gettext
from flask_wtf import FlaskForm
from flask_login import login_required
from sqlalchemy import or_, func
@@ -30,17 +29,15 @@ from wtforms.validators import *
from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_key
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease
from app.tasks.importtasks import importRepoScreenshot
from app.utils import *
from . import bp
from ...logic.LogicError import LogicError
from ...logic.packages import do_edit_package
from . import bp, get_package_tabs
from app.logic.LogicError import LogicError
from app.logic.packages import do_edit_package
from app.models.packages import PackageProvides
from app.tasks.webhooktasks import post_discord_webhook
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1', 'lucky': '1' })
@bp.route("/packages/")
def list_all():
qb = QueryBuilder(request.args)
@@ -70,7 +67,7 @@ def list_all():
if qb.lucky:
package = query.first()
if package:
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
topic = qb.buildTopicQuery().first()
if qb.search and topic:
@@ -103,7 +100,7 @@ def list_all():
selected_tags = set(qb.tags)
return render_template("packages/list.html",
title=title, packages=query.items, pagination=query,
query_hint=title, packages=query.items, pagination=query,
query=search, tags=tags, selected_tags=selected_tags, type=type_name,
authors=authors, packages_count=query.total, topics=topics)
@@ -118,26 +115,39 @@ def getReleases(package):
@bp.route("/packages/<author>/<name>/")
@is_package_page
def view(package):
alternatives = None
if package.type == PackageType.MOD:
alternatives = Package.query \
.filter_by(name=package.name, type=PackageType.MOD) \
.filter(Package.id != package.id, Package.state!=PackageState.DELETED) \
.order_by(db.desc(Package.score)) \
.all()
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
show_similar = not package.approved and (
current_user in package.maintainers or
package.checkPerm(current_user, Permission.APPROVE_NEW))
show_similar_topics = current_user == package.author or \
package.checkPerm(current_user, Permission.APPROVE_NEW)
conflicting_modnames = None
if show_similar and package.type != PackageType.TXP:
conflicting_modnames = db.session.query(MetaPackage.name) \
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
.filter(MetaPackage.packages.any(Package.id != package.id)) \
.all()
similar_topics = None if not show_similar_topics else \
ForumTopic.query \
.filter_by(name=package.name) \
conflicting_modnames += db.session.query(ForumTopic.name) \
.filter(ForumTopic.name.in_([ mp.name for mp in package.provides ])) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
conflicting_modnames = set([x[0] for x in conflicting_modnames])
packages_uses = None
if package.type == PackageType.MOD:
packages_uses = Package.query.filter(
Package.type == PackageType.MOD,
Package.id != package.id,
Package.state == PackageState.APPROVED,
Package.dependencies.any(
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
.order_by(db.desc(Package.score)).limit(6).all()
releases = getReleases(package)
review_thread = package.review_thread
@@ -149,16 +159,16 @@ def view(package):
if package.state != PackageState.APPROVED and package.forums is not None:
errors = []
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
errors.append("<b>Error: Another package already uses this forum topic!</b>")
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>")
topic_error_lvl = "danger"
topic = ForumTopic.query.get(package.forums)
if topic is not None:
if topic.author != package.author:
errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
topic_error_lvl = "danger"
elif package.type != PackageType.TXP:
errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
topic_error = "<br />".join(errors)
@@ -166,14 +176,14 @@ def view(package):
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated:
threads = threads.filter_by(private=False)
elif not current_user.rank.atLeast(UserRank.EDITOR) and not current_user == package.author:
elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author:
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
return render_template("packages/view.html",
package=package, releases=releases,
alternatives=alternatives, similar_topics=similar_topics,
package=package, releases=releases, packages_uses=packages_uses,
conflicting_modnames=conflicting_modnames,
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
threads=threads.all(), has_review=has_review)
@@ -182,7 +192,7 @@ def view(package):
@is_package_page
def shield(package, type):
if type == "title":
url = "https://img.shields.io/badge/ContentDB-{}-{}" \
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
.format(urlescape(package.title), urlescape("#375a7f"))
elif type == "downloads":
#api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
@@ -198,6 +208,9 @@ def shield(package, type):
@bp.route("/packages/<author>/<name>/download/")
@is_package_page
def download(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
release = package.getDownloadRelease()
if release is None:
@@ -205,10 +218,10 @@ def download(package):
not "text/html" in request.accept_mimetypes:
return "", 204
else:
flash("No download available.", "danger")
return redirect(package.getDetailsURL())
flash(gettext("No download available."), "danger")
return redirect(package.getURL("packages.view"))
else:
return redirect(release.getDownloadURL(), code=302)
return redirect(release.getDownloadURL())
def makeLabel(obj):
@@ -217,25 +230,28 @@ def makeLabel(obj):
else:
return obj.title
class PackageForm(FlaskForm):
type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
title = StringField("Title (Human-readable)", [InputRequired(), Length(3, 100)])
name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
type = SelectField(lazy_gettext("Type"), [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)])
tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
content_warnings = QuerySelectMultipleField('Content Warnings', query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
license = QuerySelectField("License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField("Media License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)])
submit = SubmitField("Save")
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/new/", methods=["GET", "POST"])
@@ -251,11 +267,11 @@ def create_edit(author=None, name=None):
else:
author = User.query.filter_by(username=author).first()
if author is None:
flash("Unable to find that user", "danger")
flash(gettext("Unable to find that user"), "danger")
return redirect(url_for("packages.create_edit"))
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash("Permission denied", "danger")
flash(gettext("Permission denied"), "danger")
return redirect(url_for("packages.create_edit"))
else:
@@ -263,7 +279,7 @@ def create_edit(author=None, name=None):
if package is None:
abort(404)
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
author = package.author
@@ -279,8 +295,6 @@ def create_edit(author=None, name=None):
form.license.data = None
form.media_license.data = None
else:
# form.harddep_str.data = ",".join([str(x) for x in package.getSortedHardDependencies() ])
# form.softdep_str.data = ",".join([str(x) for x in package.getSortedOptionalDependencies() ])
form.tags.data = list(package.tags)
form.content_warnings.data = list(package.content_warnings)
@@ -295,7 +309,7 @@ def create_edit(author=None, name=None):
if package.state == PackageState.READY_FOR_REVIEW:
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else:
flash("Package already exists!", "danger")
flash(gettext("Package already exists!"), "danger")
return redirect(url_for("packages.create_edit"))
package = Package()
@@ -304,11 +318,12 @@ def create_edit(author=None, name=None):
wasNew = True
try:
do_edit_package(current_user, package, wasNew, {
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
@@ -323,11 +338,11 @@ def create_edit(author=None, name=None):
if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id)
next_url = package.getDetailsURL()
next_url = package.getURL("packages.view")
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.getSetupReleasesURL()
next_url = package.getURL("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
@@ -341,7 +356,8 @@ def create_edit(author=None, name=None):
return render_template("packages/create_edit.html", package=package,
form=form, author=author, enable_wizard=enableWizard,
packages=package_query.all(),
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all())
mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
tabs=get_package_tabs(current_user, package), current_tab="edit")
@bp.route("/packages/<author>/<name>/state/", methods=["POST"])
@@ -353,14 +369,16 @@ def move_to_state(package):
abort(400)
if not package.canMoveToState(current_user, state):
flash("You don't have permission to do that", "danger")
return redirect(package.getDetailsURL())
flash(gettext("You don't have permission to do that"), "danger")
return redirect(package.getURL("packages.view"))
package.state = state
msg = "Marked {} as {}".format(package.title, state.value)
if state == PackageState.APPROVED:
if not package.approved_at:
post_discord_webhook.delay(package.author.username,
"New package {}".format(package.getURL("packages.view", absolute=True)), False)
package.approved_at = datetime.datetime.now()
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
@@ -368,21 +386,24 @@ def move_to_state(package):
s.approved = True
msg = "Approved {}".format(package.title)
elif state == PackageState.READY_FOR_REVIEW:
post_discord_webhook.delay(package.author.username,
"Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
db.session.commit()
if package.state == PackageState.CHANGES_NEEDED:
flash("Please comment what changes are needed in the review thread", "warning")
flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
if package.review_thread:
return redirect(package.review_thread.getViewURL())
else:
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@@ -390,48 +411,51 @@ def move_to_state(package):
@is_package_page
def remove(package):
if request.method == "GET":
return render_template("packages/remove.html", package=package)
return render_template("packages/remove.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="remove")
reason = request.form.get("reason") or "?"
if "delete" in request.form:
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
flash("You don't have permission to do that.", "danger")
return redirect(package.getDetailsURL())
flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.getURL("packages.view"))
package.state = PackageState.DELETED
url = url_for("users.profile", username=package.author.username)
msg = "Deleted {}".format(package.title)
msg = "Deleted {}, reason={}".format(package.title, reason)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
db.session.commit()
flash("Deleted package", "success")
flash(gettext("Deleted package"), "success")
return redirect(url)
elif "unapprove" in request.form:
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash("You don't have permission to do that.", "danger")
return redirect(package.getDetailsURL())
flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.getURL("packages.view"))
package.state = PackageState.WIP
msg = "Unapproved {}".format(package.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getDetailsURL(), package)
msg = "Unapproved {}, reason={}".format(package.title, reason)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package)
db.session.commit()
flash("Unapproved package", "success")
flash(gettext("Unapproved package"), "success")
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
else:
abort(400)
class PackageMaintainersForm(FlaskForm):
maintainers_str = StringField("Maintainers (Comma-separated)", [Optional()])
submit = SubmitField("Save")
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
@@ -439,8 +463,8 @@ class PackageMaintainersForm(FlaskForm):
@is_package_page
def edit_maintainers(package):
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
flash("You do not have permission to edit maintainers", "danger")
return redirect(package.getDetailsURL())
flash(gettext("You do not have permission to edit maintainers"), "danger")
return redirect(package.getURL("packages.view"))
form = PackageMaintainersForm(formdata=request.form)
if request.method == "GET":
@@ -450,15 +474,19 @@ def edit_maintainers(package):
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
users = User.query.filter(func.lower(User.username).in_(usernames)).all()
thread = package.threads.filter_by(author=get_system_user()).first()
for user in users:
if not user in package.maintainers:
if thread:
thread.watchers.append(user)
addNotification(user, current_user, NotificationType.MAINTAINER,
"Added you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
"Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
for user in package.maintainers:
if user != package.author and not user in users:
addNotification(user, current_user, NotificationType.MAINTAINER,
"Removed you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
"Removed you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
package.maintainers.clear()
package.maintainers.extend(users)
@@ -466,18 +494,18 @@ def edit_maintainers(package):
package.maintainers.append(package.author)
msg = "Edited {} maintainers".format(package.title)
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getDetailsURL(), package)
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
return render_template("packages/edit_maintainers.html",
package=package, form=form, users=users)
return render_template("packages/edit_maintainers.html", package=package, form=form,
users=users, tabs=get_package_tabs(current_user, package), current_tab="maintainers")
@bp.route("/packages/<author>/<name>/remove-self-maintainer/", methods=["POST"])
@@ -485,45 +513,111 @@ def edit_maintainers(package):
@is_package_page
def remove_self_maintainers(package):
if not current_user in package.maintainers:
flash("You are not a maintainer", "danger")
flash(gettext("You are not a maintainer"), "danger")
elif current_user == package.author:
flash("Package owners cannot remove themselves as maintainers", "danger")
flash(gettext("Package owners cannot remove themselves as maintainers"), "danger")
else:
package.maintainers.remove(current_user)
addNotification(package.author, current_user, NotificationType.MAINTAINER,
"Removed themself as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
"Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
@bp.route("/packages/<author>/<name>/import-meta/", methods=["POST"])
@bp.route("/packages/<author>/<name>/audit/")
@login_required
@is_package_page
def update_from_release(package):
if not package.checkPerm(current_user, Permission.REIMPORT_META):
flash("You don't have permission to reimport meta", "danger")
return redirect(package.getDetailsURL())
def audit(package):
if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
package.checkPerm(current_user, Permission.APPROVE_NEW)):
abort(403)
release = package.releases.first()
if not release:
flash("Release needed", "danger")
return redirect(package.getDetailsURL())
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
msg = "Updated meta from latest release"
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT,
msg, package.getDetailsURL(), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
db.session.commit()
pagination = query.paginate(page, num, True)
return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
task_id = uuid()
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
checkZipRelease.apply_async((release.id, zippath), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=package.getEditURL()))
class PackageAliasForm(FlaskForm):
author = StringField(lazy_gettext("Author Name"), [InputRequired(), Length(1, 50)])
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100),
Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/aliases/")
@rank_required(UserRank.EDITOR)
@is_package_page
def alias_list(package: Package):
return render_template("packages/alias_list.html", package=package)
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
@rank_required(UserRank.EDITOR)
@is_package_page
def alias_create_edit(package: Package, alias_id: int = None):
alias = None
if alias_id:
alias = PackageAlias.query.get(alias_id)
if alias is None or alias.package != package:
abort(404)
form = PackageAliasForm(request.form, obj=alias)
if form.validate_on_submit():
if alias is None:
alias = PackageAlias()
alias.package = package
db.session.add(alias)
form.populate_obj(alias)
db.session.commit()
return redirect(package.getURL("packages.alias_list"))
return render_template("packages/alias_create_edit.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/share/")
@login_required
@is_package_page
def share(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
return render_template("packages/share.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="share")
@bp.route("/packages/<author>/<name>/similar/")
@is_package_page
def similar(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
packages_modnames = {}
for metapackage in package.provides:
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
Package.state != PackageState.DELETED) \
.filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.id)) \
.order_by(db.desc(Package.score)) \
.all()
similar_topics = ForumTopic.query \
.filter_by(name=package.name) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
return render_template("packages/similar.html", package=package,
packages_modnames=packages_modnames, similar_topics=similar_topics)

View File

@@ -16,6 +16,7 @@
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import *
@@ -26,7 +27,18 @@ from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_
from app.rediscache import has_key, set_key, make_download_key
from app.tasks.importtasks import check_update_config
from app.utils import *
from . import bp
from . import bp, get_package_tabs
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
@is_package_page
def list_releases(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
return render_template("packages/releases_list.html",
package=package,
tabs=get_package_tabs(current_user, package), current_tab="releases")
def get_mt_releases(is_max):
@@ -40,38 +52,39 @@ def get_mt_releases(is_max):
class CreatePackageReleaseForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 30)])
uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
vcsLabel = StringField("Git reference (ie: commit hash, branch, or tag)", default=None)
fileUpload = FileField("File Upload")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload")
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
fileUpload = FileField(lazy_gettext("File Upload"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
class EditPackageReleaseForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 30)])
url = StringField("URL", [Optional()])
task_id = StringField("Task ID", filters = [lambda x: x or None])
approved = BooleanField("Is Approved")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
url = StringField(lazy_gettext("URL"), [Optional()])
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None])
approved = BooleanField(lazy_gettext("Is Approved"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
@login_required
@is_package_page
def create_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data
form = CreatePackageReleaseForm()
if package.repo is not None:
form["uploadOpt"].choices = [("vcs", "Import from Git"), ("upload", "Upload .zip file")]
form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))]
if request.method == "GET":
form["uploadOpt"].data = "vcs"
form.vcsLabel.data = request.args.get("ref")
@@ -97,6 +110,9 @@ def create_release(package):
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
@is_package_page
def download_release(package, id):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
release = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
@@ -121,7 +137,7 @@ def download_release(package, id):
db.session.commit()
return redirect(release.url, code=300)
return redirect(release.url)
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
@@ -133,9 +149,9 @@ def edit_release(package, id):
abort(404)
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
canApprove = package.checkPerm(current_user, Permission.APPROVE_RELEASE)
canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
if not (canEdit or canApprove):
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data
form = EditPackageReleaseForm(formdata=request.form, obj=release)
@@ -162,21 +178,21 @@ def edit_release(package, id):
release.approved = False
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.list_releases"))
return render_template("packages/release_edit.html", package=package, release=release, form=form)
class BulkReleaseForm(FlaskForm):
set_min = BooleanField("Set Min")
min_rel = QuerySelectField("Minimum Minetest Version", [InputRequired()],
set_min = BooleanField(lazy_gettext("Set Min"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name)
set_max = BooleanField("Set Max")
max_rel = QuerySelectField("Maximum Minetest Version", [InputRequired()],
set_max = BooleanField(lazy_gettext("Set Max"))
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
only_change_none = BooleanField("Only change values previously set as none")
submit = SubmitField("Update")
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none"))
submit = SubmitField(lazy_gettext("Update"))
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@@ -184,7 +200,7 @@ class BulkReleaseForm(FlaskForm):
@is_package_page
def bulk_change_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data
form = BulkReleaseForm()
@@ -202,7 +218,7 @@ def bulk_change_release(package):
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.list_releases"))
return render_template("packages/release_bulk_change.html", package=package, form=form)
@@ -216,21 +232,25 @@ def delete_release(package, id):
abort(404)
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
return redirect(release.getEditURL())
return redirect(package.getURL("packages.list_releases"))
db.session.delete(release)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
class PackageUpdateConfigFrom(FlaskForm):
trigger = RadioField("Trigger", [InputRequired()], choices=PackageUpdateTrigger.choices(), coerce=PackageUpdateTrigger.coerce,
default=PackageUpdateTrigger.TAG)
ref = StringField("Branch name", [Optional()], default=None)
action = RadioField("Action", [InputRequired()], choices=[("notification", "Send notification and mark as outdated"), ("make_release", "Create release")], default="make_release")
submit = SubmitField("Save Settings")
disable = SubmitField("Disable Automation")
trigger = RadioField(lazy_gettext("Trigger"), [InputRequired()],
choices=[(PackageUpdateTrigger.COMMIT, lazy_gettext("New Commit")),
(PackageUpdateTrigger.TAG, lazy_gettext("New Tag"))],
coerce=PackageUpdateTrigger.coerce, default=PackageUpdateTrigger.TAG)
ref = StringField(lazy_gettext("Branch name"), [Optional()], default=None)
action = RadioField(lazy_gettext("Action"), [InputRequired()],
choices=[("notification", lazy_gettext("Send notification and mark as outdated")), ("make_release", lazy_gettext("Create release"))],
default="make_release")
submit = SubmitField(lazy_gettext("Save Settings"))
disable = SubmitField(lazy_gettext("Disable Automation"))
def set_update_config(package, form):
@@ -269,8 +289,8 @@ def update_config(package):
abort(403)
if not package.repo:
flash("Please add a Git repository URL in order to set up automatic releases", "danger")
return redirect(package.getEditURL())
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
return redirect(package.getURL("packages.create_edit"))
form = PackageUpdateConfigFrom(obj=package.update_config)
if request.method == "GET":
@@ -280,9 +300,12 @@ def update_config(package):
form.trigger.data = PackageUpdateTrigger.COMMIT
form.action.data = "notification"
if "trigger" in request.args:
form.trigger.data = PackageUpdateTrigger.get(request.args["trigger"])
if form.validate_on_submit():
if form.disable.data:
flash("Deleted update configuration", "success")
flash(gettext("Deleted update configuration"), "success")
if package.update_config:
db.session.delete(package.update_config)
db.session.commit()
@@ -290,10 +313,10 @@ def update_config(package):
set_update_config(package, form)
if not form.disable.data and package.releases.count() == 0:
flash("Now, please create an initial release", "success")
return redirect(package.getCreateReleaseURL())
flash(gettext("Now, please create an initial release"), "success")
return redirect(package.getURL("packages.create_release"))
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.list_releases"))
return render_template("packages/update_config.html", package=package, form=form)
@@ -306,7 +329,7 @@ def setup_releases(package):
abort(403)
if package.update_config:
return redirect(package.getUpdateConfigURL())
return redirect(package.getURL("packages.update_config"))
return render_template("packages/release_wizard.html", package=package)

View File

@@ -13,6 +13,9 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import namedtuple
from flask_babel import gettext, lazy_gettext
from . import bp
@@ -21,8 +24,10 @@ from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType
from app.utils import is_package_page, addNotification, get_int_or_abort
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
Permission
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required
from app.tasks.webhooktasks import post_discord_webhook
@bp.route("/reviews/")
@@ -35,18 +40,22 @@ def list_reviews():
class ReviewForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
recommends = RadioField("Private", [InputRequired()], choices=[("yes", "Yes"), ("no", "No")])
submit = SubmitField("Save")
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
recommends = RadioField(lazy_gettext("Private"), [InputRequired()],
choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
@login_required
@is_package_page
def review(package):
if current_user in package.maintainers:
flash("You can't review your own package!", "danger")
return redirect(package.getDetailsURL())
flash(gettext("You can't review your own package!"), "danger")
return redirect(package.getURL("packages.view"))
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
review = PackageReview.query.filter_by(package=package, author=current_user).first()
@@ -108,9 +117,13 @@ def review(package):
addNotification(package.maintainers, current_user, type, notif_msg,
url_for("threads.view", id=thread.id), package)
if was_new:
post_discord_webhook.delay(thread.author.username,
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
db.session.commit()
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
return render_template("packages/review_create_edit.html",
form=form, package=package, review=review)
@@ -141,3 +154,78 @@ def delete_review(package):
db.session.commit()
return redirect(thread.getViewURL())
def handle_review_vote(package: Package, review_id: int):
if current_user in package.maintainers:
flash(gettext("You can't vote on the reviews on your own package!"), "danger")
return
review: PackageReview = PackageReview.query.get(review_id)
if review is None or review.package != package:
abort(404)
if review.author == current_user:
flash(gettext("You can't vote on your own reviews!"), "danger")
return
is_positive = isYes(request.form["is_positive"])
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
if vote is None:
vote = PackageReviewVote()
vote.review = review
vote.user = current_user
vote.is_positive = is_positive
db.session.add(vote)
elif vote.is_positive == is_positive:
db.session.delete(vote)
else:
vote.is_positive = is_positive
review.update_score()
db.session.commit()
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
@login_required
@is_package_page
def review_vote(package, review_id):
handle_review_vote(package, review_id)
next_url = request.args.get("r")
if next_url and is_safe_url(next_url):
return redirect(next_url)
else:
return redirect(review.thread.getViewURL())
@bp.route("/packages/<author>/<name>/review-votes/")
@rank_required(UserRank.ADMIN)
@is_package_page
def review_votes(package):
user_biases = {}
for review in package.reviews:
review_sign = 1 if review.recommends else -1
for vote in review.votes:
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
vote_sign = 1 if vote.is_positive else -1
vote_bias = review_sign * vote_sign
if vote_bias == 1:
user_biases[vote.user.username][0] += 1
else:
user_biases[vote.user.username][1] += 1
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
user_biases_info = []
for username, bias in user_biases.items():
total_votes = bias[0] + bias[1]
balance = bias[0] - bias[1]
perc_with = round((100 * bias[0]) / total_votes)
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(package.reviews) - total_votes, perc_with))
user_biases_info.sort(key=lambda x: -abs(x.balance))
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
user_biases=user_biases_info)

View File

@@ -16,6 +16,7 @@
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_wtf import FlaskForm
from flask_login import login_required
from wtforms import *
@@ -23,26 +24,26 @@ from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.utils import *
from . import bp
from . import bp, get_package_tabs
from app.logic.LogicError import LogicError
from app.logic.screenshots import do_create_screenshot, do_order_screenshots
class CreateScreenshotForm(FlaskForm):
title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
fileUpload = FileField("File Upload", [InputRequired()])
submit = SubmitField("Save")
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()])
submit = SubmitField(lazy_gettext("Save"))
class EditScreenshotForm(FlaskForm):
title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
approved = BooleanField("Is Approved")
submit = SubmitField("Save")
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
approved = BooleanField(lazy_gettext("Is Approved"))
submit = SubmitField(lazy_gettext("Save"))
class EditPackageScreenshotsForm(FlaskForm):
cover_image = QuerySelectField("Cover Image", [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField("Save")
cover_image = QuerySelectField(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
@@ -50,10 +51,10 @@ class EditPackageScreenshotsForm(FlaskForm):
@is_package_page
def screenshots(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
if package.screenshots.count() == 0:
return redirect(package.getNewScreenshotURL())
return redirect(package.getURL("packages.create_screenshot"))
form = EditPackageScreenshotsForm(obj=package)
form.cover_image.query = package.screenshots
@@ -63,7 +64,7 @@ def screenshots(package):
if order:
try:
do_order_screenshots(current_user, package, order.split(","))
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
except LogicError as e:
flash(e.message, "danger")
@@ -71,7 +72,8 @@ def screenshots(package):
form.populate_obj(package)
db.session.commit()
return render_template("packages/screenshots.html", package=package, form=form)
return render_template("packages/screenshots.html", package=package, form=form,
tabs=get_package_tabs(current_user, package), current_tab="screenshots")
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
@@ -79,14 +81,14 @@ def screenshots(package):
@is_package_page
def create_screenshot(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data
form = CreateScreenshotForm()
if form.validate_on_submit():
try:
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
return redirect(package.getEditScreenshotsURL())
return redirect(package.getURL("packages.screenshots"))
except LogicError as e:
flash(e.message, "danger")
@@ -104,7 +106,7 @@ def edit_screenshot(package, id):
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
if not (canEdit or canApprove):
return redirect(package.getEditScreenshotsURL())
return redirect(package.getURL("packages.screenshots"))
# Initial form class from post data and default data
form = EditScreenshotForm(obj=screenshot)
@@ -120,7 +122,7 @@ def edit_screenshot(package, id):
screenshot.approved = wasApproved
db.session.commit()
return redirect(package.getEditScreenshotsURL())
return redirect(package.getURL("packages.screenshots"))
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
@@ -134,7 +136,7 @@ def delete_screenshot(package, id):
abort(404)
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
flash("Permission denied", "danger")
flash(gettext("Permission denied"), "danger")
return redirect(url_for("homepage.home"))
if package.cover_image == screenshot:
@@ -144,4 +146,4 @@ def delete_screenshot(package, id):
db.session.delete(screenshot)
db.session.commit()
return redirect(package.getEditScreenshotsURL())
return redirect(package.getURL("packages.screenshots"))

View File

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

View File

@@ -14,19 +14,22 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_babel import gettext, lazy_gettext
from app.markdown import get_user_mentions, render_markdown
from app.tasks.webhooktasks import post_discord_webhook
bp = Blueprint("threads", __name__)
from flask_login import current_user, login_required
from app import menu
from app.models import *
from app.utils import addNotification, isYes, addAuditLog
from app.utils import addNotification, isYes, addAuditLog, get_system_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.utils import get_int_or_abort
@menu.register_menu(bp, ".threads", "Threads", order=20)
@bp.route("/threads/")
def list_all():
query = Thread.query
@@ -58,9 +61,9 @@ def subscribe(id):
abort(404)
if current_user in thread.watchers:
flash("Already subscribed!", "success")
flash(gettext("Already subscribed!"), "success")
else:
flash("Subscribed to thread", "success")
flash(gettext("Subscribed to thread"), "success")
thread.watchers.append(current_user)
db.session.commit()
@@ -75,11 +78,11 @@ def unsubscribe(id):
abort(404)
if current_user in thread.watchers:
flash("Unsubscribed!", "success")
flash(gettext("Unsubscribed!"), "success")
thread.watchers.remove(current_user)
db.session.commit()
else:
flash("Already not subscribed!", "success")
flash(gettext("Already not subscribed!"), "success")
return redirect(thread.getViewURL())
@@ -98,13 +101,13 @@ def set_lock(id):
msg = None
if thread.locked:
msg = "Locked thread '{}'".format(thread.title)
flash("Locked thread", "success")
flash(gettext("Locked thread"), "success")
else:
msg = "Unlocked thread '{}'".format(thread.title)
flash("Unlocked thread", "success")
flash(gettext("Unlocked thread"), "success")
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(AuditSeverity.MODERATION, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
db.session.commit()
@@ -150,7 +153,7 @@ def delete_reply(id):
abort(404)
if thread.replies[0] == reply:
flash("Cannot delete thread opening post!", "danger")
flash(gettext("Cannot delete thread opening post!"), "danger")
return redirect(thread.getViewURL())
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
@@ -169,8 +172,8 @@ def delete_reply(id):
class CommentForm(FlaskForm):
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
submit = SubmitField("Comment")
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
submit = SubmitField(lazy_gettext("Comment"))
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@@ -211,7 +214,7 @@ def edit_reply(id):
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id):
thread = Thread.query.get(id)
thread: Thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
@@ -219,11 +222,11 @@ def view(id):
comment = request.form["comment"]
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
flash("You cannot comment on this thread", "danger")
flash(gettext("You cannot comment on this thread"), "danger")
return redirect(thread.getViewURL())
if not current_user.canCommentRL():
flash("Please wait before commenting again", "danger")
flash(gettext("Please wait before commenting again"), "danger")
return redirect(thread.getViewURL())
if 2000 >= len(comment) > 3:
@@ -236,23 +239,40 @@ def view(id):
if not current_user in thread.watchers:
thread.watchers.append(current_user)
for mentioned_username in get_user_mentions(render_markdown(comment)):
mentioned = User.query.filter_by(username=mentioned_username)
if mentioned is None:
continue
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
msg, thread.getViewURL(), thread.package)
msg = "New comment on '{}'".format(thread.title)
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
if thread.author == get_system_user():
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
thread.getViewURL(), thread.package)
post_discord_webhook.delay(current_user.username,
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
db.session.commit()
return redirect(thread.getViewURL())
else:
flash("Comment needs to be between 3 and 2000 characters.")
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
return render_template("threads/view.html", thread=thread)
class ThreadForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)])
comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
private = BooleanField("Private")
submit = SubmitField("Open Thread")
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
private = BooleanField(lazy_gettext("Private"))
submit = SubmitField(lazy_gettext("Open Thread"))
@bp.route("/threads/new/", methods=["GET", "POST"])
@@ -264,7 +284,7 @@ def new():
if "pid" in request.args:
package = Package.query.get(int(request.args.get("pid")))
if package is None:
flash("Unable to find that package!", "danger")
flash(gettext("Unable to find that package!"), "danger")
# Don't allow making orphan threads on approved packages for now
if package is None:
@@ -278,19 +298,19 @@ def new():
# Check that user can make the thread
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
flash("Unable to create thread!", "danger")
flash(gettext("Unable to create thread!"), "danger")
return redirect(url_for("homepage.home"))
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
flash("A review thread already exists!", "danger")
flash(gettext("An approval thread already exists!"), "danger")
return redirect(package.review_thread.getViewURL())
elif not current_user.canOpenThreadRL():
flash("Please wait before opening another thread", "danger")
flash(gettext("Please wait before opening another thread"), "danger")
if package:
return redirect(package.getDetailsURL())
return redirect(package.getURL("packages.view"))
else:
return redirect(url_for("homepage.home"))
@@ -325,16 +345,26 @@ def new():
if is_review_thread:
package.review_thread = thread
if package.state == PackageState.READY_FOR_REVIEW and current_user not in package.maintainers:
package.state = PackageState.CHANGES_NEEDED
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
mentioned = User.query.filter_by(username=mentioned_username)
if mentioned is None:
continue
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
msg, thread.getViewURL(), thread.package)
notif_msg = "New thread '{}'".format(thread.title)
if package is not None:
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
editors = User.query.filter(User.rank >= UserRank.EDITOR).all()
addNotification(editors, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
if is_review_thread:
post_discord_webhook.delay(current_user.username,
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
db.session.commit()
@@ -342,3 +372,12 @@ def new():
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
@bp.route("/users/<username>/comments/")
def user_comments(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
return render_template("threads/user_comments.html", user=user, replies=user.replies)

View File

@@ -63,18 +63,24 @@ def view_editor():
else:
abort(400)
license_needed = Package.query \
.filter(Package.state.in_([PackageState.READY_FOR_REVIEW, PackageState.APPROVED])) \
.filter(or_(Package.license.has(License.name.like("Other %")),
Package.media_license.has(License.name.like("Other %")))) \
.all()
total_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
unfulfilled_meta_packages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(optional=False)) \
.filter(MetaPackage.dependencies.any(Package.state == PackageState.APPROVED, optional=False)) \
.order_by(db.asc(MetaPackage.name)).count()
return render_template("todo/editor.html", current_tab="editor",
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
total_packages=total_packages, total_to_tag=total_to_tag,
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
unfulfilled_meta_packages=unfulfilled_meta_packages)
@@ -93,7 +99,7 @@ def topics():
page = get_int_or_abort(request.args.get("page"), 1)
num = get_int_or_abort(request.args.get("n"), 100)
if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
if num > 100 and not current_user.rank.atLeast(UserRank.APPROVER):
num = 100
query = query.paginate(page, num, True)
@@ -117,9 +123,14 @@ def tags():
qb.setSortIfNone("score", "desc")
query = qb.buildPackageQuery()
only_no_tags = isYes(request.args.get("no_tags"))
if only_no_tags:
query = query.filter(Package.tags==None)
tags = Tag.query.order_by(db.asc(Tag.title)).all()
return render_template("todo/tags.html", current_tab="tags", packages=query.all(), tags=tags)
return render_template("todo/tags.html", current_tab="tags", packages=query.all(), \
tags=tags, only_no_tags=only_no_tags)
@bp.route("/user/tags/")
@@ -149,7 +160,7 @@ def view_user(username=None):
if not user:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
if current_user != user and not current_user.rank.atLeast(UserRank.APPROVER):
abort(403)
unapproved_packages = user.packages \
@@ -216,8 +227,8 @@ def apply_all_updates(username):
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
rel.getEditURL(), package)
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getDetailsURL(), package)
rel.getURL("packages.create_edit"), package)
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
db.session.commit()
return redirect(url_for("todo.view_user", username=username))

View File

@@ -15,7 +15,9 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
@@ -24,23 +26,24 @@ from wtforms.validators import *
from app.models import *
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, nonEmptyOrNone
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
nonEmptyOrNone, post_login, is_username_valid
from passlib.pwd import genphrase
from . import bp
class LoginForm(FlaskForm):
username = StringField("Username or email", [InputRequired()])
password = PasswordField("Password", [InputRequired(), Length(6, 100)])
remember_me = BooleanField("Remember me", default=True)
submit = SubmitField("Sign in")
username = StringField(lazy_gettext("Username or email"), [InputRequired()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
remember_me = BooleanField(lazy_gettext("Remember me"), default=True)
submit = SubmitField(lazy_gettext("Sign in"))
def handle_login(form):
def show_safe_err(err):
if "@" in username:
flash("Incorrect email or password", "danger")
flash(gettext("Incorrect email or password"), "danger")
else:
flash(err, "danger")
@@ -48,27 +51,24 @@ def handle_login(form):
username = form.username.data.strip()
user = User.query.filter(or_(User.username == username, User.email == username)).first()
if user is None:
return show_safe_err("User {} does not exist".format(username))
return show_safe_err(gettext(u"User %(username)s does not exist", username=username))
if not check_password_hash(user.password, form.password.data):
return show_safe_err("Incorrect password. Did you set one?")
return show_safe_err(gettext(u"Incorrect password. Did you set one?"))
if not user.is_active:
flash("You need to confirm the registration email", "danger")
flash(gettext("You need to confirm the registration email"), "danger")
return
addAuditLog(AuditSeverity.USER, user, "Logged in using password",
url_for("users.profile", username=user.username))
db.session.commit()
login_user(user, remember=form.remember_me.data)
flash("Logged in successfully.", "success")
if not login_user(user, remember=form.remember_me.data):
flash(gettext("Login failed"), "danger")
return
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return redirect(next or url_for("homepage.home"))
return post_login(user, request.args.get("next"))
@bp.route("/user/login/", methods=["GET", "POST"])
@@ -100,42 +100,63 @@ def logout():
class RegisterForm(FlaskForm):
username = StringField("Username", [InputRequired()])
email = StringField("Email", [InputRequired(), Email()])
password = PasswordField("Password", [InputRequired(), Length(6, 100)])
submit = SubmitField("Register")
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone])
username = StringField(lazy_gettext("Username"), [InputRequired(),
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
submit = SubmitField(lazy_gettext("Register"))
def handle_register(form):
if form.question.data.strip().lower() != "19":
flash(gettext("Incorrect captcha answer"), "danger")
return
if not is_username_valid(form.username.data):
flash(gettext("Username is invalid"))
return
user_by_name = User.query.filter(or_(
User.username == form.username.data,
User.username == form.display_name.data,
User.display_name == form.display_name.data,
User.forums_username == form.username.data,
User.github_username == form.username.data)).first()
if user_by_name:
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
flash("An account already exists for that username but hasn't been claimed yet.", "danger")
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger")
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
else:
flash("That username is already in use, please choose another.", "danger")
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author==form.username.data,
PackageAlias.author==form.display_name.data)).first()
if alias_by_name:
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, "Email already in use",
"We were unable to create the account as the email is already in use by {}. Try a different email address.".format(
user_by_email.display_name))
flash("Check your email address to verify your account", "success")
return redirect(url_for("homepage.home"))
send_anon_email.delay(form.email.data, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent"))
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
user.notification_preferences = UserNotificationPreferences(user)
if form.display_name.data:
user.display_name = form.display_name.data
db.session.add(user)
addAuditLog(AuditSeverity.USER, user, "Registered with email",
addAuditLog(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
url_for("users.profile", username=user.username))
token = randomString(32)
@@ -149,8 +170,7 @@ def handle_register(form):
send_verify_email.delay(form.email.data, token)
flash("Check your email address to verify your account", "success")
return redirect(url_for("homepage.home"))
return redirect(url_for("users.email_sent"))
@bp.route("/user/register/", methods=["GET", "POST"])
@@ -161,12 +181,13 @@ def register():
if ret:
return ret
return render_template("users/register.html", form=form, suggested_password=genphrase(entropy=52, wordset="bip39"))
return render_template("users/register.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
class ForgotPasswordForm(FlaskForm):
email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField("Reset Password")
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password"))
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password():
@@ -195,35 +216,44 @@ def forgot_password():
We were unable to perform the password reset as we could not find an account
associated with this email.
</p>
<p>
This may be because you used another email with your account, or because you never
confirmed your email.
</p>
<p>
You can use GitHub to log in if it is associated with your account.
Otherwise, you may need to contact rubenwardy for help.
</p>
<p>
If you weren't expecting to receive this email, then you can safely ignore it.
</p>
""")
flash("Check your email address to continue the reset", "success")
return redirect(url_for("homepage.home"))
return redirect(url_for("users.email_sent"))
return render_template("users/forgot_password.html", form=form)
class SetPasswordForm(FlaskForm):
email = StringField("Email", [Optional(), Email()])
password = PasswordField("New password", [InputRequired(), Length(8, 100)])
password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
submit = SubmitField("Save")
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save"))
class ChangePasswordForm(FlaskForm):
old_password = PasswordField("Old password", [InputRequired(), Length(8, 100)])
password = PasswordField("New password", [InputRequired(), Length(8, 100)])
password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
submit = SubmitField("Save")
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save"))
def handle_set_password(form):
one = form.password.data
two = form.password2.data
if one != two:
flash("Passwords do not much", "danger")
flash(gettext("Passwords do not match"), "danger")
return
addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
@@ -234,19 +264,31 @@ def handle_set_password(form):
newEmail = nonEmptyOrNone(form.email.data)
if newEmail and newEmail != current_user.email:
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
token = randomString(32)
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, gettext("Email already in use"),
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
else:
token = randomString(32)
ver = UserEmailVerification()
ver.user = current_user
ver.token = token
ver.email = newEmail
db.session.add(ver)
ver = UserEmailVerification()
ver.user = current_user
ver.token = token
ver.email = newEmail
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token)
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("users.email_sent"))
db.session.commit()
flash("Your password has been changed successfully.", "success")
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("homepage.home"))
@@ -261,7 +303,7 @@ def change_password():
if ret:
return ret
else:
flash("Old password is incorrect", "danger")
flash(gettext("Old password is incorrect"), "danger")
return render_template("users/change_set_password.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
@@ -289,9 +331,17 @@ def set_password():
@bp.route("/user/verify/")
def verify_email():
token = request.args.get("token")
ver : UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
if ver is None:
flash("Unknown verification token!", "danger")
flash(gettext("Unknown verification token!"), "danger")
return redirect(url_for("homepage.home"))
delta = (datetime.datetime.now() - ver.created_at)
delta: datetime.timedelta
if delta.total_seconds() > 12*60*60:
flash(gettext("Token has expired"), "danger")
db.session.delete(ver)
db.session.commit()
return redirect(url_for("homepage.home"))
user = ver.user
@@ -303,15 +353,15 @@ def verify_email():
if ver.email and user.email != ver.email:
if User.query.filter_by(email=ver.email).count() > 0:
flash("Another user is already using that email", "danger")
flash(gettext("Another user is already using that email"), "danger")
return redirect(url_for("homepage.home"))
flash("Confirmed email change", "success")
flash(gettext("Confirmed email change"), "success")
if user.email:
send_user_email.delay(user.email,
"Email address changed",
"Your email address has changed. If you didn't request this, please contact an administrator.")
gettext("Email address changed"),
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
user.is_active = True
user.email = ver.email
@@ -329,15 +379,15 @@ def verify_email():
if current_user.is_authenticated:
return redirect(url_for("users.profile", username=current_user.username))
elif was_activating:
flash("You may now log in", "success")
flash(gettext("You may now log in"), "success")
return redirect(url_for("users.login"))
else:
return redirect(url_for("homepage.home"))
class UnsubscribeForm(FlaskForm):
email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField("Send")
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Send"))
def unsubscribe_verify():
@@ -353,8 +403,7 @@ def unsubscribe_verify():
db.session.commit()
send_unsubscribe_verify.delay(form.email.data)
flash("Check your email address to continue the unsubscribe", "success")
return redirect(url_for("homepage.home"))
return redirect(url_for("users.email_sent"))
return render_template("users/unsubscribe.html", form=form)
@@ -369,7 +418,7 @@ def unsubscribe_manage(sub: EmailSubscription):
sub.blacklisted = True
db.session.commit()
flash("That email is now blacklisted. Please contact an admin if you wish to undo this.", "success")
flash(gettext("That email is now blacklisted. Please contact an admin if you wish to undo this."), "success")
return redirect(url_for("homepage.home"))
return render_template("users/unsubscribe.html", user=user)
@@ -384,3 +433,8 @@ def unsubscribe():
return unsubscribe_manage(sub)
return unsubscribe_verify()
@bp.route("/email_sent/")
def email_sent():
return render_template("users/email_sent.html")

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_login import current_user, login_required, logout_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
from wtforms import *
from wtforms.validators import *
@@ -11,34 +13,79 @@ from . import bp
def get_setting_tabs(user):
return [
ret = [
{
"id": "edit_profile",
"title": "Edit Profile",
"title": gettext("Edit Profile"),
"url": url_for("users.profile_edit", username=user.username)
},
{
"id": "account",
"title": "Account and Security",
"title": gettext("Account and Security"),
"url": url_for("users.account", username=user.username)
},
{
"id": "notifications",
"title": "Email and Notifications",
"title": gettext("Email and Notifications"),
"url": url_for("users.email_notifications", username=user.username)
},
{
"id": "api_tokens",
"title": "API Tokens",
"title": gettext("API Tokens"),
"url": url_for("api.list_tokens", username=user.username)
},
]
if current_user.rank.atLeast(UserRank.MODERATOR):
ret.append({
"id": "modtools",
"title": gettext("Moderator Tools"),
"url": url_for("users.modtools", username=user.username)
})
return ret
class UserProfileForm(FlaskForm):
website_url = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
donate_url = StringField("Donation URL", [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField("Save")
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField(lazy_gettext("Save"))
def handle_profile_edit(form, user, username):
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
url_for("users.profile", username=username))
if user.checkPerm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
user.display_name != form.display_name.data:
if User.query.filter(User.id != user.id,
or_(User.username == form.display_name.data,
User.display_name.ilike(form.display_name.data))).count() > 0:
flash(gettext("A user already has that name"), "danger")
return None
alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author == form.display_name.data)).first()
if alias_by_name:
flash(gettext("A user already has that name"), "danger")
return
user.display_name = form.display_name.data
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
addAuditLog(severity, current_user, "Changed display name of {} to {}"
.format(user.username, user.display_name),
url_for("users.profile", username=username))
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
user.website_url = form["website_url"].data
user.donate_url = form["donate_url"].data
db.session.commit()
return redirect(url_for("users.profile", username=username))
@bp.route("/users/<username>/settings/profile/", methods=["GET", "POST"])
@@ -49,22 +96,14 @@ def profile_edit(username):
abort(404)
if not user.can_see_edit_profile(current_user):
flash("Permission denied", "danger")
flash(gettext("Permission denied"), "danger")
return redirect(url_for("users.profile", username=username))
form = UserProfileForm(obj=user)
if form.validate_on_submit():
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
url_for("users.profile", username=username))
if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
user.website_url = form["website_url"].data
user.donate_url = form["donate_url"].data
db.session.commit()
return redirect(url_for("users.profile", username=username))
ret = handle_profile_edit(form, user, username)
if ret:
return ret
# Process GET or invalid POST
return render_template("users/profile_edit.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="edit_profile")
@@ -72,8 +111,8 @@ def profile_edit(username):
def make_settings_form():
attrs = {
"email": StringField("Email", [Optional(), Email()]),
"submit": SubmitField("Save")
"email": StringField(lazy_gettext("Email"), [Optional(), Email()]),
"submit": SubmitField(lazy_gettext("Save"))
}
for notificationType in NotificationType:
@@ -100,7 +139,7 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
newEmail = form.email.data
if newEmail and newEmail != user.email and newEmail.strip() != "":
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
token = randomString(32)
@@ -117,10 +156,8 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
db.session.add(ver)
db.session.commit()
flash("Check your email to confirm it", "success")
send_verify_email.delay(newEmail, token)
return redirect(url_for("users.email_notifications", username=user.username))
return redirect(url_for("users.email_sent"))
db.session.commit()
return redirect(url_for("users.email_notifications", username=user.username))
@@ -166,36 +203,98 @@ def email_notifications(username=None):
tabs=get_setting_tabs(user), current_tab="notifications")
class UserAccountForm(FlaskForm):
display_name = StringField("Display name", [Optional(), Length(2, 100)])
forums_username = StringField("Forums Username", [Optional(), Length(2, 50)])
github_username = StringField("GitHub Username", [Optional(), Length(2, 50)])
rank = SelectField("Rank", [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
default=UserRank.NEW_MEMBER)
submit = SubmitField("Save")
@bp.route("/users/<username>/settings/account/", methods=["GET", "POST"])
@bp.route("/users/<username>/settings/account/")
@login_required
def account(username):
user : User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.can_see_edit_profile(current_user):
flash("Permission denied", "danger")
return redirect(url_for("users.profile", username=username))
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
can_edit_account_settings = user.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
user.checkPerm(current_user, Permission.CHANGE_RANK)
form = UserAccountForm(obj=user) if can_edit_account_settings else None
if form and form.validate_on_submit():
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def delete(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if user.rank.atLeast(UserRank.MODERATOR):
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
return redirect(url_for("users.account", username=username))
if request.method == "GET":
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
if "delete" in request.form and (user.can_delete() or current_user.rank.atLeast(UserRank.ADMIN)):
msg = "Deleted user {}".format(user.username)
flash(msg, "success")
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
if current_user.rank.atLeast(UserRank.ADMIN):
for pkg in user.packages.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.delete(user)
elif "deactivate" in request.form:
user.replies.delete()
for thread in user.threads.all():
db.session.delete(thread)
user.email = None
user.rank = UserRank.NOT_JOINED
msg = "Deactivated user {}".format(user.username)
flash(msg, "success")
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
else:
assert False
db.session.commit()
if user == current_user:
logout_user()
return redirect(url_for("homepage.home"))
class ModToolsForm(FlaskForm):
username = StringField(lazy_gettext("Username"), [Optional(), Length(1, 50)])
display_name = StringField(lazy_gettext("Display name"), [Optional(), Length(2, 100)])
forums_username = StringField(lazy_gettext("Forums Username"), [Optional(), Length(2, 50)])
github_username = StringField(lazy_gettext("GitHub Username"), [Optional(), Length(2, 50)])
rank = SelectField(lazy_gettext("Rank"), [Optional()], choices=UserRank.choices(), coerce=UserRank.coerce,
default=UserRank.NEW_MEMBER)
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/users/<username>/modtools/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def modtools(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
abort(403)
form = ModToolsForm(obj=user)
if form.validate_on_submit():
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
addAuditLog(severity, current_user, "Edited {}'s account".format(user.display_name),
url_for("users.profile", username=username))
# Copy form fields to user_profile fields
if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
if user.username != form.username.data:
for package in user.packages:
alias = PackageAlias(user.username, package.name)
package.aliases.append(alias)
db.session.add(alias)
user.username = form.username.data
user.display_name = form.display_name.data
user.forums_username = nonEmptyOrNone(form.forums_username.data)
user.github_username = nonEmptyOrNone(form.github_username.data)
@@ -207,51 +306,63 @@ def account(username):
user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
addAuditLog(AuditSeverity.MODERATION, current_user, msg,
url_for("users.profile", username=username))
url_for("users.profile", username=username))
else:
flash("Can't promote a user to a rank higher than yourself!", "danger")
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
db.session.commit()
return redirect(url_for("users.account", username=username))
return redirect(url_for("users.modtools", username=username))
return render_template("users/account.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="account")
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")
@bp.route("/users/<username>/delete/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def delete(username):
@bp.route("/users/<username>/modtools/set-email/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_set_email(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if user.rank.atLeast(UserRank.MODERATOR):
flash("Users with moderator rank or above cannot be deleted", "danger")
return redirect(url_for("users.account", username=username))
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
abort(403)
if request.method == "GET":
return render_template("users/delete.html", user=user, can_delete=user.can_delete())
user.email = request.form["email"]
user.is_active = False
if user.can_delete():
msg = "Deleted user {}".format(user.username)
flash(msg, "success")
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
db.session.delete(user)
else:
user.replies.delete()
for thread in user.threads.all():
db.session.delete(thread)
user.email = None
user.rank = UserRank.NOT_JOINED
msg = "Deactivated user {}".format(user.username)
flash(msg, "success")
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
token = randomString(32)
addAuditLog(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
url_for("users.profile", username=user.username), None)
ver = UserEmailVerification()
ver.user = user
ver.token = token
ver.email = user.email
ver.is_password_reset = True
db.session.add(ver)
db.session.commit()
if user == current_user:
logout_user()
send_verify_email.delay(user.email, token)
return redirect(url_for("homepage.home"))
flash(f"Set email and sent a password reset on {user.username}", "success")
return redirect(url_for("users.modtools", username=username))
@bp.route("/users/<username>/modtools/ban/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_ban(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
abort(403)
user.rank = UserRank.BANNED
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}",
url_for("users.profile", username=user.username), None)
db.session.commit()
flash(f"Banned {user.username}", "success")
return redirect(url_for("users.modtools", username=username))

View File

@@ -11,16 +11,23 @@ def populate(session):
admin_user.rank = UserRank.ADMIN
session.add(admin_user)
system_user = User("ContentDB", active=False)
system_user.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
system_user.rank = UserRank.BOT
session.add(system_user)
session.add(MinetestRelease("None", 0))
session.add(MinetestRelease("0.4.16/17", 32))
session.add(MinetestRelease("5.0", 37))
session.add(MinetestRelease("5.1", 38))
session.add(MinetestRelease("5.2", 39))
session.add(MinetestRelease("5.3", 39))
tags = {}
for tag in ["Inventory", "Mapgen", "Building",
"Mobs and NPCs", "Tools", "Player effects",
"Environment", "Transport", "Maintenance", "Plants and farming",
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
"PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer", "Featured"]:
row = Tag(tag)
tags[row.name] = row
session.add(row)
@@ -53,7 +60,7 @@ def populate_test_data(session):
ez.rank = UserRank.EDITOR
session.add(ez)
not1 = Notification(admin_user, ez, "Awards approved", "/packages/rubenwardy/awards/")
not1 = Notification(admin_user, ez, NotificationType.PACKAGE_APPROVAL, "Awards approved", "/packages/rubenwardy/awards/")
session.add(not1)
jeija = User("Jeija")

View File

@@ -1,14 +1,17 @@
title: Help
toc: False
## General Help
* [Frequently Asked Questions](faq)
* [Content Ratings and Flags](content_flags)
* [Non-free Licenses](non_free)
* [Why WTFPL is a terrible license](wtfpl)
* [Ranks and Permissions](ranks_permissions)
* [Reporting Content](reporting)
* [Contact Us](contact_us)
* [Top Packages Algorithm](top_packages)
* [Featured Packages](featured)
## Help for Package Authors

View File

@@ -1,5 +1,11 @@
title: API
## Resources
* [How the Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
## Responses and Error Handling
If there is an error, the response will be JSON similar to the following with a non-200 status code:
@@ -14,7 +20,33 @@ If there is an error, the response will be JSON similar to the following with a
Successful GET requests will return the resource's information directly as a JSON response.
Other successful results will return a dictionary with `success` equaling true, and
often other keys with information.
often other keys with information. For example:
```js
{
"success": true,
"release": {
/* same as returned by a GET */
}
}
```
### Paginated Results
Some API endpoints returns results in pages. The page number is specified using the `page` query argument, and
the number of items is specified using `num`
The response will be a dictionary with the following keys:
* `page`: page number, integer from 1 to max
* `per_page`: number of items per page, same as `n`
* `page_count`: number of pages
* `total`: total number of results
* `urls`: dictionary containing
* `next`: url to next page
* `previous`: url to previous page
* `items`: array of items
## Authentication
@@ -46,28 +78,52 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved).
* `short_description`
* `tags`: List of tag names, see [misc](#misc).
* `content_warnings`: List of content warning names, see [misc](#misc).
* `license`: A license name.
* `media_license`: A license name.
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
`LOOKING_FOR_MAINTAINER`.
* `tags`: List of [tag](#tags) names.
* `content_warnings`: List of [content warning](#content-warnings) names.
* `license`: A [license](#licenses) name.
* `media_license`: A [license](#licenses) name.
* `long_description`: Long markdown description.
* `repo`: Git repo URL.
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates
* If query argument `only_hard` is present, only hard deps will be returned.
* GET `/api/dependencies/`
* Returns `provides` and raw dependencies for all packages.
* Supports [Package Queries](#package-queries)
* [Paginated result](#paginated-results), max 300 results per page
* Each item in `items` will be a dictionary with the following keys:
* `type`: One of `GAME`, `MOD`, `TXP`.
* `author`: Username of the package author.
* `name`: Package name.
* `provides`: List of technical mod names inside the package.
* `depends`: List of hard dependencies.
* Each dep will either be a metapackage dependency (`name`), or a
package dependency (`author/name`).
* `optional_depends`: list of optional dependencies
* Same as above.
You can download a package by building one of the two URLs:
```
https://content.minetest.net/packages/${author}/${name}/download/`
https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
```
Examples:
```bash
# Edit packages
curl -X PUT http://localhost:5123/api/packages/username/name/ \
# Edit package
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
# Remove website URL
curl -X PUT http://localhost:5123/api/packages/username/name/ \
curl -X PUT https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "website": null }'
```
@@ -98,7 +154,11 @@ Supported query parameters:
## Releases
* GET `/api/packages/<username>/<name>/releases/` (List)
* GET `/api/releases/` (List)
* Limited to 30 most recent releases.
* Optional arguments:
* `author`: Filter by author
* `maintainer`: Filter by maintainer
* Returns array of release dictionaries with keys:
* `id`: release ID
* `title`: human-readable title
@@ -108,6 +168,12 @@ Supported query parameters:
* `downloads`: number of downloads
* `min_minetest_version`: dict or null, minimum supported minetest version (inclusive).
* `max_minetest_version`: dict or null, minimum supported minetest version (inclusive).
* `package`
* `author`: author username
* `name`: technical name
* `type`: `mod`, `game`, or `txp`
* GET `/api/packages/<username>/<name>/releases/` (List)
* Returns array of release dictionaries, see above, but without package info.
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
* POST `/api/packages/<username>/<name>/releases/new/` (Create)
* Requires authentication.
@@ -117,7 +183,8 @@ Supported query parameters:
* `method`: must be `git`.
* `ref`: (Optional) git reference, eg: `master`.
* For zip upload release creation:
* `file`: multipart file to upload, like `<input type=file>`.
* `file`: multipart file to upload, like `<input type="file" name="file">`.
* `commit`: (Optional) Source Git commit hash, for informational purposes.
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
* Requires authentication.
@@ -136,6 +203,11 @@ curl -X POST https://content.minetest.net/api/packages/username/name/releases/ne
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/file.zip
# Create release from zip upload with commit hash
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F commit="8ef74deec170a8ce789f6055a59d43876d16a7ea" -F file=@path/to/file.zip
# Delete release
curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/3/ \
-H "Authorization: Bearer YOURTOKEN"
@@ -166,10 +238,13 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
* Requires authentication.
* Body is a JSON array containing the screenshot IDs in their order.
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
Examples:
```bash
# Create screenshots
# Create screenshot
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png
@@ -185,40 +260,113 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
```
## Reviews
* GET `/api/packages/<username>/<name>/reviews/` (List)
* Returns array of review dictionaries with keys:
* `user`: dictionary with `display_name` and `username`.
* `title`: review title
* `comment`: the text
* `is_positive`: boolean
* `created_at`: iso timestamp
* `votes`: dictionary with `helpful` and `unhelpful`,
* GET `/api/reviews/` (List)
* Returns a paginated response. This is a dictionary with `page`, `url`, and `items`.
* [Paginated result](#paginated-results)
* `items`: array of review dictionaries, like above
* Each review also has a `package` dictionary with `type`, `author` and `name`
* Query arguments:
* `page`: page number, integer from 1 to max
* `n`: number of results per page, max 100
* `author`: filter by review author username
* `is_positive`: true or false. Default: null
* `q`: filter by title (case insensitive, no fulltext search)
Example:
```json
[
{
"comment": "This is a really good mod!",
"created_at": "2021-11-24T16:18:33.764084",
"is_positive": true,
"title": "Really good",
"user": {
"display_name": "rubenwardy",
"username": "rubenwardy"
},
"votes": {
"helpful": 0,
"unhelpful": 0
}
}
]
```
## Topics
* GET `/api/topics/`: Supports [Package Queries](#package-queries), and the following two options:
* `show_added`: Show topics which exist as packages, default true.
* `show_discarded`: Show topics which have been marked as outdated, default false.
* GET `/api/topics/` ([View](/api/topics/))
* See [Topic Queries](#topic-queries)
### Topic Queries
Example:
/api/topics/?q=mobs
/api/topics/?q=mobs&type=mod&type=game
Supported query parameters:
* `q`: Query string.
* `sort`: Sort by (`name`, `views`, `date`).
* `order`: Sort ascending (`asc`) or descending (`desc`).
* `type`: Package types (`mod`, `game`, `txp`).
* `sort`: Sort by (`name`, `views`, `created_at`).
* `show_added`: Show topics that have an existing package.
* `show_discarded`: Show topics marked as discarded.
* `limit`: Return at most `limit` topics.
## Types
## Misc
### Tags
* GET `/api/scores/`
* See [Package Queries](#package-queries)
* GET `/api/tags/`: List of:
* GET `/api/tags/` ([View](/api/tags/)): List of:
* `name`: technical name
* `title`: human-readable title
* `description`: tag description or null
* GET `/api/licenses/`: List of:
### Content Warnings
* GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
* `name`: technical name
* `title`: human-readable title
* `description`: tag description or null
### Licenses
* GET `/api/licenses/` ([View](/api/licenses/)): List of:
* `name`
* `is_foss`: whether the license is foss
* GET `/api/homepage/`
### Minetest Versions
* GET `/api/minetest_versions/` ([View](/api/minetest_versions/))
* `name`: Version name.
* `is_dev`: boolean, is dev version.
* `protocol_version`: protocol version umber.
## Misc
* GET `/api/scores/` ([View](/api/scores/))
* See [Top Packages Algorithm](/help/top_packages/).
* Supports [Package Queries](#package-queries).
* Returns list of:
* `author`: package author name.
* `name`: package technical name.
* `downloads`: number of downloads.
* `score`: total package score.
* `score_reviews`: score from reviews.
* `score_downloads`: score from downloads.
* GET `/api/homepage/` ([View](/api/homepage/)) - get contents of homepage.
* `count`: number of packages
* `downloads`: get number of downloads
* `new`: new packages
@@ -227,4 +375,3 @@ Supported query parameters:
* `pop_txp`: popular textures
* `pop_game`: popular games
* `high_reviewed`: highest reviewed
* GET `/api/minetest_versions/`

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -50,6 +50,8 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved).
* `short_description`
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
`LOOKING_FOR_MAINTAINER`.
* `tags`: List of tag names, see [/api/tags/](/api/tags/).
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
* `license`: A license name, see [/api/licenses/](/api/licenses/).

View File

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

View File

@@ -20,6 +20,11 @@ The process is as follows:
3. The git host posts a webhook notification to ContentDB, using the API token assigned to it.
4. ContentDB checks the API token and issues a new release.
<p class="alert alert-warning">
"New commit" or "push" based webhooks will currently only work on branches named `master` or
`main`.
</p>
## Setting up
### GitHub
@@ -49,9 +54,10 @@ The process is as follows:
choose "Tag push events".
8. Add webhook.
## Configuring
## Configuring Release Creation
See the [Package Configuration and Releases Guide](/help/package_config/) for
documentation on configuring the release creation.
You can set the min/max Minetest version from the Git repository, and also
configure what files are included.
From the Git repository, you can set the min/max Minetest versions, which files are included,
and update the package meta.

View File

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

View File

@@ -6,7 +6,7 @@ toc: False
Please reconsider the choice of WTFPL as a license.
<script src="/static/jquery.min.js"></script>
<script src="/static/libs/jquery.min.js"></script>
<script>
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later

View File

@@ -27,7 +27,7 @@ including ones not covered by this document, and to ban users who abuse this ser
### 2.1. Acceptable Content
Sexually-orientated content is not permitted.
If in doubt at what this means, [contact us by raising a report](/help/reporting/).
If in doubt at what this means, [contact us by raising a report](/report/).
Mature content is permitted providing that it is labelled correctly.
See [Content Flags](/help/content_flags/).
@@ -46,6 +46,9 @@ but still has value. Note that this doesn't mean that you should add a thing
you started working on yesterday, it's worth adding all the basic stuff to
make your package useful.
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
as this will help advise players.
Adding non-player facing mods, such as libraries and server tools, is perfectly fine
and encouraged. ContentDB isn't just for player-facing things, and adding
libraries allows them to be installed when a mod depends on it.
@@ -80,6 +83,23 @@ should 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. Reserved namespaces
A reserved namespace is a prefix to a package name like `abc_`, where any package names that begin with that prefix
need permission from the namespace owner to be used.
You can request a namespace be reserved by opening a thread on a package that uses it. A namespace must be in active
use and must be specific enough to have low risk of conflict - for example, `mobs` cannot be a reserved namespace.
The package must also be deemed "serious" enough to get a reservation - larger and more longterm projects are more
likely to qualify.
Mod names used in packages posted on CDB or the forums before 2022-01-21 are exempt.
List of reserved namespaces:
* `nc_` is reserved by NodeCore.
* `ikea_` is reserved by IKEA.
## 4. Licenses
@@ -134,6 +154,20 @@ ContentDB is for the community. We may remove any promotions if we feel that
they're inappropriate.
## 6. Reporting Violations
## 6. Reviews and Package Score
See the [Reporting Content](/help/reporting/) page.
You may invite players to review your package(s). One way to do this is by sharing the link found in the
"Share and Badges" page of the package's settings.
You must not require anyone to review a package. You must not promise or provide incentives for reviewing a package,
including but not limited to monetary rewards, in-game items, features, and/or privileges.
You may give a cosmetic-only role or badge to those who review your package - this must not be tied to the content or
rating of the review.
You must not attempt to unfairly manipulate your package's ranking, whether by reviews or any other method.
Doing so may result in temporary or permanent suspension from ContentDB.
## 7. Reporting Violations
Please click "Report" on the package page.

View File

@@ -72,7 +72,7 @@ requested. See below.
## Removal Requests
Please [raise a report](https://content.minetest.net/help/reporting/) if you
Please [raise a report](https://content.minetest.net/report/?anon=0) if you
wish to remove your personal information.
ContentDB keeps a record of each username and forum topic on the forums,

View File

@@ -17,9 +17,11 @@
import re
import validators
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, License
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
License, UserRank, PackageDevState
from app.utils import addAuditLog
@@ -34,23 +36,24 @@ def get_license(name):
license = License.query.filter(License.name.ilike(name)).first()
if license is None:
raise LogicError(400, "Unknown license: " + name)
raise LogicError(400, "Unknown license " + name)
return license
name_re = re.compile("^[a-z0-9_]+$")
any = "?"
AnyType = "?"
ALLOWED_FIELDS = {
"type": any,
"type": AnyType,
"title": str,
"name": str,
"short_description": str,
"short_desc": str,
"dev_state": AnyType,
"tags": list,
"content_warnings": list,
"license": any,
"media_license": any,
"license": AnyType,
"media_license": AnyType,
"long_description": str,
"desc": str,
"repo": str,
@@ -80,14 +83,14 @@ def validate(data: dict):
if value is not None:
typ = ALLOWED_FIELDS.get(key)
check(typ is not None, key + " is not a known field")
if typ != any:
if typ != AnyType:
check(isinstance(value, typ), key + " must be a " + typ.__name__)
if "name" in data:
name = data["name"]
check(isinstance(name, str), "Name must be a string")
check(bool(name_re.match(name)),
"Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)")
lazy_gettext("Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)"))
for key in ["repo", "website", "issue_tracker", "issueTracker"]:
value = data.get(key)
@@ -98,13 +101,14 @@ def validate(data: dict):
check(validators.url(value, public=True), key + " must be a valid URL")
def do_edit_package(user: User, package: Package, was_new: bool, data: dict, reason: str = None):
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
reason: str = None):
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, "You do not have permission to edit this package")
raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
if "name" in data and package.name != data["name"] and \
not package.checkPerm(user, Permission.CHANGE_NAME):
raise LogicError(403, "You do not have permission to change the package name")
raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
for alias, to in ALIASES.items():
if alias in data:
@@ -115,13 +119,16 @@ def do_edit_package(user: User, package: Package, was_new: bool, data: dict, rea
if "type" in data:
data["type"] = PackageType.coerce(data["type"])
if "dev_state" in data:
data["dev_state"] = PackageDevState.coerce(data["dev_state"])
if "license" in data:
data["license"] = get_license(data["license"])
if "media_license" in data:
data["media_license"] = get_license(data["media_license"])
for key in ["name", "title", "short_desc", "desc", "type", "license", "media_license",
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums"]:
if key in data:
setattr(package, key, data[key])
@@ -134,15 +141,28 @@ def do_edit_package(user: User, package: Package, was_new: bool, data: dict, rea
package.provides.append(m)
if "tags" in data:
old_tags = list(package.tags)
package.tags.clear()
for tag_id in data["tags"]:
if is_int(tag_id):
package.tags.append(Tag.query.get(tag_id))
tag = Tag.query.get(tag_id)
else:
tag = Tag.query.filter_by(name=tag_id).first()
if tag is None:
raise LogicError(400, "Unknown tag: " + tag_id)
package.tags.append(tag)
if not was_web and tag.is_protected:
break
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
package.tags.append(tag)
if not was_web:
for tag in old_tags:
if tag.is_protected:
package.tags.append(tag)
if "content_warnings" in data:
package.content_warnings.clear()
@@ -162,7 +182,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, data: dict, rea
msg = "Edited {} ({})".format(package.title, reason)
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, user, msg, package.getDetailsURL(), package)
addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
db.session.commit()

View File

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

View File

@@ -1,4 +1,6 @@
import datetime
import datetime, json
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file
@@ -10,9 +12,9 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
if count >= 20:
raise LogicError(429, "Too many requests, please wait before trying again")
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
uploaded_url, uploaded_path = upload_file(file, "image", "a PNG or JPG image file")
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file"))
counter = 1
for screenshot in package.screenshots.all():
@@ -32,8 +34,8 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
else:
msg = "Created screenshot {} ({})".format(ss.title, reason)
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getDetailsURL(), package)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
db.session.commit()
@@ -46,13 +48,13 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
lookup[screenshot.id] = screenshot
counter = 1
for id in order:
for ss_id in order:
try:
lookup[int(id)].order = counter
lookup[int(ss_id)].order = counter
counter += 1
except KeyError as e:
raise LogicError(400, "Unable to find screenshot with id={}".format(id))
except ValueError as e:
raise LogicError(400, "Invalid number: {}".format(id))
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
except (ValueError, TypeError) as e:
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
db.session.commit()

View File

@@ -18,6 +18,8 @@
import imghdr
import os
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.models import *
from app.utils import randomString
@@ -47,10 +49,10 @@ def upload_file(file, fileType, fileTypeDesc):
ext = get_extension(file.filename)
if ext is None or not ext in allowedExtensions:
raise LogicError(400, "Please upload " + fileTypeDesc)
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
if isImage and not isAllowedImage(file.stream.read()):
raise LogicError(400, "Uploaded image isn't actually an image")
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
file.stream.seek(0)

179
app/markdown.py Normal file
View File

@@ -0,0 +1,179 @@
from functools import partial
import bleach
from bleach import Cleaner
from bleach.linkifier import LinkifyFilter
from bs4 import BeautifulSoup
from markdown import Markdown
from flask import Markup, url_for
from markdown.extensions import Extension
from markdown.inlinepatterns import SimpleTagInlineProcessor
from markdown.inlinepatterns import Pattern
from xml.etree import ElementTree
# Based on
# https://github.com/Wenzil/mdx_bleach/blob/master/mdx_bleach/whitelist.py
#
# License: MIT
ALLOWED_TAGS = [
"h1", "h2", "h3", "h4", "h5", "h6", "hr",
"ul", "ol", "li",
"p",
"br",
"pre",
"code",
"blockquote",
"strong",
"em",
"a",
"img",
"table", "thead", "tbody", "tr", "th", "td",
"div", "span", "del", "s",
]
ALLOWED_CSS = [
"highlight", "codehilite",
"hll", "c", "err", "g", "k", "l", "n", "o", "x", "p", "ch", "cm", "cp", "cpf", "c1", "cs",
"gd", "ge", "gr", "gh", "gi", "go", "gp", "gs", "gu", "gt", "kc", "kd", "kn", "kp", "kr",
"kt", "ld", "m", "s", "na", "nb", "nc", "no", "nd", "ni", "ne", "nf", "nl", "nn", "nx",
"py", "nt", "nv", "ow", "w", "mb", "mf", "mh", "mi", "mo", "sa", "sb", "sc", "dl", "sd",
"s2", "se", "sh", "si", "sx", "sr", "s1", "ss", "bp", "fm", "vc", "vg", "vi", "vm", "il",
]
def allow_class(_tag, name, value):
return name == "class" and value in ALLOWED_CSS
ALLOWED_ATTRIBUTES = {
"h1": ["id"],
"h2": ["id"],
"h3": ["id"],
"h4": ["id"],
"a": ["href", "title", "data-username"],
"img": ["src", "title", "alt"],
"code": allow_class,
"div": allow_class,
"span": allow_class,
}
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
md = None
def render_markdown(source):
html = md.convert(source)
cleaner = Cleaner(
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS,
filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
return cleaner.clean(html)
class DelInsExtension(Extension):
def extendMarkdown(self, md):
del_proc = SimpleTagInlineProcessor(r"(\~\~)(.+?)(\~\~)", "del")
md.inlinePatterns.register(del_proc, "del", 200)
ins_proc = SimpleTagInlineProcessor(r"(\+\+)(.+?)(\+\+)", "ins")
md.inlinePatterns.register(ins_proc, "ins", 200)
RE_PARTS = dict(
USER=r"[A-Za-z0-9._-]*\b",
REPO=r"[A-Za-z0-9_]+\b"
)
class MentionPattern(Pattern):
ANCESTOR_EXCLUDES = ("a",)
def __init__(self, config, md):
MENTION_RE = r"(@({USER})(?:\/({REPO}))?)".format(**RE_PARTS)
super(MentionPattern, self).__init__(MENTION_RE, md)
self.config = config
def handleMatch(self, m):
from app.models import User
label = m.group(2)
user = m.group(3)
package_name = m.group(4)
if package_name:
el = ElementTree.Element("a")
el.text = label
el.set("href", url_for("packages.view", author=user, name=package_name))
return el
else:
if User.query.filter_by(username=user).count() == 0:
return None
el = ElementTree.Element("a")
el.text = label
el.set("href", url_for("users.profile", username=user))
el.set("data-username", user)
return el
class MentionExtension(Extension):
def __init__(self, *args, **kwargs):
super(MentionExtension, self).__init__(*args, **kwargs)
def extendMarkdown(self, md):
md.ESCAPED_CHARS.append("@")
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()]
MARKDOWN_EXTENSION_CONFIG = {
"fenced_code": {},
"tables": {},
"codehilite": {
"guess_lang": False,
}
}
def init_markdown(app):
global md
md = Markdown(extensions=MARKDOWN_EXTENSIONS,
extension_configs=MARKDOWN_EXTENSION_CONFIG,
output_format="html5")
@app.template_filter()
def markdown(source):
return Markup(render_markdown(source))
def get_headings(html: str):
soup = BeautifulSoup(html, "html.parser")
headings = soup.find_all(["h1", "h2", "h3"])
root = []
stack = []
for heading in headings:
this = {"link": heading.get("id") or "", "text": heading.text, "children": []}
this_level = int(heading.name[1:]) - 1
while this_level <= len(stack):
stack.pop()
if len(stack) > 0:
stack[-1]["children"].append(this)
else:
root.append(this)
stack.append(this)
return root
def get_user_mentions(html: str) -> set:
soup = BeautifulSoup(html, "html.parser")
links = soup.select("a[data-username]")
return set([x.get("data-username") for x in links])

View File

@@ -72,7 +72,7 @@ class AuditSeverity(enum.Enum):
@classmethod
def coerce(cls, item):
return item if type(item) == AuditSeverity else AuditSeverity[item]
return item if type(item) == AuditSeverity else AuditSeverity[item.upper()]
class AuditLogEntry(db.Model):
@@ -115,7 +115,7 @@ class ForumTopic(db.Model):
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User")
author = db.relationship("User", back_populates="forum_topics")
wip = db.Column(db.Boolean, server_default="0")
discarded = db.Column(db.Boolean, server_default="0")

View File

@@ -19,6 +19,7 @@ import datetime
import enum
from flask import url_for
from flask_babel import lazy_gettext
from flask_sqlalchemy import BaseQuery
from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType
@@ -35,10 +36,12 @@ class License(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False, unique=True)
is_foss = db.Column(db.Boolean, nullable=False, default=True)
url = db.Column(db.String(128), nullable=True, default=None)
def __init__(self, v, is_foss=True):
def __init__(self, v: str, is_foss: bool = True, url: str = None):
self.name = v
self.is_foss = is_foss
self.url = url
def __str__(self):
return self.name
@@ -55,6 +58,24 @@ class PackageType(enum.Enum):
def __str__(self):
return self.name
@property
def text(self):
if self == PackageType.MOD:
return lazy_gettext("Mod")
elif self == PackageType.GAME:
return lazy_gettext("Game")
elif self == PackageType.TXP:
return lazy_gettext("Texture Pack")
@property
def plural(self):
if self == PackageType.MOD:
return lazy_gettext("Mods")
elif self == PackageType.GAME:
return lazy_gettext("Games")
elif self == PackageType.TXP:
return lazy_gettext("Texture Packs")
@classmethod
def get(cls, name):
try:
@@ -64,11 +85,70 @@ class PackageType(enum.Enum):
@classmethod
def choices(cls):
return [(choice, choice.value) for choice in cls]
return [(choice, choice.text) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == PackageType else PackageType[item]
return item if type(item) == PackageType else PackageType[item.upper()]
class PackageDevState(enum.Enum):
WIP = "Work in Progress"
BETA = "Beta"
ACTIVELY_DEVELOPED = "Actively Developed"
MAINTENANCE_ONLY = "Maintenance Only"
AS_IS = "As-Is"
DEPRECATED = "Deprecated"
LOOKING_FOR_MAINTAINER = "Looking for Maintainer"
def toName(self):
return self.name.lower()
def __str__(self):
return self.name
def get_desc(self):
if self == PackageDevState.WIP:
return "Under active development, and may break worlds/things without warning"
elif self == PackageDevState.BETA:
return "Fully playable, but with some breakages/changes expected"
elif self == PackageDevState.MAINTENANCE_ONLY:
return "Finished, with bug fixes being made as needed"
elif self == PackageDevState.AS_IS:
return "Finished, the maintainer doesn't intend to continue working on it or provide support"
elif self == PackageDevState.DEPRECATED:
return "The maintainer doesn't recommend this package. See the description for more info"
else:
return None
@classmethod
def get(cls, name):
try:
return PackageDevState[name.upper()]
except KeyError:
return None
@classmethod
def choices(cls, with_none):
def build_label(choice):
desc = choice.get_desc()
if desc is None:
return choice.value
else:
return f"{choice.value}: {desc}"
ret = [(choice, build_label(choice)) 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) == PackageDevState else PackageDevState[item.upper()]
class PackageState(enum.Enum):
@@ -83,17 +163,30 @@ class PackageState(enum.Enum):
def verb(self):
if self == self.READY_FOR_REVIEW:
return "Submit for Review"
return lazy_gettext("Submit for Approval")
elif self == self.APPROVED:
return "Approve"
return lazy_gettext("Approve")
elif self == self.DELETED:
return "Delete"
return lazy_gettext("Delete")
else:
return self.value
def __str__(self):
return self.name
@property
def color(self):
if self == self.WIP:
return "warning"
elif self == self.CHANGES_NEEDED:
return "danger"
elif self == self.READY_FOR_REVIEW:
return "success"
elif self == self.APPROVED:
return "info"
else:
return "danger"
@classmethod
def get(cls, name):
try:
@@ -107,7 +200,7 @@ class PackageState(enum.Enum):
@classmethod
def coerce(cls, item):
return item if type(item) == PackageState else PackageState[item]
return item if type(item) == PackageState else PackageState[item.upper()]
PACKAGE_STATE_FLOW = {
@@ -143,7 +236,7 @@ class PackagePropertyKey(enum.Enum):
return str(value)
provides = db.Table("provides",
PackageProvides = db.Table("provides",
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
)
@@ -277,7 +370,8 @@ class Package(db.Model):
media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
media_license = db.relationship("License", foreign_keys=[media_license_id])
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None)
@property
def approved(self):
@@ -296,7 +390,7 @@ class Package(db.Model):
issueTracker = db.Column(db.String(200), nullable=True)
forums = db.Column(db.Integer, nullable=True)
provides = db.relationship("MetaPackage", secondary=provides, order_by=db.asc("name"), back_populates="packages")
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
@@ -322,11 +416,12 @@ class Package(db.Model):
threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"),
foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic")
reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at"),
reviews = db.relationship("PackageReview", back_populates="package",
order_by=[db.desc("package_review_score"),db.desc("package_review_created_at")],
cascade="all, delete, delete-orphan")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id", back_populates="package",
order_by=db.desc("audit_log_entry_created_at"))
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id",
lazy="dynamic", back_populates="package", order_by=db.desc("audit_log_entry_created_at"))
notifications = db.relationship("Notification", foreign_keys="Notification.package_id",
back_populates="package", cascade="all, delete, delete-orphan")
@@ -337,6 +432,9 @@ class Package(db.Model):
update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package",
cascade="all, delete, delete-orphan")
aliases = db.relationship("PackageAlias", foreign_keys="PackageAlias.package_id",
back_populates="package", cascade="all, delete, delete-orphan")
def __init__(self, package=None):
if package is None:
return
@@ -374,7 +472,7 @@ class Package(db.Model):
def getAsDictionaryKey(self):
return {
"name": self.name,
"author": self.author.display_name,
"author": self.author.username,
"type": self.type.toName(),
}
@@ -385,16 +483,26 @@ class Package(db.Model):
release = self.getDownloadRelease(version=version)
release_id = release and release.id
return {
short_desc = self.short_desc
if self.dev_state == PackageDevState.WIP:
short_desc = "Work in Progress. " + self.short_desc
ret = {
"name": self.name,
"title": self.title,
"author": self.author.username,
"short_description": self.short_desc,
"short_description": short_desc,
"type": self.type.toName(),
"release": release_id,
"thumbnail": (base_url + tnurl) if tnurl is not None else None
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
"aliases": [ alias.getAsDictionary() for alias in self.aliases ],
}
if not ret["aliases"]:
del ret["aliases"]
return ret
def getAsDictionary(self, base_url, version=None):
tnurl = self.getThumbnailURL(1)
release = self.getDownloadRelease(version=version)
@@ -403,6 +511,7 @@ class Package(db.Model):
"maintainers": [x.username for x in self.maintainers],
"state": self.state.name,
"dev_state": self.dev_state.name if self.dev_state else None,
"name": self.name,
"title": self.title,
@@ -426,7 +535,7 @@ class Package(db.Model):
"thumbnail": (base_url + tnurl) if tnurl is not None else None,
"screenshots": [base_url + ss.url for ss in self.screenshots],
"url": base_url + self.getDownloadURL(),
"url": base_url + self.getURL("packages.download"),
"release": release and release.id,
"score": round(self.score * 10) / 10,
@@ -451,14 +560,12 @@ class Package(db.Model):
else:
return screenshot.url
def getDetailsURL(self, absolute=False):
def getURL(self, endpoint, absolute=False, **kwargs):
if absolute:
from app.utils import abs_url_for
return abs_url_for("packages.view",
author=self.author.username, name=self.name)
return abs_url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
else:
return url_for("packages.view",
author=self.author.username, name=self.name)
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
def getShieldURL(self, type):
from app.utils import abs_url_for
@@ -467,11 +574,7 @@ class Package(db.Model):
def makeShield(self, type):
return "[![ContentDB]({})]({})" \
.format(self.getShieldURL(type), self.getDetailsURL(True))
def getEditURL(self):
return url_for("packages.create_edit",
author=self.author.username, name=self.name)
.format(self.getShieldURL(type), self.getURL("packages.view", True))
def getSetStateURL(self, state):
if type(state) == str:
@@ -482,54 +585,6 @@ class Package(db.Model):
return url_for("packages.move_to_state",
author=self.author.username, name=self.name, state=state.name.lower())
def getRemoveURL(self):
return url_for("packages.remove",
author=self.author.username, name=self.name)
def getNewScreenshotURL(self):
return url_for("packages.create_screenshot",
author=self.author.username, name=self.name)
def getEditScreenshotsURL(self):
return url_for("packages.screenshots",
author=self.author.username, name=self.name)
def getCreateReleaseURL(self, **kwargs):
return url_for("packages.create_release",
author=self.author.username, name=self.name, **kwargs)
def getBulkReleaseURL(self):
return url_for("packages.bulk_change_release",
author=self.author.username, name=self.name)
def getUpdateConfigURL(self, action=None):
return url_for("packages.update_config",
author=self.author.username, name=self.name, action=action)
def getSetupReleasesURL(self):
return url_for("packages.setup_releases",
author=self.author.username, name=self.name)
def getDownloadURL(self):
return url_for("packages.download",
author=self.author.username, name=self.name)
def getEditMaintainersURL(self):
return url_for("packages.edit_maintainers",
author=self.author.username, name=self.name)
def getRemoveSelfMaintainerURL(self):
return url_for("packages.remove_self_maintainers",
author=self.author.username, name=self.name)
def getUpdateFromReleaseURL(self):
return url_for("packages.update_from_release",
author=self.author.username, name=self.name)
def getReviewURL(self):
return url_for('packages.review',
author=self.author.username, name=self.name)
def getDownloadRelease(self, version=None):
for rel in self.releases:
if rel.approved and (version is None or
@@ -550,41 +605,45 @@ class Package(db.Model):
isOwner = user == self.author
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
isApprover = user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.CREATE_THREAD:
if perm == Permission.SEE_PACKAGE:
return self.state == PackageState.APPROVED or isMaintainer or isApprover
elif perm == Permission.CREATE_THREAD:
return user.rank.atLeast(UserRank.MEMBER)
# Members can edit their own packages, and editors can edit any packages
elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
return isMaintainer
elif perm == Permission.EDIT_PACKAGE or \
perm == Permission.APPROVE_CHANGES or perm == Permission.APPROVE_RELEASE:
elif perm == Permission.EDIT_PACKAGE:
return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
elif perm == Permission.APPROVE_RELEASE:
return (isMaintainer or isApprover) and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
# Anyone can change the package name when not approved, but only editors when approved
elif perm == Permission.CHANGE_NAME:
return not self.approved or user.rank.atLeast(UserRank.EDITOR)
# Editors can change authors and approve new packages
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
return user.rank.atLeast(UserRank.EDITOR)
return isApprover
elif perm == Permission.APPROVE_SCREENSHOT:
return isMaintainer and user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
return (isMaintainer or isApprover) and \
user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
elif perm == Permission.EDIT_MAINTAINERS:
return isOwner or user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE:
return isOwner or user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.UNAPPROVE_PACKAGE or perm == Permission.DELETE_PACKAGE:
return user.rank.atLeast(UserRank.MEMBER if isOwner else UserRank.EDITOR)
elif perm == Permission.UNAPPROVE_PACKAGE:
return isOwner or user.rank.atLeast(UserRank.APPROVER)
elif perm == Permission.CHANGE_RELEASE_URL:
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.REIMPORT_META:
return user.rank.atLeast(UserRank.ADMIN)
else:
raise Exception("Permission {} is not related to packages".format(perm.name))
@@ -610,9 +669,10 @@ class Package(db.Model):
return False
if state == PackageState.READY_FOR_REVIEW or state == PackageState.APPROVED:
requiredPerm = Permission.APPROVE_NEW if state == PackageState.APPROVED else Permission.EDIT_PACKAGE
if state == PackageState.APPROVED and not self.checkPerm(user, Permission.APPROVE_NEW):
return False
if not self.checkPerm(user, requiredPerm):
if not (self.checkPerm(user, Permission.APPROVE_NEW) or self.checkPerm(user, Permission.EDIT_PACKAGE)):
return False
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
@@ -663,7 +723,7 @@ class MetaPackage(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
dependencies = db.relationship("Dependency", back_populates="meta_package", lazy="dynamic")
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=provides)
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=PackageProvides)
mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
@@ -740,6 +800,7 @@ class Tag(db.Model):
backgroundColor = db.Column(db.String(6), nullable=False)
textColor = db.Column(db.String(6), nullable=False)
views = db.Column(db.Integer, nullable=False, default=0)
is_protected = db.Column(db.Boolean, nullable=False, default=False)
packages = db.relationship("Package", back_populates="tags", secondary=Tags)
@@ -791,7 +852,10 @@ class MinetestRelease(db.Model):
return release
if protocol_num:
return MinetestRelease.query.filter_by(protocol=protocol_num).first()
# 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 None
@@ -831,6 +895,19 @@ class PackageRelease(db.Model):
"max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(),
}
def getLongAsDictionary(self):
return {
"id": self.id,
"title": self.title,
"url": self.url if self.url != "" else None,
"release_date": self.releaseDate.isoformat(),
"commit": self.commit_hash,
"downloads": self.downloads,
"min_minetest_version": self.min_rel and self.min_rel.getAsDictionary(),
"max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(),
"package": self.package.getAsDictionaryKey()
}
def getEditURL(self):
return url_for("packages.edit_release",
author=self.package.author.username,
@@ -852,8 +929,11 @@ class PackageRelease(db.Model):
def __init__(self):
self.releaseDate = datetime.datetime.now()
def getDownloadFileName(self):
return f"{self.package.name}_{self.id}.zip"
def approve(self, user):
if not self.package.checkPerm(user, Permission.APPROVE_RELEASE):
if not self.checkPerm(user, Permission.APPROVE_RELEASE):
return False
if self.approved:
@@ -878,24 +958,27 @@ class PackageRelease(db.Model):
elif type(perm) != Permission:
raise Exception("Unknown permission given to PackageRelease.checkPerm()")
isOwner = user == self.package.author
isMaintainer = user == self.package.author or user in self.package.maintainers
if perm == Permission.DELETE_RELEASE:
if user.rank.atLeast(UserRank.ADMIN):
return True
if not (isOwner or user.rank.atLeast(UserRank.EDITOR)):
if not (isMaintainer or user.rank.atLeast(UserRank.EDITOR)):
return False
if not self.package.approved or self.task_id is not None:
return True
count = PackageRelease.query \
.filter_by(package_id=self.package_id) \
count = self.package.releases \
.filter(PackageRelease.id > self.id) \
.count()
return count > 0
elif perm == Permission.APPROVE_RELEASE:
return user.rank.atLeast(UserRank.APPROVER) or \
(isMaintainer and user.rank.atLeast(
UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER))
else:
raise Exception("Permission {} is not related to releases".format(perm.name))
@@ -961,7 +1044,7 @@ class PackageUpdateTrigger(enum.Enum):
@classmethod
def coerce(cls, item):
return item if type(item) == PackageUpdateTrigger else PackageUpdateTrigger[item]
return item if type(item) == PackageUpdateTrigger else PackageUpdateTrigger[item.upper()]
class PackageUpdateConfig(db.Model):
@@ -1005,4 +1088,25 @@ class PackageUpdateConfig(db.Model):
return self.last_tag or self.last_commit
def get_create_release_url(self):
return self.package.getCreateReleaseURL(title=self.get_title(), ref=self.get_ref())
return self.package.getURL("packages.create_release", title=self.get_title(), ref=self.get_ref())
class PackageAlias(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
package = db.relationship("Package", back_populates="aliases", foreign_keys=[package_id])
author = db.Column(db.String(50), nullable=False)
name = db.Column(db.String(100), nullable=False)
def __init__(self, author="", name=""):
self.author = author
self.name = name
def getEditURL(self):
return url_for("packages.alias_create_edit", author=self.package.author.username,
name=self.package.name, alias_id=self.id)
def getAsDictionary(self):
return f"{self.author}/{self.name}"

View File

@@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from typing import Tuple, List
from flask import url_for
@@ -22,7 +23,6 @@ from . import db
from .users import Permission, UserRank
from .packages import Package
watchers = db.Table("watchers",
db.Column("user_id", db.Integer, db.ForeignKey("user.id"), primary_key=True),
db.Column("thread_id", db.Integer, db.ForeignKey("thread.id"), primary_key=True)
@@ -55,8 +55,19 @@ class Thread(db.Model):
watchers = db.relationship("User", secondary=watchers, backref="watching")
def getViewURL(self):
return url_for("threads.view", id=self.id, _external=False)
def get_description(self):
comment = self.replies[0].comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
if len(comment) > 100:
return comment[:97] + "..."
else:
return comment
def getViewURL(self, absolute=False):
if absolute:
from ..utils import abs_url_for
return abs_url_for("threads.view", id=self.id)
else:
return url_for("threads.view", id=self.id, _external=False)
def getSubscribeURL(self):
return url_for("threads.subscribe", id=self.id)
@@ -77,7 +88,7 @@ class Thread(db.Model):
if self.package:
isMaintainer = isMaintainer or user in self.package.maintainers
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.EDITOR)
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.SEE_THREAD:
return canSee
@@ -85,9 +96,14 @@ class Thread(db.Model):
elif perm == Permission.COMMENT_THREAD:
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
elif perm == Permission.LOCK_THREAD or perm == Permission.DELETE_THREAD:
elif perm == Permission.LOCK_THREAD:
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.DELETE_THREAD:
from app.utils.models import get_system_user
return (self.author == get_system_user() and self.package and
user in self.package.maintainers) or user.rank.atLeast(UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to threads".format(perm.name))
@@ -108,6 +124,9 @@ class ThreadReply(db.Model):
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def get_url(self):
return url_for('threads.view', id=self.thread.id) + "#reply-" + str(self.id)
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
@@ -118,7 +137,7 @@ class ThreadReply(db.Model):
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
if perm == Permission.EDIT_REPLY:
return user == self.author and user.rank.atLeast(UserRank.MEMBER) and not self.thread.locked
return user.rank.atLeast(UserRank.MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
elif perm == Permission.DELETE_REPLY:
return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
@@ -141,14 +160,66 @@ class PackageReview(db.Model):
recommends = db.Column(db.Boolean, nullable=False)
thread = db.relationship("Thread", uselist=False, back_populates="review")
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
score = db.Column(db.Integer, nullable=False, default=1)
def get_totals(self, current_user = None) -> Tuple[int,int,bool]:
votes: List[PackageReviewVote] = self.votes
pos = sum([ 1 for vote in votes if vote.is_positive ])
neg = sum([ 1 for vote in votes if not vote.is_positive])
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
return pos, neg, user_vote.is_positive if user_vote else None
def getAsDictionary(self, include_package=False):
pos, neg, _user = self.get_totals()
ret = {
"is_positive": self.recommends,
"user": {
"username": self.author.username,
"display_name": self.author.display_name,
},
"created_at": self.created_at.isoformat(),
"votes": {
"helpful": pos,
"unhelpful": neg,
},
"title": self.thread.title,
"comment": self.thread.replies[0].comment,
}
if include_package:
ret["package"] = self.package.getAsDictionaryKey()
return ret
def asSign(self):
return 1 if self.recommends else -1
def getEditURL(self):
return self.package.getReviewURL()
return self.package.getURL("packages.review")
def getDeleteURL(self):
return url_for("packages.delete_review",
author=self.package.author.username,
name=self.package.name)
def getVoteUrl(self, next_url=None):
return url_for("packages.review_vote",
author=self.package.author.username,
name=self.package.name,
review_id=self.id,
r=next_url)
def update_score(self):
(pos, neg, _) = self.get_totals()
self.score = 3 * (pos - neg) + 1
class PackageReviewVote(db.Model):
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
is_positive = db.Column(db.Boolean, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)

View File

@@ -31,10 +31,11 @@ class UserRank(enum.Enum):
NEW_MEMBER = 2
MEMBER = 3
TRUSTED_MEMBER = 4
EDITOR = 5
BOT = 6
MODERATOR = 7
ADMIN = 8
APPROVER = 5
EDITOR = 6
BOT = 7
MODERATOR = 8
ADMIN = 9
def atLeast(self, min):
return self.value >= min.value
@@ -54,19 +55,18 @@ class UserRank(enum.Enum):
@classmethod
def coerce(cls, item):
return item if type(item) == UserRank else UserRank[item]
return item if type(item) == UserRank else UserRank[item.upper()]
class Permission(enum.Enum):
SEE_PACKAGE = "SEE_PACKAGE"
EDIT_PACKAGE = "EDIT_PACKAGE"
APPROVE_CHANGES = "APPROVE_CHANGES"
DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE"
DELETE_RELEASE = "DELETE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
REIMPORT_META = "REIMPORT_META"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
APPROVE_RELEASE = "APPROVE_RELEASE"
APPROVE_NEW = "APPROVE_NEW"
@@ -88,6 +88,7 @@ class Permission(enum.Enum):
CREATE_TOKEN = "CREATE_TOKEN"
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
@@ -96,13 +97,14 @@ class Permission(enum.Enum):
return False
if self == Permission.APPROVE_NEW or \
self == Permission.APPROVE_CHANGES or \
self == Permission.APPROVE_RELEASE or \
self == Permission.APPROVE_SCREENSHOT or \
self == Permission.EDIT_TAGS or \
self == Permission.CREATE_TAG or \
self == Permission.SEE_THREAD:
return user.rank.atLeast(UserRank.APPROVER)
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
return user.rank.atLeast(UserRank.EDITOR)
else:
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
@@ -123,6 +125,8 @@ def display_name_default(context):
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
# User authentication information
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
password = db.Column(db.String(255), nullable=True, server_default=None)
@@ -142,7 +146,7 @@ class User(db.Model, UserMixin):
# User email information
email = db.Column(db.String(255), nullable=True, unique=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
# User information
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
@@ -171,13 +175,14 @@ class User(db.Model, UserMixin):
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
def __init__(self, username=None, active=False, email=None, password=None):
self.username = username
self.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
self.display_name = username
self.is_active = active
self.email = email
@@ -186,8 +191,7 @@ class User(db.Model, UserMixin):
def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self) or \
Permission.APPROVE_CHANGES.check(self)
Permission.APPROVE_RELEASE.check(self)
def isClaimed(self):
return self.rank.atLeast(UserRank.NEW_MEMBER)
@@ -198,7 +202,7 @@ class User(db.Model, UserMixin):
elif self.rank == UserRank.BOT:
return "/static/bot_avatar.png"
else:
return gravatar(self.email or "")
return gravatar(self.email or f"{self.username}@content.minetest.net")
def checkPerm(self, user, perm):
if not user.is_authenticated:
@@ -212,10 +216,14 @@ class User(db.Model, UserMixin):
# Members can edit their own packages, and editors can edit any packages
if perm == Permission.CHANGE_AUTHOR:
return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_USERNAMES:
elif perm == Permission.CHANGE_USERNAMES:
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_RANK:
return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
return user == self or user.rank.atLeast(UserRank.ADMIN)
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank))
elif perm == Permission.CHANGE_DISPLAY_NAME:
return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
elif perm == Permission.CREATE_TOKEN:
if user == self:
return user.rank.atLeast(UserRank.MEMBER)
@@ -285,6 +293,7 @@ class UserEmailVerification(db.Model):
token = db.Column(db.String(32), nullable=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications")
is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
class EmailSubscription(db.Model):
@@ -373,7 +382,7 @@ class NotificationType(enum.Enum):
@classmethod
def coerce(cls, item):
return item if type(item) == NotificationType else NotificationType[item]
return item if type(item) == NotificationType else NotificationType[item.upper()]
class Notification(db.Model):

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
app/public/static/libs/easymde.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
app/public/static/libs/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

117
app/public/static/libs/url.min.js vendored Normal file
View File

@@ -0,0 +1,117 @@
/*! URI.js v1.19.5 http://medialize.github.io/URI.js/ */
/* build contains: IPv6.js, punycode.js, SecondLevelDomains.js, URI.js */
/*
URI.js - Mutating URLs
IPv6 Support
Version: 1.19.5
Author: Rodney Rehm
Web: http://medialize.github.io/URI.js/
Licensed under
MIT License http://www.opensource.org/licenses/mit-license
https://mths.be/punycode v1.4.0 by @mathias URI.js - Mutating URLs
Second Level Domain (SLD) Support
Version: 1.19.5
Author: Rodney Rehm
Web: http://medialize.github.io/URI.js/
Licensed under
MIT License http://www.opensource.org/licenses/mit-license
URI.js - Mutating URLs
Version: 1.19.5
Author: Rodney Rehm
Web: http://medialize.github.io/URI.js/
Licensed under
MIT License http://www.opensource.org/licenses/mit-license
*/
(function(t,w){"object"===typeof module&&module.exports?module.exports=w():"function"===typeof define&&define.amd?define(w):t.IPv6=w(t)})(this,function(t){var w=t&&t.IPv6;return{best:function(n){n=n.toLowerCase().split(":");var k=n.length,d=8;""===n[0]&&""===n[1]&&""===n[2]?(n.shift(),n.shift()):""===n[0]&&""===n[1]?n.shift():""===n[k-1]&&""===n[k-2]&&n.pop();k=n.length;-1!==n[k-1].indexOf(".")&&(d=7);var m;for(m=0;m<k&&""!==n[m];m++);if(m<d)for(n.splice(m,1,"0000");n.length<d;)n.splice(m,0,"0000");
for(m=0;m<d;m++){k=n[m].split("");for(var x=0;3>x;x++)if("0"===k[0]&&1<k.length)k.splice(0,1);else break;n[m]=k.join("")}k=-1;var v=x=0,J=-1,E=!1;for(m=0;m<d;m++)E?"0"===n[m]?v+=1:(E=!1,v>x&&(k=J,x=v)):"0"===n[m]&&(E=!0,J=m,v=1);v>x&&(k=J,x=v);1<x&&n.splice(k,x,"");k=n.length;d="";""===n[0]&&(d=":");for(m=0;m<k;m++){d+=n[m];if(m===k-1)break;d+=":"}""===n[k-1]&&(d+=":");return d},noConflict:function(){t.IPv6===this&&(t.IPv6=w);return this}}});
(function(t){function w(l){throw new RangeError(O[l]);}function n(l,p){for(var u=l.length,q=[];u--;)q[u]=p(l[u]);return q}function k(l,p){var u=l.split("@"),q="";1<u.length&&(q=u[0]+"@",l=u[1]);l=l.replace(L,".");u=l.split(".");u=n(u,p).join(".");return q+u}function d(l){for(var p=[],u=0,q=l.length,z,C;u<q;)z=l.charCodeAt(u++),55296<=z&&56319>=z&&u<q?(C=l.charCodeAt(u++),56320==(C&64512)?p.push(((z&1023)<<10)+(C&1023)+65536):(p.push(z),u--)):p.push(z);return p}function m(l){return n(l,function(p){var u=
"";65535<p&&(p-=65536,u+=g(p>>>10&1023|55296),p=56320|p&1023);return u+=g(p)}).join("")}function x(l,p,u){var q=0;l=u?F(l/700):l>>1;for(l+=F(l/p);455<l;q+=36)l=F(l/35);return F(q+36*l/(l+38))}function v(l){var p=[],u=l.length,q=0,z=128,C=72,a,b;var c=l.lastIndexOf("-");0>c&&(c=0);for(a=0;a<c;++a)128<=l.charCodeAt(a)&&w("not-basic"),p.push(l.charCodeAt(a));for(c=0<c?c+1:0;c<u;){a=q;var e=1;for(b=36;;b+=36){c>=u&&w("invalid-input");var f=l.charCodeAt(c++);f=10>f-48?f-22:26>f-65?f-65:26>f-97?f-97:36;
(36<=f||f>F((2147483647-q)/e))&&w("overflow");q+=f*e;var h=b<=C?1:b>=C+26?26:b-C;if(f<h)break;f=36-h;e>F(2147483647/f)&&w("overflow");e*=f}e=p.length+1;C=x(q-a,e,0==a);F(q/e)>2147483647-z&&w("overflow");z+=F(q/e);q%=e;p.splice(q++,0,z)}return m(p)}function J(l){var p,u,q,z=[];l=d(l);var C=l.length;var a=128;var b=0;var c=72;for(q=0;q<C;++q){var e=l[q];128>e&&z.push(g(e))}for((p=u=z.length)&&z.push("-");p<C;){var f=2147483647;for(q=0;q<C;++q)e=l[q],e>=a&&e<f&&(f=e);var h=p+1;f-a>F((2147483647-b)/h)&&
w("overflow");b+=(f-a)*h;a=f;for(q=0;q<C;++q)if(e=l[q],e<a&&2147483647<++b&&w("overflow"),e==a){var r=b;for(f=36;;f+=36){e=f<=c?1:f>=c+26?26:f-c;if(r<e)break;var y=r-e;r=36-e;var A=z;e+=y%r;A.push.call(A,g(e+22+75*(26>e)-0));r=F(y/r)}z.push(g(r+22+75*(26>r)-0));c=x(b,h,p==u);b=0;++p}++b;++a}return z.join("")}var E="object"==typeof exports&&exports&&!exports.nodeType&&exports,M="object"==typeof module&&module&&!module.nodeType&&module,H="object"==typeof global&&global;if(H.global===H||H.window===H||
H.self===H)t=H;var P=/^xn--/,N=/[^\x20-\x7E]/,L=/[\x2E\u3002\uFF0E\uFF61]/g,O={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},F=Math.floor,g=String.fromCharCode,B;var D={version:"1.3.2",ucs2:{decode:d,encode:m},decode:v,encode:J,toASCII:function(l){return k(l,function(p){return N.test(p)?"xn--"+J(p):p})},toUnicode:function(l){return k(l,function(p){return P.test(p)?v(p.slice(4).toLowerCase()):
p})}};if("function"==typeof define&&"object"==typeof define.amd&&define.amd)define("punycode",function(){return D});else if(E&&M)if(module.exports==E)M.exports=D;else for(B in D)D.hasOwnProperty(B)&&(E[B]=D[B]);else t.punycode=D})(this);
(function(t,w){"object"===typeof module&&module.exports?module.exports=w():"function"===typeof define&&define.amd?define(w):t.SecondLevelDomains=w(t)})(this,function(t){var w=t&&t.SecondLevelDomains,n={list:{ac:" com gov mil net org ",ae:" ac co gov mil name net org pro sch ",af:" com edu gov net org ",al:" com edu gov mil net org ",ao:" co ed gv it og pb ",ar:" com edu gob gov int mil net org tur ",at:" ac co gv or ",au:" asn com csiro edu gov id net org ",ba:" co com edu gov mil net org rs unbi unmo unsa untz unze ",
bb:" biz co com edu gov info net org store tv ",bh:" biz cc com edu gov info net org ",bn:" com edu gov net org ",bo:" com edu gob gov int mil net org tv ",br:" adm adv agr am arq art ato b bio blog bmd cim cng cnt com coop ecn edu eng esp etc eti far flog fm fnd fot fst g12 ggf gov imb ind inf jor jus lel mat med mil mus net nom not ntr odo org ppg pro psc psi qsl rec slg srv tmp trd tur tv vet vlog wiki zlg ",bs:" com edu gov net org ",bz:" du et om ov rg ",ca:" ab bc mb nb nf nl ns nt nu on pe qc sk yk ",
ck:" biz co edu gen gov info net org ",cn:" ac ah bj com cq edu fj gd gov gs gx gz ha hb he hi hl hn jl js jx ln mil net nm nx org qh sc sd sh sn sx tj tw xj xz yn zj ",co:" com edu gov mil net nom org ",cr:" ac c co ed fi go or sa ",cy:" ac biz com ekloges gov ltd name net org parliament press pro tm ","do":" art com edu gob gov mil net org sld web ",dz:" art asso com edu gov net org pol ",ec:" com edu fin gov info med mil net org pro ",eg:" com edu eun gov mil name net org sci ",er:" com edu gov ind mil net org rochest w ",
es:" com edu gob nom org ",et:" biz com edu gov info name net org ",fj:" ac biz com info mil name net org pro ",fk:" ac co gov net nom org ",fr:" asso com f gouv nom prd presse tm ",gg:" co net org ",gh:" com edu gov mil org ",gn:" ac com gov net org ",gr:" com edu gov mil net org ",gt:" com edu gob ind mil net org ",gu:" com edu gov net org ",hk:" com edu gov idv net org ",hu:" 2000 agrar bolt casino city co erotica erotika film forum games hotel info ingatlan jogasz konyvelo lakas media news org priv reklam sex shop sport suli szex tm tozsde utazas video ",
id:" ac co go mil net or sch web ",il:" ac co gov idf k12 muni net org ","in":" ac co edu ernet firm gen gov i ind mil net nic org res ",iq:" com edu gov i mil net org ",ir:" ac co dnssec gov i id net org sch ",it:" edu gov ",je:" co net org ",jo:" com edu gov mil name net org sch ",jp:" ac ad co ed go gr lg ne or ",ke:" ac co go info me mobi ne or sc ",kh:" com edu gov mil net org per ",ki:" biz com de edu gov info mob net org tel ",km:" asso com coop edu gouv k medecin mil nom notaires pharmaciens presse tm veterinaire ",
kn:" edu gov net org ",kr:" ac busan chungbuk chungnam co daegu daejeon es gangwon go gwangju gyeongbuk gyeonggi gyeongnam hs incheon jeju jeonbuk jeonnam k kg mil ms ne or pe re sc seoul ulsan ",kw:" com edu gov net org ",ky:" com edu gov net org ",kz:" com edu gov mil net org ",lb:" com edu gov net org ",lk:" assn com edu gov grp hotel int ltd net ngo org sch soc web ",lr:" com edu gov net org ",lv:" asn com conf edu gov id mil net org ",ly:" com edu gov id med net org plc sch ",ma:" ac co gov m net org press ",
mc:" asso tm ",me:" ac co edu gov its net org priv ",mg:" com edu gov mil nom org prd tm ",mk:" com edu gov inf name net org pro ",ml:" com edu gov net org presse ",mn:" edu gov org ",mo:" com edu gov net org ",mt:" com edu gov net org ",mv:" aero biz com coop edu gov info int mil museum name net org pro ",mw:" ac co com coop edu gov int museum net org ",mx:" com edu gob net org ",my:" com edu gov mil name net org sch ",nf:" arts com firm info net other per rec store web ",ng:" biz com edu gov mil mobi name net org sch ",
ni:" ac co com edu gob mil net nom org ",np:" com edu gov mil net org ",nr:" biz com edu gov info net org ",om:" ac biz co com edu gov med mil museum net org pro sch ",pe:" com edu gob mil net nom org sld ",ph:" com edu gov i mil net ngo org ",pk:" biz com edu fam gob gok gon gop gos gov net org web ",pl:" art bialystok biz com edu gda gdansk gorzow gov info katowice krakow lodz lublin mil net ngo olsztyn org poznan pwr radom slupsk szczecin torun warszawa waw wroc wroclaw zgora ",pr:" ac biz com edu est gov info isla name net org pro prof ",
ps:" com edu gov net org plo sec ",pw:" belau co ed go ne or ",ro:" arts com firm info nom nt org rec store tm www ",rs:" ac co edu gov in org ",sb:" com edu gov net org ",sc:" com edu gov net org ",sh:" co com edu gov net nom org ",sl:" com edu gov net org ",st:" co com consulado edu embaixada gov mil net org principe saotome store ",sv:" com edu gob org red ",sz:" ac co org ",tr:" av bbs bel biz com dr edu gen gov info k12 name net org pol tel tsk tv web ",tt:" aero biz cat co com coop edu gov info int jobs mil mobi museum name net org pro tel travel ",
tw:" club com ebiz edu game gov idv mil net org ",mu:" ac co com gov net or org ",mz:" ac co edu gov org ",na:" co com ",nz:" ac co cri geek gen govt health iwi maori mil net org parliament school ",pa:" abo ac com edu gob ing med net nom org sld ",pt:" com edu gov int net nome org publ ",py:" com edu gov mil net org ",qa:" com edu gov mil net org ",re:" asso com nom ",ru:" ac adygeya altai amur arkhangelsk astrakhan bashkiria belgorod bir bryansk buryatia cbg chel chelyabinsk chita chukotka chuvashia com dagestan e-burg edu gov grozny int irkutsk ivanovo izhevsk jar joshkar-ola kalmykia kaluga kamchatka karelia kazan kchr kemerovo khabarovsk khakassia khv kirov koenig komi kostroma kranoyarsk kuban kurgan kursk lipetsk magadan mari mari-el marine mil mordovia mosreg msk murmansk nalchik net nnov nov novosibirsk nsk omsk orenburg org oryol penza perm pp pskov ptz rnd ryazan sakhalin samara saratov simbirsk smolensk spb stavropol stv surgut tambov tatarstan tom tomsk tsaritsyn tsk tula tuva tver tyumen udm udmurtia ulan-ude vladikavkaz vladimir vladivostok volgograd vologda voronezh vrn vyatka yakutia yamal yekaterinburg yuzhno-sakhalinsk ",
rw:" ac co com edu gouv gov int mil net ",sa:" com edu gov med net org pub sch ",sd:" com edu gov info med net org tv ",se:" a ac b bd c d e f g h i k l m n o org p parti pp press r s t tm u w x y z ",sg:" com edu gov idn net org per ",sn:" art com edu gouv org perso univ ",sy:" com edu gov mil net news org ",th:" ac co go in mi net or ",tj:" ac biz co com edu go gov info int mil name net nic org test web ",tn:" agrinet com defense edunet ens fin gov ind info intl mincom nat net org perso rnrt rns rnu tourism ",
tz:" ac co go ne or ",ua:" biz cherkassy chernigov chernovtsy ck cn co com crimea cv dn dnepropetrovsk donetsk dp edu gov if in ivano-frankivsk kh kharkov kherson khmelnitskiy kiev kirovograd km kr ks kv lg lugansk lutsk lviv me mk net nikolaev od odessa org pl poltava pp rovno rv sebastopol sumy te ternopil uzhgorod vinnica vn zaporizhzhe zhitomir zp zt ",ug:" ac co go ne or org sc ",uk:" ac bl british-library co cym gov govt icnet jet lea ltd me mil mod national-library-scotland nel net nhs nic nls org orgn parliament plc police sch scot soc ",
us:" dni fed isa kids nsn ",uy:" com edu gub mil net org ",ve:" co com edu gob info mil net org web ",vi:" co com k12 net org ",vn:" ac biz com edu gov health info int name net org pro ",ye:" co com gov ltd me net org plc ",yu:" ac co edu gov org ",za:" ac agric alt bourse city co cybernet db edu gov grondar iaccess imt inca landesign law mil net ngo nis nom olivetti org pix school tm web ",zm:" ac co com edu gov net org sch ",com:"ar br cn de eu gb gr hu jpn kr no qc ru sa se uk us uy za ",net:"gb jp se uk ",
org:"ae",de:"com "},has:function(k){var d=k.lastIndexOf(".");if(0>=d||d>=k.length-1)return!1;var m=k.lastIndexOf(".",d-1);if(0>=m||m>=d-1)return!1;var x=n.list[k.slice(d+1)];return x?0<=x.indexOf(" "+k.slice(m+1,d)+" "):!1},is:function(k){var d=k.lastIndexOf(".");if(0>=d||d>=k.length-1||0<=k.lastIndexOf(".",d-1))return!1;var m=n.list[k.slice(d+1)];return m?0<=m.indexOf(" "+k.slice(0,d)+" "):!1},get:function(k){var d=k.lastIndexOf(".");if(0>=d||d>=k.length-1)return null;var m=k.lastIndexOf(".",d-1);
if(0>=m||m>=d-1)return null;var x=n.list[k.slice(d+1)];return!x||0>x.indexOf(" "+k.slice(m+1,d)+" ")?null:k.slice(m+1)},noConflict:function(){t.SecondLevelDomains===this&&(t.SecondLevelDomains=w);return this}};return n});
(function(t,w){"object"===typeof module&&module.exports?module.exports=w(require("./punycode"),require("./IPv6"),require("./SecondLevelDomains")):"function"===typeof define&&define.amd?define(["./punycode","./IPv6","./SecondLevelDomains"],w):t.URI=w(t.punycode,t.IPv6,t.SecondLevelDomains,t)})(this,function(t,w,n,k){function d(a,b){var c=1<=arguments.length,e=2<=arguments.length;if(!(this instanceof d))return c?e?new d(a,b):new d(a):new d;if(void 0===a){if(c)throw new TypeError("undefined is not a valid argument for URI");
a="undefined"!==typeof location?location.href+"":""}if(null===a&&c)throw new TypeError("null is not a valid argument for URI");this.href(a);return void 0!==b?this.absoluteTo(b):this}function m(a){return a.replace(/([.*+?^=!:${}()|[\]\/\\])/g,"\\$1")}function x(a){return void 0===a?"Undefined":String(Object.prototype.toString.call(a)).slice(8,-1)}function v(a){return"Array"===x(a)}function J(a,b){var c={},e;if("RegExp"===x(b))c=null;else if(v(b)){var f=0;for(e=b.length;f<e;f++)c[b[f]]=!0}else c[b]=
!0;f=0;for(e=a.length;f<e;f++)if(c&&void 0!==c[a[f]]||!c&&b.test(a[f]))a.splice(f,1),e--,f--;return a}function E(a,b){var c;if(v(b)){var e=0;for(c=b.length;e<c;e++)if(!E(a,b[e]))return!1;return!0}var f=x(b);e=0;for(c=a.length;e<c;e++)if("RegExp"===f){if("string"===typeof a[e]&&a[e].match(b))return!0}else if(a[e]===b)return!0;return!1}function M(a,b){if(!v(a)||!v(b)||a.length!==b.length)return!1;a.sort();b.sort();for(var c=0,e=a.length;c<e;c++)if(a[c]!==b[c])return!1;return!0}function H(a){return a.replace(/^\/+|\/+$/g,
"")}function P(a){return escape(a)}function N(a){return encodeURIComponent(a).replace(/[!'()*]/g,P).replace(/\*/g,"%2A")}function L(a){return function(b,c){if(void 0===b)return this._parts[a]||"";this._parts[a]=b||null;this.build(!c);return this}}function O(a,b){return function(c,e){if(void 0===c)return this._parts[a]||"";null!==c&&(c+="",c.charAt(0)===b&&(c=c.substring(1)));this._parts[a]=c;this.build(!e);return this}}var F=k&&k.URI;d.version="1.19.5";var g=d.prototype,B=Object.prototype.hasOwnProperty;
d._parts=function(){return{protocol:null,username:null,password:null,hostname:null,urn:null,port:null,path:null,query:null,fragment:null,preventInvalidHostname:d.preventInvalidHostname,duplicateQueryParameters:d.duplicateQueryParameters,escapeQuerySpace:d.escapeQuerySpace}};d.preventInvalidHostname=!1;d.duplicateQueryParameters=!1;d.escapeQuerySpace=!0;d.protocol_expression=/^[a-z][a-z0-9.+-]*$/i;d.idn_expression=/[^a-z0-9\._-]/i;d.punycode_expression=/(xn--)/i;d.ip4_expression=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
d.ip6_expression=/^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/;
d.find_uri_expression=/\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u2018\u2019]))/ig;d.findUri={start:/\b(?:([a-z][a-z0-9.+-]*:\/\/)|www\.)/gi,end:/[\s\r\n]|$/,trim:/[`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u201e\u2018\u2019]+$/,parens:/(\([^\)]*\)|\[[^\]]*\]|\{[^}]*\}|<[^>]*>)/g};d.defaultPorts={http:"80",https:"443",ftp:"21",
gopher:"70",ws:"80",wss:"443"};d.hostProtocols=["http","https"];d.invalid_hostname_characters=/[^a-zA-Z0-9\.\-:_]/;d.domAttributes={a:"href",blockquote:"cite",link:"href",base:"href",script:"src",form:"action",img:"src",area:"href",iframe:"src",embed:"src",source:"src",track:"src",input:"src",audio:"src",video:"src"};d.getDomAttribute=function(a){if(a&&a.nodeName){var b=a.nodeName.toLowerCase();if("input"!==b||"image"===a.type)return d.domAttributes[b]}};d.encode=N;d.decode=decodeURIComponent;d.iso8859=
function(){d.encode=escape;d.decode=unescape};d.unicode=function(){d.encode=N;d.decode=decodeURIComponent};d.characters={pathname:{encode:{expression:/%(24|26|2B|2C|3B|3D|3A|40)/ig,map:{"%24":"$","%26":"&","%2B":"+","%2C":",","%3B":";","%3D":"=","%3A":":","%40":"@"}},decode:{expression:/[\/\?#]/g,map:{"/":"%2F","?":"%3F","#":"%23"}}},reserved:{encode:{expression:/%(21|23|24|26|27|28|29|2A|2B|2C|2F|3A|3B|3D|3F|40|5B|5D)/ig,map:{"%3A":":","%2F":"/","%3F":"?","%23":"#","%5B":"[","%5D":"]","%40":"@",
"%21":"!","%24":"$","%26":"&","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",","%3B":";","%3D":"="}}},urnpath:{encode:{expression:/%(21|24|27|28|29|2A|2B|2C|3B|3D|40)/ig,map:{"%21":"!","%24":"$","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",","%3B":";","%3D":"=","%40":"@"}},decode:{expression:/[\/\?#:]/g,map:{"/":"%2F","?":"%3F","#":"%23",":":"%3A"}}}};d.encodeQuery=function(a,b){var c=d.encode(a+"");void 0===b&&(b=d.escapeQuerySpace);return b?c.replace(/%20/g,"+"):c};d.decodeQuery=
function(a,b){a+="";void 0===b&&(b=d.escapeQuerySpace);try{return d.decode(b?a.replace(/\+/g,"%20"):a)}catch(c){return a}};var D={encode:"encode",decode:"decode"},l,p=function(a,b){return function(c){try{return d[b](c+"").replace(d.characters[a][b].expression,function(e){return d.characters[a][b].map[e]})}catch(e){return c}}};for(l in D)d[l+"PathSegment"]=p("pathname",D[l]),d[l+"UrnPathSegment"]=p("urnpath",D[l]);D=function(a,b,c){return function(e){var f=c?function(y){return d[b](d[c](y))}:d[b];
e=(e+"").split(a);for(var h=0,r=e.length;h<r;h++)e[h]=f(e[h]);return e.join(a)}};d.decodePath=D("/","decodePathSegment");d.decodeUrnPath=D(":","decodeUrnPathSegment");d.recodePath=D("/","encodePathSegment","decode");d.recodeUrnPath=D(":","encodeUrnPathSegment","decode");d.encodeReserved=p("reserved","encode");d.parse=function(a,b){b||(b={preventInvalidHostname:d.preventInvalidHostname});var c=a.indexOf("#");-1<c&&(b.fragment=a.substring(c+1)||null,a=a.substring(0,c));c=a.indexOf("?");-1<c&&(b.query=
a.substring(c+1)||null,a=a.substring(0,c));"//"===a.substring(0,2)?(b.protocol=null,a=a.substring(2),a=d.parseAuthority(a,b)):(c=a.indexOf(":"),-1<c&&(b.protocol=a.substring(0,c)||null,b.protocol&&!b.protocol.match(d.protocol_expression)?b.protocol=void 0:"//"===a.substring(c+1,c+3)?(a=a.substring(c+3),a=d.parseAuthority(a,b)):(a=a.substring(c+1),b.urn=!0)));b.path=a;return b};d.parseHost=function(a,b){a||(a="");a=a.replace(/\\/g,"/");var c=a.indexOf("/");-1===c&&(c=a.length);if("["===a.charAt(0)){var e=
a.indexOf("]");b.hostname=a.substring(1,e)||null;b.port=a.substring(e+2,c)||null;"/"===b.port&&(b.port=null)}else{var f=a.indexOf(":");e=a.indexOf("/");f=a.indexOf(":",f+1);-1!==f&&(-1===e||f<e)?(b.hostname=a.substring(0,c)||null,b.port=null):(e=a.substring(0,c).split(":"),b.hostname=e[0]||null,b.port=e[1]||null)}b.hostname&&"/"!==a.substring(c).charAt(0)&&(c++,a="/"+a);b.preventInvalidHostname&&d.ensureValidHostname(b.hostname,b.protocol);b.port&&d.ensureValidPort(b.port);return a.substring(c)||
"/"};d.parseAuthority=function(a,b){a=d.parseUserinfo(a,b);return d.parseHost(a,b)};d.parseUserinfo=function(a,b){var c=a;-1!==a.indexOf("\\")&&(a=a.replace(/\\/g,"/"));var e=a.indexOf("/"),f=a.lastIndexOf("@",-1<e?e:a.length-1);-1<f&&(-1===e||f<e)?(e=a.substring(0,f).split(":"),b.username=e[0]?d.decode(e[0]):null,e.shift(),b.password=e[0]?d.decode(e.join(":")):null,a=c.substring(f+1)):(b.username=null,b.password=null);return a};d.parseQuery=function(a,b){if(!a)return{};a=a.replace(/&+/g,"&").replace(/^\?*&*|&+$/g,
"");if(!a)return{};for(var c={},e=a.split("&"),f=e.length,h,r,y=0;y<f;y++)if(h=e[y].split("="),r=d.decodeQuery(h.shift(),b),h=h.length?d.decodeQuery(h.join("="),b):null,B.call(c,r)){if("string"===typeof c[r]||null===c[r])c[r]=[c[r]];c[r].push(h)}else c[r]=h;return c};d.build=function(a){var b="",c=!1;a.protocol&&(b+=a.protocol+":");a.urn||!b&&!a.hostname||(b+="//",c=!0);b+=d.buildAuthority(a)||"";"string"===typeof a.path&&("/"!==a.path.charAt(0)&&c&&(b+="/"),b+=a.path);"string"===typeof a.query&&
a.query&&(b+="?"+a.query);"string"===typeof a.fragment&&a.fragment&&(b+="#"+a.fragment);return b};d.buildHost=function(a){var b="";if(a.hostname)b=d.ip6_expression.test(a.hostname)?b+("["+a.hostname+"]"):b+a.hostname;else return"";a.port&&(b+=":"+a.port);return b};d.buildAuthority=function(a){return d.buildUserinfo(a)+d.buildHost(a)};d.buildUserinfo=function(a){var b="";a.username&&(b+=d.encode(a.username));a.password&&(b+=":"+d.encode(a.password));b&&(b+="@");return b};d.buildQuery=function(a,b,
c){var e="",f,h;for(f in a)if(B.call(a,f))if(v(a[f])){var r={};var y=0;for(h=a[f].length;y<h;y++)void 0!==a[f][y]&&void 0===r[a[f][y]+""]&&(e+="&"+d.buildQueryParameter(f,a[f][y],c),!0!==b&&(r[a[f][y]+""]=!0))}else void 0!==a[f]&&(e+="&"+d.buildQueryParameter(f,a[f],c));return e.substring(1)};d.buildQueryParameter=function(a,b,c){return d.encodeQuery(a,c)+(null!==b?"="+d.encodeQuery(b,c):"")};d.addQuery=function(a,b,c){if("object"===typeof b)for(var e in b)B.call(b,e)&&d.addQuery(a,e,b[e]);else if("string"===
typeof b)void 0===a[b]?a[b]=c:("string"===typeof a[b]&&(a[b]=[a[b]]),v(c)||(c=[c]),a[b]=(a[b]||[]).concat(c));else throw new TypeError("URI.addQuery() accepts an object, string as the name parameter");};d.setQuery=function(a,b,c){if("object"===typeof b)for(var e in b)B.call(b,e)&&d.setQuery(a,e,b[e]);else if("string"===typeof b)a[b]=void 0===c?null:c;else throw new TypeError("URI.setQuery() accepts an object, string as the name parameter");};d.removeQuery=function(a,b,c){var e;if(v(b))for(c=0,e=b.length;c<
e;c++)a[b[c]]=void 0;else if("RegExp"===x(b))for(e in a)b.test(e)&&(a[e]=void 0);else if("object"===typeof b)for(e in b)B.call(b,e)&&d.removeQuery(a,e,b[e]);else if("string"===typeof b)void 0!==c?"RegExp"===x(c)?!v(a[b])&&c.test(a[b])?a[b]=void 0:a[b]=J(a[b],c):a[b]!==String(c)||v(c)&&1!==c.length?v(a[b])&&(a[b]=J(a[b],c)):a[b]=void 0:a[b]=void 0;else throw new TypeError("URI.removeQuery() accepts an object, string, RegExp as the first parameter");};d.hasQuery=function(a,b,c,e){switch(x(b)){case "String":break;
case "RegExp":for(var f in a)if(B.call(a,f)&&b.test(f)&&(void 0===c||d.hasQuery(a,f,c)))return!0;return!1;case "Object":for(var h in b)if(B.call(b,h)&&!d.hasQuery(a,h,b[h]))return!1;return!0;default:throw new TypeError("URI.hasQuery() accepts a string, regular expression or object as the name parameter");}switch(x(c)){case "Undefined":return b in a;case "Boolean":return a=!(v(a[b])?!a[b].length:!a[b]),c===a;case "Function":return!!c(a[b],b,a);case "Array":return v(a[b])?(e?E:M)(a[b],c):!1;case "RegExp":return v(a[b])?
e?E(a[b],c):!1:!(!a[b]||!a[b].match(c));case "Number":c=String(c);case "String":return v(a[b])?e?E(a[b],c):!1:a[b]===c;default:throw new TypeError("URI.hasQuery() accepts undefined, boolean, string, number, RegExp, Function as the value parameter");}};d.joinPaths=function(){for(var a=[],b=[],c=0,e=0;e<arguments.length;e++){var f=new d(arguments[e]);a.push(f);f=f.segment();for(var h=0;h<f.length;h++)"string"===typeof f[h]&&b.push(f[h]),f[h]&&c++}if(!b.length||!c)return new d("");b=(new d("")).segment(b);
""!==a[0].path()&&"/"!==a[0].path().slice(0,1)||b.path("/"+b.path());return b.normalize()};d.commonPath=function(a,b){var c=Math.min(a.length,b.length),e;for(e=0;e<c;e++)if(a.charAt(e)!==b.charAt(e)){e--;break}if(1>e)return a.charAt(0)===b.charAt(0)&&"/"===a.charAt(0)?"/":"";if("/"!==a.charAt(e)||"/"!==b.charAt(e))e=a.substring(0,e).lastIndexOf("/");return a.substring(0,e+1)};d.withinString=function(a,b,c){c||(c={});var e=c.start||d.findUri.start,f=c.end||d.findUri.end,h=c.trim||d.findUri.trim,r=
c.parens||d.findUri.parens,y=/[a-z0-9-]=["']?$/i;for(e.lastIndex=0;;){var A=e.exec(a);if(!A)break;var K=A.index;if(c.ignoreHtml){var G=a.slice(Math.max(K-3,0),K);if(G&&y.test(G))continue}var I=K+a.slice(K).search(f);G=a.slice(K,I);for(I=-1;;){var Q=r.exec(G);if(!Q)break;I=Math.max(I,Q.index+Q[0].length)}G=-1<I?G.slice(0,I)+G.slice(I).replace(h,""):G.replace(h,"");G.length<=A[0].length||c.ignore&&c.ignore.test(G)||(I=K+G.length,A=b(G,K,I,a),void 0===A?e.lastIndex=I:(A=String(A),a=a.slice(0,K)+A+a.slice(I),
e.lastIndex=K+A.length))}e.lastIndex=0;return a};d.ensureValidHostname=function(a,b){var c=!!a,e=!1;b&&(e=E(d.hostProtocols,b));if(e&&!c)throw new TypeError("Hostname cannot be empty, if protocol is "+b);if(a&&a.match(d.invalid_hostname_characters)){if(!t)throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-:_] and Punycode.js is not available');if(t.toASCII(a).match(d.invalid_hostname_characters))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-:_]');
}};d.ensureValidPort=function(a){if(a){var b=Number(a);if(!(/^[0-9]+$/.test(b)&&0<b&&65536>b))throw new TypeError('Port "'+a+'" is not a valid port');}};d.noConflict=function(a){if(a)return a={URI:this.noConflict()},k.URITemplate&&"function"===typeof k.URITemplate.noConflict&&(a.URITemplate=k.URITemplate.noConflict()),k.IPv6&&"function"===typeof k.IPv6.noConflict&&(a.IPv6=k.IPv6.noConflict()),k.SecondLevelDomains&&"function"===typeof k.SecondLevelDomains.noConflict&&(a.SecondLevelDomains=k.SecondLevelDomains.noConflict()),
a;k.URI===this&&(k.URI=F);return this};g.build=function(a){if(!0===a)this._deferred_build=!0;else if(void 0===a||this._deferred_build)this._string=d.build(this._parts),this._deferred_build=!1;return this};g.clone=function(){return new d(this)};g.valueOf=g.toString=function(){return this.build(!1)._string};g.protocol=L("protocol");g.username=L("username");g.password=L("password");g.hostname=L("hostname");g.port=L("port");g.query=O("query","?");g.fragment=O("fragment","#");g.search=function(a,b){var c=
this.query(a,b);return"string"===typeof c&&c.length?"?"+c:c};g.hash=function(a,b){var c=this.fragment(a,b);return"string"===typeof c&&c.length?"#"+c:c};g.pathname=function(a,b){if(void 0===a||!0===a){var c=this._parts.path||(this._parts.hostname?"/":"");return a?(this._parts.urn?d.decodeUrnPath:d.decodePath)(c):c}this._parts.path=this._parts.urn?a?d.recodeUrnPath(a):"":a?d.recodePath(a):"/";this.build(!b);return this};g.path=g.pathname;g.href=function(a,b){var c;if(void 0===a)return this.toString();
this._string="";this._parts=d._parts();var e=a instanceof d,f="object"===typeof a&&(a.hostname||a.path||a.pathname);a.nodeName&&(f=d.getDomAttribute(a),a=a[f]||"",f=!1);!e&&f&&void 0!==a.pathname&&(a=a.toString());if("string"===typeof a||a instanceof String)this._parts=d.parse(String(a),this._parts);else if(e||f){e=e?a._parts:a;for(c in e)"query"!==c&&B.call(this._parts,c)&&(this._parts[c]=e[c]);e.query&&this.query(e.query,!1)}else throw new TypeError("invalid input");this.build(!b);return this};
g.is=function(a){var b=!1,c=!1,e=!1,f=!1,h=!1,r=!1,y=!1,A=!this._parts.urn;this._parts.hostname&&(A=!1,c=d.ip4_expression.test(this._parts.hostname),e=d.ip6_expression.test(this._parts.hostname),b=c||e,h=(f=!b)&&n&&n.has(this._parts.hostname),r=f&&d.idn_expression.test(this._parts.hostname),y=f&&d.punycode_expression.test(this._parts.hostname));switch(a.toLowerCase()){case "relative":return A;case "absolute":return!A;case "domain":case "name":return f;case "sld":return h;case "ip":return b;case "ip4":case "ipv4":case "inet4":return c;
case "ip6":case "ipv6":case "inet6":return e;case "idn":return r;case "url":return!this._parts.urn;case "urn":return!!this._parts.urn;case "punycode":return y}return null};var u=g.protocol,q=g.port,z=g.hostname;g.protocol=function(a,b){if(a&&(a=a.replace(/:(\/\/)?$/,""),!a.match(d.protocol_expression)))throw new TypeError('Protocol "'+a+"\" contains characters other than [A-Z0-9.+-] or doesn't start with [A-Z]");return u.call(this,a,b)};g.scheme=g.protocol;g.port=function(a,b){if(this._parts.urn)return void 0===
a?"":this;void 0!==a&&(0===a&&(a=null),a&&(a+="",":"===a.charAt(0)&&(a=a.substring(1)),d.ensureValidPort(a)));return q.call(this,a,b)};g.hostname=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0!==a){var c={preventInvalidHostname:this._parts.preventInvalidHostname};if("/"!==d.parseHost(a,c))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-]');a=c.hostname;this._parts.preventInvalidHostname&&d.ensureValidHostname(a,this._parts.protocol)}return z.call(this,
a,b)};g.origin=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){var c=this.protocol();return this.authority()?(c?c+"://":"")+this.authority():""}c=d(a);this.protocol(c.protocol()).authority(c.authority()).build(!b);return this};g.host=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?d.buildHost(this._parts):"";if("/"!==d.parseHost(a,this._parts))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-]');
this.build(!b);return this};g.authority=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?d.buildAuthority(this._parts):"";if("/"!==d.parseAuthority(a,this._parts))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-]');this.build(!b);return this};g.userinfo=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){var c=d.buildUserinfo(this._parts);return c?c.substring(0,c.length-1):c}"@"!==a[a.length-1]&&
(a+="@");d.parseUserinfo(a,this._parts);this.build(!b);return this};g.resource=function(a,b){if(void 0===a)return this.path()+this.search()+this.hash();var c=d.parse(a);this._parts.path=c.path;this._parts.query=c.query;this._parts.fragment=c.fragment;this.build(!b);return this};g.subdomain=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.length-this.domain().length-1;return this._parts.hostname.substring(0,
c)||""}c=this._parts.hostname.length-this.domain().length;c=this._parts.hostname.substring(0,c);c=new RegExp("^"+m(c));a&&"."!==a.charAt(a.length-1)&&(a+=".");if(-1!==a.indexOf(":"))throw new TypeError("Domains cannot contain colons");a&&d.ensureValidHostname(a,this._parts.protocol);this._parts.hostname=this._parts.hostname.replace(c,a);this.build(!b);return this};g.domain=function(a,b){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(b=a,a=void 0);if(void 0===a){if(!this._parts.hostname||
this.is("IP"))return"";var c=this._parts.hostname.match(/\./g);if(c&&2>c.length)return this._parts.hostname;c=this._parts.hostname.length-this.tld(b).length-1;c=this._parts.hostname.lastIndexOf(".",c-1)+1;return this._parts.hostname.substring(c)||""}if(!a)throw new TypeError("cannot set domain empty");if(-1!==a.indexOf(":"))throw new TypeError("Domains cannot contain colons");d.ensureValidHostname(a,this._parts.protocol);!this._parts.hostname||this.is("IP")?this._parts.hostname=a:(c=new RegExp(m(this.domain())+
"$"),this._parts.hostname=this._parts.hostname.replace(c,a));this.build(!b);return this};g.tld=function(a,b){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(b=a,a=void 0);if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.lastIndexOf(".");c=this._parts.hostname.substring(c+1);return!0!==b&&n&&n.list[c.toLowerCase()]?n.get(this._parts.hostname)||c:c}if(a)if(a.match(/[^a-zA-Z0-9-]/))if(n&&n.is(a))c=new RegExp(m(this.tld())+"$"),this._parts.hostname=
this._parts.hostname.replace(c,a);else throw new TypeError('TLD "'+a+'" contains characters other than [A-Z0-9]');else{if(!this._parts.hostname||this.is("IP"))throw new ReferenceError("cannot set TLD on non-domain host");c=new RegExp(m(this.tld())+"$");this._parts.hostname=this._parts.hostname.replace(c,a)}else throw new TypeError("cannot set TLD empty");this.build(!b);return this};g.directory=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path&&
!this._parts.hostname)return"";if("/"===this._parts.path)return"/";var c=this._parts.path.length-this.filename().length-1;c=this._parts.path.substring(0,c)||(this._parts.hostname?"/":"");return a?d.decodePath(c):c}c=this._parts.path.length-this.filename().length;c=this._parts.path.substring(0,c);c=new RegExp("^"+m(c));this.is("relative")||(a||(a="/"),"/"!==a.charAt(0)&&(a="/"+a));a&&"/"!==a.charAt(a.length-1)&&(a+="/");a=d.recodePath(a);this._parts.path=this._parts.path.replace(c,a);this.build(!b);
return this};g.filename=function(a,b){if(this._parts.urn)return void 0===a?"":this;if("string"!==typeof a){if(!this._parts.path||"/"===this._parts.path)return"";var c=this._parts.path.lastIndexOf("/");c=this._parts.path.substring(c+1);return a?d.decodePathSegment(c):c}c=!1;"/"===a.charAt(0)&&(a=a.substring(1));a.match(/\.?\//)&&(c=!0);var e=new RegExp(m(this.filename())+"$");a=d.recodePath(a);this._parts.path=this._parts.path.replace(e,a);c?this.normalizePath(b):this.build(!b);return this};g.suffix=
function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var c=this.filename(),e=c.lastIndexOf(".");if(-1===e)return"";c=c.substring(e+1);c=/^[a-z0-9%]+$/i.test(c)?c:"";return a?d.decodePathSegment(c):c}"."===a.charAt(0)&&(a=a.substring(1));if(c=this.suffix())e=a?new RegExp(m(c)+"$"):new RegExp(m("."+c)+"$");else{if(!a)return this;this._parts.path+="."+d.recodePath(a)}e&&(a=d.recodePath(a),this._parts.path=this._parts.path.replace(e,
a));this.build(!b);return this};g.segment=function(a,b,c){var e=this._parts.urn?":":"/",f=this.path(),h="/"===f.substring(0,1);f=f.split(e);void 0!==a&&"number"!==typeof a&&(c=b,b=a,a=void 0);if(void 0!==a&&"number"!==typeof a)throw Error('Bad segment "'+a+'", must be 0-based integer');h&&f.shift();0>a&&(a=Math.max(f.length+a,0));if(void 0===b)return void 0===a?f:f[a];if(null===a||void 0===f[a])if(v(b)){f=[];a=0;for(var r=b.length;a<r;a++)if(b[a].length||f.length&&f[f.length-1].length)f.length&&!f[f.length-
1].length&&f.pop(),f.push(H(b[a]))}else{if(b||"string"===typeof b)b=H(b),""===f[f.length-1]?f[f.length-1]=b:f.push(b)}else b?f[a]=H(b):f.splice(a,1);h&&f.unshift("");return this.path(f.join(e),c)};g.segmentCoded=function(a,b,c){var e;"number"!==typeof a&&(c=b,b=a,a=void 0);if(void 0===b){a=this.segment(a,b,c);if(v(a)){var f=0;for(e=a.length;f<e;f++)a[f]=d.decode(a[f])}else a=void 0!==a?d.decode(a):void 0;return a}if(v(b))for(f=0,e=b.length;f<e;f++)b[f]=d.encode(b[f]);else b="string"===typeof b||b instanceof
String?d.encode(b):b;return this.segment(a,b,c)};var C=g.query;g.query=function(a,b){if(!0===a)return d.parseQuery(this._parts.query,this._parts.escapeQuerySpace);if("function"===typeof a){var c=d.parseQuery(this._parts.query,this._parts.escapeQuerySpace),e=a.call(this,c);this._parts.query=d.buildQuery(e||c,this._parts.duplicateQueryParameters,this._parts.escapeQuerySpace);this.build(!b);return this}return void 0!==a&&"string"!==typeof a?(this._parts.query=d.buildQuery(a,this._parts.duplicateQueryParameters,
this._parts.escapeQuerySpace),this.build(!b),this):C.call(this,a,b)};g.setQuery=function(a,b,c){var e=d.parseQuery(this._parts.query,this._parts.escapeQuerySpace);if("string"===typeof a||a instanceof String)e[a]=void 0!==b?b:null;else if("object"===typeof a)for(var f in a)B.call(a,f)&&(e[f]=a[f]);else throw new TypeError("URI.addQuery() accepts an object, string as the name parameter");this._parts.query=d.buildQuery(e,this._parts.duplicateQueryParameters,this._parts.escapeQuerySpace);"string"!==typeof a&&
(c=b);this.build(!c);return this};g.addQuery=function(a,b,c){var e=d.parseQuery(this._parts.query,this._parts.escapeQuerySpace);d.addQuery(e,a,void 0===b?null:b);this._parts.query=d.buildQuery(e,this._parts.duplicateQueryParameters,this._parts.escapeQuerySpace);"string"!==typeof a&&(c=b);this.build(!c);return this};g.removeQuery=function(a,b,c){var e=d.parseQuery(this._parts.query,this._parts.escapeQuerySpace);d.removeQuery(e,a,b);this._parts.query=d.buildQuery(e,this._parts.duplicateQueryParameters,
this._parts.escapeQuerySpace);"string"!==typeof a&&(c=b);this.build(!c);return this};g.hasQuery=function(a,b,c){var e=d.parseQuery(this._parts.query,this._parts.escapeQuerySpace);return d.hasQuery(e,a,b,c)};g.setSearch=g.setQuery;g.addSearch=g.addQuery;g.removeSearch=g.removeQuery;g.hasSearch=g.hasQuery;g.normalize=function(){return this._parts.urn?this.normalizeProtocol(!1).normalizePath(!1).normalizeQuery(!1).normalizeFragment(!1).build():this.normalizeProtocol(!1).normalizeHostname(!1).normalizePort(!1).normalizePath(!1).normalizeQuery(!1).normalizeFragment(!1).build()};
g.normalizeProtocol=function(a){"string"===typeof this._parts.protocol&&(this._parts.protocol=this._parts.protocol.toLowerCase(),this.build(!a));return this};g.normalizeHostname=function(a){this._parts.hostname&&(this.is("IDN")&&t?this._parts.hostname=t.toASCII(this._parts.hostname):this.is("IPv6")&&w&&(this._parts.hostname=w.best(this._parts.hostname)),this._parts.hostname=this._parts.hostname.toLowerCase(),this.build(!a));return this};g.normalizePort=function(a){"string"===typeof this._parts.protocol&&
this._parts.port===d.defaultPorts[this._parts.protocol]&&(this._parts.port=null,this.build(!a));return this};g.normalizePath=function(a){var b=this._parts.path;if(!b)return this;if(this._parts.urn)return this._parts.path=d.recodeUrnPath(this._parts.path),this.build(!a),this;if("/"===this._parts.path)return this;b=d.recodePath(b);var c="";if("/"!==b.charAt(0)){var e=!0;b="/"+b}if("/.."===b.slice(-3)||"/."===b.slice(-2))b+="/";b=b.replace(/(\/(\.\/)+)|(\/\.$)/g,"/").replace(/\/{2,}/g,"/");e&&(c=b.substring(1).match(/^(\.\.\/)+/)||
"")&&(c=c[0]);for(;;){var f=b.search(/\/\.\.(\/|$)/);if(-1===f)break;else if(0===f){b=b.substring(3);continue}var h=b.substring(0,f).lastIndexOf("/");-1===h&&(h=f);b=b.substring(0,h)+b.substring(f+3)}e&&this.is("relative")&&(b=c+b.substring(1));this._parts.path=b;this.build(!a);return this};g.normalizePathname=g.normalizePath;g.normalizeQuery=function(a){"string"===typeof this._parts.query&&(this._parts.query.length?this.query(d.parseQuery(this._parts.query,this._parts.escapeQuerySpace)):this._parts.query=
null,this.build(!a));return this};g.normalizeFragment=function(a){this._parts.fragment||(this._parts.fragment=null,this.build(!a));return this};g.normalizeSearch=g.normalizeQuery;g.normalizeHash=g.normalizeFragment;g.iso8859=function(){var a=d.encode,b=d.decode;d.encode=escape;d.decode=decodeURIComponent;try{this.normalize()}finally{d.encode=a,d.decode=b}return this};g.unicode=function(){var a=d.encode,b=d.decode;d.encode=N;d.decode=unescape;try{this.normalize()}finally{d.encode=a,d.decode=b}return this};
g.readable=function(){var a=this.clone();a.username("").password("").normalize();var b="";a._parts.protocol&&(b+=a._parts.protocol+"://");a._parts.hostname&&(a.is("punycode")&&t?(b+=t.toUnicode(a._parts.hostname),a._parts.port&&(b+=":"+a._parts.port)):b+=a.host());a._parts.hostname&&a._parts.path&&"/"!==a._parts.path.charAt(0)&&(b+="/");b+=a.path(!0);if(a._parts.query){for(var c="",e=0,f=a._parts.query.split("&"),h=f.length;e<h;e++){var r=(f[e]||"").split("=");c+="&"+d.decodeQuery(r[0],this._parts.escapeQuerySpace).replace(/&/g,
"%26");void 0!==r[1]&&(c+="="+d.decodeQuery(r[1],this._parts.escapeQuerySpace).replace(/&/g,"%26"))}b+="?"+c.substring(1)}return b+=d.decodeQuery(a.hash(),!0)};g.absoluteTo=function(a){var b=this.clone(),c=["protocol","username","password","hostname","port"],e,f;if(this._parts.urn)throw Error("URNs do not have any generally defined hierarchical components");a instanceof d||(a=new d(a));if(b._parts.protocol)return b;b._parts.protocol=a._parts.protocol;if(this._parts.hostname)return b;for(e=0;f=c[e];e++)b._parts[f]=
a._parts[f];b._parts.path?(".."===b._parts.path.substring(-2)&&(b._parts.path+="/"),"/"!==b.path().charAt(0)&&(c=(c=a.directory())?c:0===a.path().indexOf("/")?"/":"",b._parts.path=(c?c+"/":"")+b._parts.path,b.normalizePath())):(b._parts.path=a._parts.path,b._parts.query||(b._parts.query=a._parts.query));b.build();return b};g.relativeTo=function(a){var b=this.clone().normalize();if(b._parts.urn)throw Error("URNs do not have any generally defined hierarchical components");a=(new d(a)).normalize();var c=
b._parts;var e=a._parts;var f=b.path();a=a.path();if("/"!==f.charAt(0))throw Error("URI is already relative");if("/"!==a.charAt(0))throw Error("Cannot calculate a URI relative to another relative URI");c.protocol===e.protocol&&(c.protocol=null);if(c.username===e.username&&c.password===e.password&&null===c.protocol&&null===c.username&&null===c.password&&c.hostname===e.hostname&&c.port===e.port)c.hostname=null,c.port=null;else return b.build();if(f===a)return c.path="",b.build();f=d.commonPath(f,a);
if(!f)return b.build();e=e.path.substring(f.length).replace(/[^\/]*$/,"").replace(/.*?\//g,"../");c.path=e+c.path.substring(f.length)||"./";return b.build()};g.equals=function(a){var b=this.clone(),c=new d(a);a={};var e;b.normalize();c.normalize();if(b.toString()===c.toString())return!0;var f=b.query();var h=c.query();b.query("");c.query("");if(b.toString()!==c.toString()||f.length!==h.length)return!1;b=d.parseQuery(f,this._parts.escapeQuerySpace);h=d.parseQuery(h,this._parts.escapeQuerySpace);for(e in b)if(B.call(b,
e)){if(!v(b[e])){if(b[e]!==h[e])return!1}else if(!M(b[e],h[e]))return!1;a[e]=!0}for(e in h)if(B.call(h,e)&&!a[e])return!1;return!0};g.preventInvalidHostname=function(a){this._parts.preventInvalidHostname=!!a;return this};g.duplicateQueryParameters=function(a){this._parts.duplicateQueryParameters=!!a;return this};g.escapeQuerySpace=function(a){this._parts.escapeQuerySpace=!!a;return this};return d});

View File

@@ -14,10 +14,53 @@ $("textarea.markdown").each(function() {
let timeout_id = null;
new EasyMDE({
function urlInserter(url) {
return (editor) => {
var cm = editor.codemirror;
var stat = getState(cm);
var options = editor.options;
_replaceSelection(cm, stat.table, `[](${url})`);
};
}
this.easy_mde = new EasyMDE({
element: this,
hideIcons: ["image"],
showIcons: ["code", "table"],
forceSync: true,
toolbar: [
"bold",
"italic",
"heading",
"|",
"code",
"quote",
"unordered-list",
"ordered-list",
"|",
"link",
"table",
"|",
"preview",
"side-by-side",
"fullscreen",
"|",
"guide",
// {
// name: "rules",
// className: "fa fa-book",
// title: "others buttons",
// children: [
// {
// name: "rules",
// action: urlInserter("/policy_and_guidance/#2-accepted-content"),
// className: "fa fa-star",
// title: "2. Accepted content",
// text: "2. Accepted content",
// },
// ]
// },
],
previewRender: (plainText, preview) => {
if (timeout_id) {
clearTimeout(timeout_id);

View File

@@ -25,6 +25,14 @@ $(function() {
const ele = $(id);
ele.val(value);
ele.trigger("change");
// EasyMDE doesn't always refresh the codemirror correctly
if (ele[0].easy_mde) {
setTimeout(() => {
ele[0].easy_mde.value(value);
ele[0].easy_mde.codemirror.refresh()
}, 100);
}
}
}
@@ -33,12 +41,9 @@ $(function() {
setField("#title", result.title);
setField("#repo", result.repo || repoURL);
setField("#issueTracker", result.issueTracker);
setField("#desc", result.description);
setField("#short_desc", result.short_description);
// setField("#harddep_str", result.depends);
// setField("#softdep_str", result.optional_depends);
setField("#short_desc", result.short_description);
setField("#forums", result.forumId);
setField("#desc", result.desc);
setField("#short_desc", result.short_desc);
setField("#forums", result.forums);
if (result.type && result.type.length > 2) {
$("#type").val(result.type);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,10 +1,18 @@
/*!
* Tag Selector plugin for jQuery: Facilitates selecting multiple tags by extending jQuery UI Autocomplete.
* You may use Tag Selector under the terms of either the MIT License or the GNU General Public License (GPL) Version 2.
* License: MIT
* https://petprojects.googlecode.com/svn/trunk/MIT-LICENSE.txt
* https://petprojects.googlecode.com/svn/trunk/GPL-LICENSE.txt
*/
(function($) {
function make_bold(text) {
const idx = text.indexOf(":");
if (idx > 0) {
return `<b>${text.substring(0, idx)}</b><span class="text-muted">: ${text.substring(idx + 1)}`;
} else {
return `<b>${text}</b>`;
}
}
function hide_error(input) {
const err = input.parent().parent().find(".invalid-remaining");
err.hide();
@@ -12,7 +20,6 @@
function show_error(input, msg) {
const err = input.parent().parent().find(".invalid-remaining");
console.log(err.length);
err.text(msg);
err.show();
}
@@ -22,14 +29,22 @@
const selector = $(this),
input = $('input[type=text]', this);
selector.click(function() { input.focus(); })
const lookup = {};
for (let i = 0; i < source.length; i++) {
lookup[source[i].id] = source[i];
}
selector.click(() => input.focus())
.delegate('.badge a', 'click', function() {
const id = $(this).parent().data("id");
select.find("option[value=" + id + "]").attr("selected", false)
recreate();
});
function addTag(id, text) {
function addTag(item) {
const id = item.id;
let text = item.text;
const idx = text.indexOf(':');
if (idx > 0) {
text = text.substr(0, idx);
@@ -49,7 +64,7 @@
selector.find("span").remove();
select.find("option").each(function() {
if (this.hasAttribute("selected")) {
addTag(this.getAttribute("value"), this.innerText);
addTag(lookup[this.getAttribute("value")]);
}
});
}
@@ -57,185 +72,44 @@
input.focusout(function() {
const value = input.val().trim();
if (value != "") {
show_error(input, "Please select an existing tag, it;s not possible to add custom ones.");
if (value !== "") {
show_error(input, "Please select an existing tag, it's not possible to add custom ones.");
}
})
input.keydown(function(e) {
if (e.keyCode === $.ui.keyCode.TAB && $(this).data('ui-autocomplete').menu.active)
e.preventDefault();
})
.autocomplete({
minLength: 0,
source: source,
select: function(event, ui) {
addTag(ui.item.id, ui.item.toString());
input.val("");
return false;
}
}).focus(function() {
// The following works only once.
// $(this).trigger('keydown.autocomplete');
// As suggested by digitalPBK, works multiple times
// $(this).data("autocomplete").search($(this).val());
// As noted by Jonny in his answer, with newer versions use uiAutocomplete
$(this).data("ui-autocomplete").search($(this).val());
});
if (e.keyCode === $.ui.keyCode.TAB && $(this).data('ui-autocomplete').menu.active) {
e.preventDefault();
}
}).autocomplete({
minLength: 0,
source: source,
select: function(event, ui) {
addTag(ui.item);
input.val("");
return false;
}
}).focus(function() {
$(this).data("ui-autocomplete").search($(this).val());
});
input.data('ui-autocomplete')._renderItem = function(ul, item) {
return $('<li/>')
.data('item.autocomplete', item)
.append($('<a/>').text(item.toString()))
.appendTo(ul);
};
return $('<li/>')
.data('item.autocomplete', item)
.append($('<a/>').html(item.toString()))
.appendTo(ul);
};
input.data('ui-autocomplete')._resizeMenu = function(ul, item) {
var ul = this.menu.element;
ul.outerWidth(Math.max(
ul.width('').outerWidth(),
selector.outerWidth()
));
};
input.data('ui-autocomplete')._resizeMenu = function() {
const ul = this.menu.element;
ul.outerWidth(Math.max(
ul.width('').outerWidth(),
selector.outerWidth()
));
};
});
}
$.fn.csvSelector = function(source, name, result, allowSlash) {
return this.each(function() {
const selector = $(this),
input = $('input[type=text]', this);
let selected = [];
const lookup = {};
for (var i = 0; i < source.length; i++) {
lookup[source[i].id] = source[i];
}
selector.click(function() { input.focus(); })
.delegate('.badge a', 'click', function() {
const id = $(this).parent().data("id");
for (let i = 0; i < selected.length; i++) {
if (selected[i] == id) {
selected.splice(i, 1);
}
}
recreate();
});
function selectItem(id) {
for (let i = 0; i < selected.length; i++) {
if (selected[i] == id) {
return false;
}
}
selected.push(id);
return true;
}
function addTag(id, value) {
const tag = $('<span class="badge badge-pill badge-primary"/>')
.text(value)
.data("id", id)
.append(' <a>x</a>')
.insertBefore(input);
input.attr("placeholder", null);
hide_error(input);
}
function recreate() {
selector.find("span").remove();
for (let i = 0; i < selected.length; i++) {
const value = lookup[selected[i]] || {value: selected[i]};
addTag(selected[i], value.value);
}
result.val(selected.join(","))
}
function readFromResult() {
selected = [];
const selected_raw = result.val().split(",");
for (let i = 0; i < selected_raw.length; i++) {
const raw = selected_raw[i].trim();
if (lookup[raw] || raw.match(/^([a-z0-9_]+)$/)) {
selected.push(raw);
}
}
recreate();
}
readFromResult();
result.change(readFromResult);
input.focusout(function() {
const item = input.val();
if (item.length == 0) {
input.data("ui-autocomplete").search("");
} else if (item.match(/^([a-z0-9_]+)$/)) {
selectItem(item);
recreate();
input.val("");
}
return true;
});
input.keydown(function(e) {
if (e.keyCode === $.ui.keyCode.TAB && $(this).data('ui-autocomplete').menu.active)
e.preventDefault();
else if (e.keyCode === $.ui.keyCode.COMMA) {
var item = input.val();
if (item.length == 0) {
input.data("ui-autocomplete").search("");
} else if (item.match(/^([a-z0-9_]+)$/)) {
selectItem(item);
recreate();
input.val("");
} else {
show_error(input, "Only lowercase alphanumeric and number names allowed.");
}
e.preventDefault();
return true;
} else if (e.keyCode === $.ui.keyCode.BACKSPACE) {
if (input.val() == "") {
var item = selected[selected.length - 1];
selected.splice(selected.length - 1, 1);
recreate();
if (!(item.indexOf("/") > 0))
input.val(item);
e.preventDefault();
return true;
}
}
})
.autocomplete({
minLength: 0,
source: source,
select: function(event, ui) {
selectItem(ui.item.id);
recreate();
input.val("");
return false;
}
});
input.data('ui-autocomplete')._renderItem = function(ul, item) {
return $('<li/>')
.data('item.autocomplete', item)
.append($('<a/>').text(item.toString()))
.appendTo(ul);
};
input.data('ui-autocomplete')._resizeMenu = function(ul, item) {
var ul = this.menu.element;
ul.outerWidth(Math.max(
ul.width('').outerWidth(),
selector.outerWidth()
));
};
});
}
$(function() {
$(".multichoice_selector").each(function() {
const ele = $(this);
@@ -245,27 +119,25 @@
const options = [];
sel.find("option").each(function() {
const text = $(this).text();
options.push({
const option = {
id: $(this).attr("value"),
value: text,
text: text,
selected: !!$(this).attr("selected"),
toString: function() { return text; },
});
toString: function() { return make_bold(text); },
};
const idx = text.indexOf(":");
if (idx > 0) {
option.title = text.substring(0, idx);
option.description = text.substring(idx + 1);
} else {
option.title = text
}
options.push(option);
});
ele.selectSelector(options, sel);
});
$(".metapackage_selector").each(function() {
const input = $(this).parent().children("input[type='text']");
input.hide();
$(this).csvSelector(meta_packages, input.attr("name"), input);
});
$(".deps_selector").each(function() {
const input = $(this).parent().children("input[type='text']");
input.hide();
$(this).csvSelector(all_packages, input.attr("name"), input);
});
});
})(jQuery);

View File

@@ -1,118 +0,0 @@
// @license MIT
/*! URI.js v1.19.1 http://medialize.github.io/URI.js/ */
/* build contains: IPv6.js, punycode.js, SecondLevelDomains.js, URI.js */
/*
URI.js - Mutating URLs
IPv6 Support
Version: 1.19.1
Author: Rodney Rehm
Web: http://medialize.github.io/URI.js/
Licensed under
MIT License http://www.opensource.org/licenses/mit-license
https://mths.be/punycode v1.4.0 by @mathias URI.js - Mutating URLs
Second Level Domain (SLD) Support
Version: 1.19.1
Author: Rodney Rehm
Web: http://medialize.github.io/URI.js/
Licensed under
MIT License http://www.opensource.org/licenses/mit-license
URI.js - Mutating URLs
Version: 1.19.1
Author: Rodney Rehm
Web: http://medialize.github.io/URI.js/
Licensed under
MIT License http://www.opensource.org/licenses/mit-license
*/
(function(k,n){"object"===typeof module&&module.exports?module.exports=n():"function"===typeof define&&define.amd?define(n):k.IPv6=n(k)})(this,function(k){var n=k&&k.IPv6;return{best:function(l){l=l.toLowerCase().split(":");var h=l.length,c=8;""===l[0]&&""===l[1]&&""===l[2]?(l.shift(),l.shift()):""===l[0]&&""===l[1]?l.shift():""===l[h-1]&&""===l[h-2]&&l.pop();h=l.length;-1!==l[h-1].indexOf(".")&&(c=7);var m;for(m=0;m<h&&""!==l[m];m++);if(m<c)for(l.splice(m,1,"0000");l.length<c;)l.splice(m,0,"0000");
for(m=0;m<c;m++){h=l[m].split("");for(var k=0;3>k;k++)if("0"===h[0]&&1<h.length)h.splice(0,1);else break;l[m]=h.join("")}h=-1;var p=k=0,n=-1,u=!1;for(m=0;m<c;m++)u?"0"===l[m]?p+=1:(u=!1,p>k&&(h=n,k=p)):"0"===l[m]&&(u=!0,n=m,p=1);p>k&&(h=n,k=p);1<k&&l.splice(h,k,"");h=l.length;c="";""===l[0]&&(c=":");for(m=0;m<h;m++){c+=l[m];if(m===h-1)break;c+=":"}""===l[h-1]&&(c+=":");return c},noConflict:function(){k.IPv6===this&&(k.IPv6=n);return this}}});
(function(k){function n(c){throw new RangeError(H[c]);}function l(c,g){for(var h=c.length,m=[];h--;)m[h]=g(c[h]);return m}function h(c,g){var h=c.split("@"),m="";1<h.length&&(m=h[0]+"@",c=h[1]);c=c.replace(F,".");h=c.split(".");h=l(h,g).join(".");return m+h}function c(c){for(var g=[],h=0,m=c.length,l,a;h<m;)l=c.charCodeAt(h++),55296<=l&&56319>=l&&h<m?(a=c.charCodeAt(h++),56320==(a&64512)?g.push(((l&1023)<<10)+(a&1023)+65536):(g.push(l),h--)):g.push(l);return g}function m(c){return l(c,function(c){var g=
"";65535<c&&(c-=65536,g+=q(c>>>10&1023|55296),c=56320|c&1023);return g+=q(c)}).join("")}function w(c,g){return c+22+75*(26>c)-((0!=g)<<5)}function p(c,h,m){var l=0;c=m?g(c/700):c>>1;for(c+=g(c/h);455<c;l+=36)c=g(c/35);return g(l+36*c/(c+38))}function D(c){var h=[],l=c.length,k=0,q=128,a=72,b,d;var e=c.lastIndexOf("-");0>e&&(e=0);for(b=0;b<e;++b)128<=c.charCodeAt(b)&&n("not-basic"),h.push(c.charCodeAt(b));for(e=0<e?e+1:0;e<l;){b=k;var f=1;for(d=36;;d+=36){e>=l&&n("invalid-input");var r=c.charCodeAt(e++);
r=10>r-48?r-22:26>r-65?r-65:26>r-97?r-97:36;(36<=r||r>g((2147483647-k)/f))&&n("overflow");k+=r*f;var A=d<=a?1:d>=a+26?26:d-a;if(r<A)break;r=36-A;f>g(2147483647/r)&&n("overflow");f*=r}f=h.length+1;a=p(k-b,f,0==b);g(k/f)>2147483647-q&&n("overflow");q+=g(k/f);k%=f;h.splice(k++,0,q)}return m(h)}function u(h){var l,m,k,t=[];h=c(h);var a=h.length;var b=128;var d=0;var e=72;for(k=0;k<a;++k){var f=h[k];128>f&&t.push(q(f))}for((l=m=t.length)&&t.push("-");l<a;){var r=2147483647;for(k=0;k<a;++k)f=h[k],f>=b&&
f<r&&(r=f);var A=l+1;r-b>g((2147483647-d)/A)&&n("overflow");d+=(r-b)*A;b=r;for(k=0;k<a;++k)if(f=h[k],f<b&&2147483647<++d&&n("overflow"),f==b){var y=d;for(r=36;;r+=36){f=r<=e?1:r>=e+26?26:r-e;if(y<f)break;var I=y-f;y=36-f;t.push(q(w(f+I%y,0)));y=g(I/y)}t.push(q(w(y,0)));e=p(d,A,l==m);d=0;++l}++d;++b}return t.join("")}var B="object"==typeof exports&&exports&&!exports.nodeType&&exports,C="object"==typeof module&&module&&!module.nodeType&&module,x="object"==typeof global&&global;if(x.global===x||x.window===
x||x.self===x)k=x;var E=/^xn--/,z=/[^\x20-\x7E]/,F=/[\x2E\u3002\uFF0E\uFF61]/g,H={overflow:"Overflow: input needs wider integers to process","not-basic":"Illegal input >= 0x80 (not a basic code point)","invalid-input":"Invalid input"},g=Math.floor,q=String.fromCharCode,t;var v={version:"1.3.2",ucs2:{decode:c,encode:m},decode:D,encode:u,toASCII:function(c){return h(c,function(c){return z.test(c)?"xn--"+u(c):c})},toUnicode:function(c){return h(c,function(c){return E.test(c)?D(c.slice(4).toLowerCase()):
c})}};if("function"==typeof define&&"object"==typeof define.amd&&define.amd)define("punycode",function(){return v});else if(B&&C)if(module.exports==B)C.exports=v;else for(t in v)v.hasOwnProperty(t)&&(B[t]=v[t]);else k.punycode=v})(this);
(function(k,n){"object"===typeof module&&module.exports?module.exports=n():"function"===typeof define&&define.amd?define(n):k.SecondLevelDomains=n(k)})(this,function(k){var n=k&&k.SecondLevelDomains,l={list:{ac:" com gov mil net org ",ae:" ac co gov mil name net org pro sch ",af:" com edu gov net org ",al:" com edu gov mil net org ",ao:" co ed gv it og pb ",ar:" com edu gob gov int mil net org tur ",at:" ac co gv or ",au:" asn com csiro edu gov id net org ",ba:" co com edu gov mil net org rs unbi unmo unsa untz unze ",
bb:" biz co com edu gov info net org store tv ",bh:" biz cc com edu gov info net org ",bn:" com edu gov net org ",bo:" com edu gob gov int mil net org tv ",br:" adm adv agr am arq art ato b bio blog bmd cim cng cnt com coop ecn edu eng esp etc eti far flog fm fnd fot fst g12 ggf gov imb ind inf jor jus lel mat med mil mus net nom not ntr odo org ppg pro psc psi qsl rec slg srv tmp trd tur tv vet vlog wiki zlg ",bs:" com edu gov net org ",bz:" du et om ov rg ",ca:" ab bc mb nb nf nl ns nt nu on pe qc sk yk ",
ck:" biz co edu gen gov info net org ",cn:" ac ah bj com cq edu fj gd gov gs gx gz ha hb he hi hl hn jl js jx ln mil net nm nx org qh sc sd sh sn sx tj tw xj xz yn zj ",co:" com edu gov mil net nom org ",cr:" ac c co ed fi go or sa ",cy:" ac biz com ekloges gov ltd name net org parliament press pro tm ","do":" art com edu gob gov mil net org sld web ",dz:" art asso com edu gov net org pol ",ec:" com edu fin gov info med mil net org pro ",eg:" com edu eun gov mil name net org sci ",er:" com edu gov ind mil net org rochest w ",
es:" com edu gob nom org ",et:" biz com edu gov info name net org ",fj:" ac biz com info mil name net org pro ",fk:" ac co gov net nom org ",fr:" asso com f gouv nom prd presse tm ",gg:" co net org ",gh:" com edu gov mil org ",gn:" ac com gov net org ",gr:" com edu gov mil net org ",gt:" com edu gob ind mil net org ",gu:" com edu gov net org ",hk:" com edu gov idv net org ",hu:" 2000 agrar bolt casino city co erotica erotika film forum games hotel info ingatlan jogasz konyvelo lakas media news org priv reklam sex shop sport suli szex tm tozsde utazas video ",
id:" ac co go mil net or sch web ",il:" ac co gov idf k12 muni net org ","in":" ac co edu ernet firm gen gov i ind mil net nic org res ",iq:" com edu gov i mil net org ",ir:" ac co dnssec gov i id net org sch ",it:" edu gov ",je:" co net org ",jo:" com edu gov mil name net org sch ",jp:" ac ad co ed go gr lg ne or ",ke:" ac co go info me mobi ne or sc ",kh:" com edu gov mil net org per ",ki:" biz com de edu gov info mob net org tel ",km:" asso com coop edu gouv k medecin mil nom notaires pharmaciens presse tm veterinaire ",
kn:" edu gov net org ",kr:" ac busan chungbuk chungnam co daegu daejeon es gangwon go gwangju gyeongbuk gyeonggi gyeongnam hs incheon jeju jeonbuk jeonnam k kg mil ms ne or pe re sc seoul ulsan ",kw:" com edu gov net org ",ky:" com edu gov net org ",kz:" com edu gov mil net org ",lb:" com edu gov net org ",lk:" assn com edu gov grp hotel int ltd net ngo org sch soc web ",lr:" com edu gov net org ",lv:" asn com conf edu gov id mil net org ",ly:" com edu gov id med net org plc sch ",ma:" ac co gov m net org press ",
mc:" asso tm ",me:" ac co edu gov its net org priv ",mg:" com edu gov mil nom org prd tm ",mk:" com edu gov inf name net org pro ",ml:" com edu gov net org presse ",mn:" edu gov org ",mo:" com edu gov net org ",mt:" com edu gov net org ",mv:" aero biz com coop edu gov info int mil museum name net org pro ",mw:" ac co com coop edu gov int museum net org ",mx:" com edu gob net org ",my:" com edu gov mil name net org sch ",nf:" arts com firm info net other per rec store web ",ng:" biz com edu gov mil mobi name net org sch ",
ni:" ac co com edu gob mil net nom org ",np:" com edu gov mil net org ",nr:" biz com edu gov info net org ",om:" ac biz co com edu gov med mil museum net org pro sch ",pe:" com edu gob mil net nom org sld ",ph:" com edu gov i mil net ngo org ",pk:" biz com edu fam gob gok gon gop gos gov net org web ",pl:" art bialystok biz com edu gda gdansk gorzow gov info katowice krakow lodz lublin mil net ngo olsztyn org poznan pwr radom slupsk szczecin torun warszawa waw wroc wroclaw zgora ",pr:" ac biz com edu est gov info isla name net org pro prof ",
ps:" com edu gov net org plo sec ",pw:" belau co ed go ne or ",ro:" arts com firm info nom nt org rec store tm www ",rs:" ac co edu gov in org ",sb:" com edu gov net org ",sc:" com edu gov net org ",sh:" co com edu gov net nom org ",sl:" com edu gov net org ",st:" co com consulado edu embaixada gov mil net org principe saotome store ",sv:" com edu gob org red ",sz:" ac co org ",tr:" av bbs bel biz com dr edu gen gov info k12 name net org pol tel tsk tv web ",tt:" aero biz cat co com coop edu gov info int jobs mil mobi museum name net org pro tel travel ",
tw:" club com ebiz edu game gov idv mil net org ",mu:" ac co com gov net or org ",mz:" ac co edu gov org ",na:" co com ",nz:" ac co cri geek gen govt health iwi maori mil net org parliament school ",pa:" abo ac com edu gob ing med net nom org sld ",pt:" com edu gov int net nome org publ ",py:" com edu gov mil net org ",qa:" com edu gov mil net org ",re:" asso com nom ",ru:" ac adygeya altai amur arkhangelsk astrakhan bashkiria belgorod bir bryansk buryatia cbg chel chelyabinsk chita chukotka chuvashia com dagestan e-burg edu gov grozny int irkutsk ivanovo izhevsk jar joshkar-ola kalmykia kaluga kamchatka karelia kazan kchr kemerovo khabarovsk khakassia khv kirov koenig komi kostroma kranoyarsk kuban kurgan kursk lipetsk magadan mari mari-el marine mil mordovia mosreg msk murmansk nalchik net nnov nov novosibirsk nsk omsk orenburg org oryol penza perm pp pskov ptz rnd ryazan sakhalin samara saratov simbirsk smolensk spb stavropol stv surgut tambov tatarstan tom tomsk tsaritsyn tsk tula tuva tver tyumen udm udmurtia ulan-ude vladikavkaz vladimir vladivostok volgograd vologda voronezh vrn vyatka yakutia yamal yekaterinburg yuzhno-sakhalinsk ",
rw:" ac co com edu gouv gov int mil net ",sa:" com edu gov med net org pub sch ",sd:" com edu gov info med net org tv ",se:" a ac b bd c d e f g h i k l m n o org p parti pp press r s t tm u w x y z ",sg:" com edu gov idn net org per ",sn:" art com edu gouv org perso univ ",sy:" com edu gov mil net news org ",th:" ac co go in mi net or ",tj:" ac biz co com edu go gov info int mil name net nic org test web ",tn:" agrinet com defense edunet ens fin gov ind info intl mincom nat net org perso rnrt rns rnu tourism ",
tz:" ac co go ne or ",ua:" biz cherkassy chernigov chernovtsy ck cn co com crimea cv dn dnepropetrovsk donetsk dp edu gov if in ivano-frankivsk kh kharkov kherson khmelnitskiy kiev kirovograd km kr ks kv lg lugansk lutsk lviv me mk net nikolaev od odessa org pl poltava pp rovno rv sebastopol sumy te ternopil uzhgorod vinnica vn zaporizhzhe zhitomir zp zt ",ug:" ac co go ne or org sc ",uk:" ac bl british-library co cym gov govt icnet jet lea ltd me mil mod national-library-scotland nel net nhs nic nls org orgn parliament plc police sch scot soc ",
us:" dni fed isa kids nsn ",uy:" com edu gub mil net org ",ve:" co com edu gob info mil net org web ",vi:" co com k12 net org ",vn:" ac biz com edu gov health info int name net org pro ",ye:" co com gov ltd me net org plc ",yu:" ac co edu gov org ",za:" ac agric alt bourse city co cybernet db edu gov grondar iaccess imt inca landesign law mil net ngo nis nom olivetti org pix school tm web ",zm:" ac co com edu gov net org sch ",com:"ar br cn de eu gb gr hu jpn kr no qc ru sa se uk us uy za ",net:"gb jp se uk ",
org:"ae",de:"com "},has:function(h){var c=h.lastIndexOf(".");if(0>=c||c>=h.length-1)return!1;var k=h.lastIndexOf(".",c-1);if(0>=k||k>=c-1)return!1;var n=l.list[h.slice(c+1)];return n?0<=n.indexOf(" "+h.slice(k+1,c)+" "):!1},is:function(h){var c=h.lastIndexOf(".");if(0>=c||c>=h.length-1||0<=h.lastIndexOf(".",c-1))return!1;var k=l.list[h.slice(c+1)];return k?0<=k.indexOf(" "+h.slice(0,c)+" "):!1},get:function(h){var c=h.lastIndexOf(".");if(0>=c||c>=h.length-1)return null;var k=h.lastIndexOf(".",c-1);
if(0>=k||k>=c-1)return null;var n=l.list[h.slice(c+1)];return!n||0>n.indexOf(" "+h.slice(k+1,c)+" ")?null:h.slice(k+1)},noConflict:function(){k.SecondLevelDomains===this&&(k.SecondLevelDomains=n);return this}};return l});
(function(k,n){"object"===typeof module&&module.exports?module.exports=n(require("./punycode"),require("./IPv6"),require("./SecondLevelDomains")):"function"===typeof define&&define.amd?define(["./punycode","./IPv6","./SecondLevelDomains"],n):k.URI=n(k.punycode,k.IPv6,k.SecondLevelDomains,k)})(this,function(k,n,l,h){function c(a,b){var d=1<=arguments.length,e=2<=arguments.length;if(!(this instanceof c))return d?e?new c(a,b):new c(a):new c;if(void 0===a){if(d)throw new TypeError("undefined is not a valid argument for URI");
a="undefined"!==typeof location?location.href+"":""}if(null===a&&d)throw new TypeError("null is not a valid argument for URI");this.href(a);return void 0!==b?this.absoluteTo(b):this}function m(a){return a.replace(/([.*+?^=!:${}()|[\]\/\\])/g,"\\$1")}function w(a){return void 0===a?"Undefined":String(Object.prototype.toString.call(a)).slice(8,-1)}function p(a){return"Array"===w(a)}function D(a,b){var d={},c;if("RegExp"===w(b))d=null;else if(p(b)){var f=0;for(c=b.length;f<c;f++)d[b[f]]=!0}else d[b]=
!0;f=0;for(c=a.length;f<c;f++)if(d&&void 0!==d[a[f]]||!d&&b.test(a[f]))a.splice(f,1),c--,f--;return a}function u(a,b){var d;if(p(b)){var c=0;for(d=b.length;c<d;c++)if(!u(a,b[c]))return!1;return!0}var f=w(b);c=0;for(d=a.length;c<d;c++)if("RegExp"===f){if("string"===typeof a[c]&&a[c].match(b))return!0}else if(a[c]===b)return!0;return!1}function B(a,b){if(!p(a)||!p(b)||a.length!==b.length)return!1;a.sort();b.sort();for(var d=0,c=a.length;d<c;d++)if(a[d]!==b[d])return!1;return!0}function C(a){return a.replace(/^\/+|\/+$/g,
"")}function x(a){return escape(a)}function E(a){return encodeURIComponent(a).replace(/[!'()*]/g,x).replace(/\*/g,"%2A")}function z(a){return function(b,d){if(void 0===b)return this._parts[a]||"";this._parts[a]=b||null;this.build(!d);return this}}function F(a,b){return function(d,c){if(void 0===d)return this._parts[a]||"";null!==d&&(d+="",d.charAt(0)===b&&(d=d.substring(1)));this._parts[a]=d;this.build(!c);return this}}var H=h&&h.URI;c.version="1.19.1";var g=c.prototype,q=Object.prototype.hasOwnProperty;
c._parts=function(){return{protocol:null,username:null,password:null,hostname:null,urn:null,port:null,path:null,query:null,fragment:null,preventInvalidHostname:c.preventInvalidHostname,duplicateQueryParameters:c.duplicateQueryParameters,escapeQuerySpace:c.escapeQuerySpace}};c.preventInvalidHostname=!1;c.duplicateQueryParameters=!1;c.escapeQuerySpace=!0;c.protocol_expression=/^[a-z][a-z0-9.+-]*$/i;c.idn_expression=/[^a-z0-9\._-]/i;c.punycode_expression=/(xn--)/i;c.ip4_expression=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
c.ip6_expression=/^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/;
c.find_uri_expression=/\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u2018\u2019]))/ig;c.findUri={start:/\b(?:([a-z][a-z0-9.+-]*:\/\/)|www\.)/gi,end:/[\s\r\n]|$/,trim:/[`!()\[\]{};:'".,<>?\u00ab\u00bb\u201c\u201d\u201e\u2018\u2019]+$/,parens:/(\([^\)]*\)|\[[^\]]*\]|\{[^}]*\}|<[^>]*>)/g};c.defaultPorts={http:"80",https:"443",ftp:"21",
gopher:"70",ws:"80",wss:"443"};c.hostProtocols=["http","https"];c.invalid_hostname_characters=/[^a-zA-Z0-9\.\-:_]/;c.domAttributes={a:"href",blockquote:"cite",link:"href",base:"href",script:"src",form:"action",img:"src",area:"href",iframe:"src",embed:"src",source:"src",track:"src",input:"src",audio:"src",video:"src"};c.getDomAttribute=function(a){if(a&&a.nodeName){var b=a.nodeName.toLowerCase();if("input"!==b||"image"===a.type)return c.domAttributes[b]}};c.encode=E;c.decode=decodeURIComponent;c.iso8859=
function(){c.encode=escape;c.decode=unescape};c.unicode=function(){c.encode=E;c.decode=decodeURIComponent};c.characters={pathname:{encode:{expression:/%(24|26|2B|2C|3B|3D|3A|40)/ig,map:{"%24":"$","%26":"&","%2B":"+","%2C":",","%3B":";","%3D":"=","%3A":":","%40":"@"}},decode:{expression:/[\/\?#]/g,map:{"/":"%2F","?":"%3F","#":"%23"}}},reserved:{encode:{expression:/%(21|23|24|26|27|28|29|2A|2B|2C|2F|3A|3B|3D|3F|40|5B|5D)/ig,map:{"%3A":":","%2F":"/","%3F":"?","%23":"#","%5B":"[","%5D":"]","%40":"@",
"%21":"!","%24":"$","%26":"&","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",","%3B":";","%3D":"="}}},urnpath:{encode:{expression:/%(21|24|27|28|29|2A|2B|2C|3B|3D|40)/ig,map:{"%21":"!","%24":"$","%27":"'","%28":"(","%29":")","%2A":"*","%2B":"+","%2C":",","%3B":";","%3D":"=","%40":"@"}},decode:{expression:/[\/\?#:]/g,map:{"/":"%2F","?":"%3F","#":"%23",":":"%3A"}}}};c.encodeQuery=function(a,b){var d=c.encode(a+"");void 0===b&&(b=c.escapeQuerySpace);return b?d.replace(/%20/g,"+"):d};c.decodeQuery=
function(a,b){a+="";void 0===b&&(b=c.escapeQuerySpace);try{return c.decode(b?a.replace(/\+/g,"%20"):a)}catch(d){return a}};var t={encode:"encode",decode:"decode"},v,G=function(a,b){return function(d){try{return c[b](d+"").replace(c.characters[a][b].expression,function(d){return c.characters[a][b].map[d]})}catch(e){return d}}};for(v in t)c[v+"PathSegment"]=G("pathname",t[v]),c[v+"UrnPathSegment"]=G("urnpath",t[v]);t=function(a,b,d){return function(e){var f=d?function(a){return c[b](c[d](a))}:c[b];
e=(e+"").split(a);for(var g=0,h=e.length;g<h;g++)e[g]=f(e[g]);return e.join(a)}};c.decodePath=t("/","decodePathSegment");c.decodeUrnPath=t(":","decodeUrnPathSegment");c.recodePath=t("/","encodePathSegment","decode");c.recodeUrnPath=t(":","encodeUrnPathSegment","decode");c.encodeReserved=G("reserved","encode");c.parse=function(a,b){b||(b={preventInvalidHostname:c.preventInvalidHostname});var d=a.indexOf("#");-1<d&&(b.fragment=a.substring(d+1)||null,a=a.substring(0,d));d=a.indexOf("?");-1<d&&(b.query=
a.substring(d+1)||null,a=a.substring(0,d));"//"===a.substring(0,2)?(b.protocol=null,a=a.substring(2),a=c.parseAuthority(a,b)):(d=a.indexOf(":"),-1<d&&(b.protocol=a.substring(0,d)||null,b.protocol&&!b.protocol.match(c.protocol_expression)?b.protocol=void 0:"//"===a.substring(d+1,d+3)?(a=a.substring(d+3),a=c.parseAuthority(a,b)):(a=a.substring(d+1),b.urn=!0)));b.path=a;return b};c.parseHost=function(a,b){a||(a="");a=a.replace(/\\/g,"/");var d=a.indexOf("/");-1===d&&(d=a.length);if("["===a.charAt(0)){var e=
a.indexOf("]");b.hostname=a.substring(1,e)||null;b.port=a.substring(e+2,d)||null;"/"===b.port&&(b.port=null)}else{var f=a.indexOf(":");e=a.indexOf("/");f=a.indexOf(":",f+1);-1!==f&&(-1===e||f<e)?(b.hostname=a.substring(0,d)||null,b.port=null):(e=a.substring(0,d).split(":"),b.hostname=e[0]||null,b.port=e[1]||null)}b.hostname&&"/"!==a.substring(d).charAt(0)&&(d++,a="/"+a);b.preventInvalidHostname&&c.ensureValidHostname(b.hostname,b.protocol);b.port&&c.ensureValidPort(b.port);return a.substring(d)||
"/"};c.parseAuthority=function(a,b){a=c.parseUserinfo(a,b);return c.parseHost(a,b)};c.parseUserinfo=function(a,b){var d=a.indexOf("/"),e=a.lastIndexOf("@",-1<d?d:a.length-1);-1<e&&(-1===d||e<d)?(d=a.substring(0,e).split(":"),b.username=d[0]?c.decode(d[0]):null,d.shift(),b.password=d[0]?c.decode(d.join(":")):null,a=a.substring(e+1)):(b.username=null,b.password=null);return a};c.parseQuery=function(a,b){if(!a)return{};a=a.replace(/&+/g,"&").replace(/^\?*&*|&+$/g,"");if(!a)return{};for(var d={},e=a.split("&"),
f=e.length,g,h,k=0;k<f;k++)if(g=e[k].split("="),h=c.decodeQuery(g.shift(),b),g=g.length?c.decodeQuery(g.join("="),b):null,q.call(d,h)){if("string"===typeof d[h]||null===d[h])d[h]=[d[h]];d[h].push(g)}else d[h]=g;return d};c.build=function(a){var b="";a.protocol&&(b+=a.protocol+":");a.urn||!b&&!a.hostname||(b+="//");b+=c.buildAuthority(a)||"";"string"===typeof a.path&&("/"!==a.path.charAt(0)&&"string"===typeof a.hostname&&(b+="/"),b+=a.path);"string"===typeof a.query&&a.query&&(b+="?"+a.query);"string"===
typeof a.fragment&&a.fragment&&(b+="#"+a.fragment);return b};c.buildHost=function(a){var b="";if(a.hostname)b=c.ip6_expression.test(a.hostname)?b+("["+a.hostname+"]"):b+a.hostname;else return"";a.port&&(b+=":"+a.port);return b};c.buildAuthority=function(a){return c.buildUserinfo(a)+c.buildHost(a)};c.buildUserinfo=function(a){var b="";a.username&&(b+=c.encode(a.username));a.password&&(b+=":"+c.encode(a.password));b&&(b+="@");return b};c.buildQuery=function(a,b,d){var e="",f,g;for(f in a)if(q.call(a,
f)&&f)if(p(a[f])){var h={};var k=0;for(g=a[f].length;k<g;k++)void 0!==a[f][k]&&void 0===h[a[f][k]+""]&&(e+="&"+c.buildQueryParameter(f,a[f][k],d),!0!==b&&(h[a[f][k]+""]=!0))}else void 0!==a[f]&&(e+="&"+c.buildQueryParameter(f,a[f],d));return e.substring(1)};c.buildQueryParameter=function(a,b,d){return c.encodeQuery(a,d)+(null!==b?"="+c.encodeQuery(b,d):"")};c.addQuery=function(a,b,d){if("object"===typeof b)for(var e in b)q.call(b,e)&&c.addQuery(a,e,b[e]);else if("string"===typeof b)void 0===a[b]?
a[b]=d:("string"===typeof a[b]&&(a[b]=[a[b]]),p(d)||(d=[d]),a[b]=(a[b]||[]).concat(d));else throw new TypeError("URI.addQuery() accepts an object, string as the name parameter");};c.setQuery=function(a,b,d){if("object"===typeof b)for(var e in b)q.call(b,e)&&c.setQuery(a,e,b[e]);else if("string"===typeof b)a[b]=void 0===d?null:d;else throw new TypeError("URI.setQuery() accepts an object, string as the name parameter");};c.removeQuery=function(a,b,d){var e;if(p(b))for(d=0,e=b.length;d<e;d++)a[b[d]]=
void 0;else if("RegExp"===w(b))for(e in a)b.test(e)&&(a[e]=void 0);else if("object"===typeof b)for(e in b)q.call(b,e)&&c.removeQuery(a,e,b[e]);else if("string"===typeof b)void 0!==d?"RegExp"===w(d)?!p(a[b])&&d.test(a[b])?a[b]=void 0:a[b]=D(a[b],d):a[b]!==String(d)||p(d)&&1!==d.length?p(a[b])&&(a[b]=D(a[b],d)):a[b]=void 0:a[b]=void 0;else throw new TypeError("URI.removeQuery() accepts an object, string, RegExp as the first parameter");};c.hasQuery=function(a,b,d,e){switch(w(b)){case "String":break;
case "RegExp":for(var f in a)if(q.call(a,f)&&b.test(f)&&(void 0===d||c.hasQuery(a,f,d)))return!0;return!1;case "Object":for(var g in b)if(q.call(b,g)&&!c.hasQuery(a,g,b[g]))return!1;return!0;default:throw new TypeError("URI.hasQuery() accepts a string, regular expression or object as the name parameter");}switch(w(d)){case "Undefined":return b in a;case "Boolean":return a=!(p(a[b])?!a[b].length:!a[b]),d===a;case "Function":return!!d(a[b],b,a);case "Array":return p(a[b])?(e?u:B)(a[b],d):!1;case "RegExp":return p(a[b])?
e?u(a[b],d):!1:!(!a[b]||!a[b].match(d));case "Number":d=String(d);case "String":return p(a[b])?e?u(a[b],d):!1:a[b]===d;default:throw new TypeError("URI.hasQuery() accepts undefined, boolean, string, number, RegExp, Function as the value parameter");}};c.joinPaths=function(){for(var a=[],b=[],d=0,e=0;e<arguments.length;e++){var f=new c(arguments[e]);a.push(f);f=f.segment();for(var g=0;g<f.length;g++)"string"===typeof f[g]&&b.push(f[g]),f[g]&&d++}if(!b.length||!d)return new c("");b=(new c("")).segment(b);
""!==a[0].path()&&"/"!==a[0].path().slice(0,1)||b.path("/"+b.path());return b.normalize()};c.commonPath=function(a,b){var d=Math.min(a.length,b.length),c;for(c=0;c<d;c++)if(a.charAt(c)!==b.charAt(c)){c--;break}if(1>c)return a.charAt(0)===b.charAt(0)&&"/"===a.charAt(0)?"/":"";if("/"!==a.charAt(c)||"/"!==b.charAt(c))c=a.substring(0,c).lastIndexOf("/");return a.substring(0,c+1)};c.withinString=function(a,b,d){d||(d={});var e=d.start||c.findUri.start,f=d.end||c.findUri.end,g=d.trim||c.findUri.trim,h=
d.parens||c.findUri.parens,k=/[a-z0-9-]=["']?$/i;for(e.lastIndex=0;;){var l=e.exec(a);if(!l)break;var m=l.index;if(d.ignoreHtml){var n=a.slice(Math.max(m-3,0),m);if(n&&k.test(n))continue}var p=m+a.slice(m).search(f);n=a.slice(m,p);for(p=-1;;){var q=h.exec(n);if(!q)break;p=Math.max(p,q.index+q[0].length)}n=-1<p?n.slice(0,p)+n.slice(p).replace(g,""):n.replace(g,"");n.length<=l[0].length||d.ignore&&d.ignore.test(n)||(p=m+n.length,l=b(n,m,p,a),void 0===l?e.lastIndex=p:(l=String(l),a=a.slice(0,m)+l+a.slice(p),
e.lastIndex=m+l.length))}e.lastIndex=0;return a};c.ensureValidHostname=function(a,b){var d=!!a,e=!1;b&&(e=u(c.hostProtocols,b));if(e&&!d)throw new TypeError("Hostname cannot be empty, if protocol is "+b);if(a&&a.match(c.invalid_hostname_characters)){if(!k)throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-:_] and Punycode.js is not available');if(k.toASCII(a).match(c.invalid_hostname_characters))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-:_]');
}};c.ensureValidPort=function(a){if(a){var b=Number(a);if(!(/^[0-9]+$/.test(b)&&0<b&&65536>b))throw new TypeError('Port "'+a+'" is not a valid port');}};c.noConflict=function(a){if(a)return a={URI:this.noConflict()},h.URITemplate&&"function"===typeof h.URITemplate.noConflict&&(a.URITemplate=h.URITemplate.noConflict()),h.IPv6&&"function"===typeof h.IPv6.noConflict&&(a.IPv6=h.IPv6.noConflict()),h.SecondLevelDomains&&"function"===typeof h.SecondLevelDomains.noConflict&&(a.SecondLevelDomains=h.SecondLevelDomains.noConflict()),
a;h.URI===this&&(h.URI=H);return this};g.build=function(a){if(!0===a)this._deferred_build=!0;else if(void 0===a||this._deferred_build)this._string=c.build(this._parts),this._deferred_build=!1;return this};g.clone=function(){return new c(this)};g.valueOf=g.toString=function(){return this.build(!1)._string};g.protocol=z("protocol");g.username=z("username");g.password=z("password");g.hostname=z("hostname");g.port=z("port");g.query=F("query","?");g.fragment=F("fragment","#");g.search=function(a,b){var c=
this.query(a,b);return"string"===typeof c&&c.length?"?"+c:c};g.hash=function(a,b){var c=this.fragment(a,b);return"string"===typeof c&&c.length?"#"+c:c};g.pathname=function(a,b){if(void 0===a||!0===a){var d=this._parts.path||(this._parts.hostname?"/":"");return a?(this._parts.urn?c.decodeUrnPath:c.decodePath)(d):d}this._parts.path=this._parts.urn?a?c.recodeUrnPath(a):"":a?c.recodePath(a):"/";this.build(!b);return this};g.path=g.pathname;g.href=function(a,b){var d;if(void 0===a)return this.toString();
this._string="";this._parts=c._parts();var e=a instanceof c,f="object"===typeof a&&(a.hostname||a.path||a.pathname);a.nodeName&&(f=c.getDomAttribute(a),a=a[f]||"",f=!1);!e&&f&&void 0!==a.pathname&&(a=a.toString());if("string"===typeof a||a instanceof String)this._parts=c.parse(String(a),this._parts);else if(e||f){e=e?a._parts:a;for(d in e)"query"!==d&&q.call(this._parts,d)&&(this._parts[d]=e[d]);e.query&&this.query(e.query,!1)}else throw new TypeError("invalid input");this.build(!b);return this};
g.is=function(a){var b=!1,d=!1,e=!1,f=!1,g=!1,h=!1,k=!1,m=!this._parts.urn;this._parts.hostname&&(m=!1,d=c.ip4_expression.test(this._parts.hostname),e=c.ip6_expression.test(this._parts.hostname),b=d||e,g=(f=!b)&&l&&l.has(this._parts.hostname),h=f&&c.idn_expression.test(this._parts.hostname),k=f&&c.punycode_expression.test(this._parts.hostname));switch(a.toLowerCase()){case "relative":return m;case "absolute":return!m;case "domain":case "name":return f;case "sld":return g;case "ip":return b;case "ip4":case "ipv4":case "inet4":return d;
case "ip6":case "ipv6":case "inet6":return e;case "idn":return h;case "url":return!this._parts.urn;case "urn":return!!this._parts.urn;case "punycode":return k}return null};var J=g.protocol,K=g.port,L=g.hostname;g.protocol=function(a,b){if(a&&(a=a.replace(/:(\/\/)?$/,""),!a.match(c.protocol_expression)))throw new TypeError('Protocol "'+a+"\" contains characters other than [A-Z0-9.+-] or doesn't start with [A-Z]");return J.call(this,a,b)};g.scheme=g.protocol;g.port=function(a,b){if(this._parts.urn)return void 0===
a?"":this;void 0!==a&&(0===a&&(a=null),a&&(a+="",":"===a.charAt(0)&&(a=a.substring(1)),c.ensureValidPort(a)));return K.call(this,a,b)};g.hostname=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0!==a){var d={preventInvalidHostname:this._parts.preventInvalidHostname};if("/"!==c.parseHost(a,d))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-]');a=d.hostname;this._parts.preventInvalidHostname&&c.ensureValidHostname(a,this._parts.protocol)}return L.call(this,
a,b)};g.origin=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){var d=this.protocol();return this.authority()?(d?d+"://":"")+this.authority():""}d=c(a);this.protocol(d.protocol()).authority(d.authority()).build(!b);return this};g.host=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?c.buildHost(this._parts):"";if("/"!==c.parseHost(a,this._parts))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-]');
this.build(!b);return this};g.authority=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a)return this._parts.hostname?c.buildAuthority(this._parts):"";if("/"!==c.parseAuthority(a,this._parts))throw new TypeError('Hostname "'+a+'" contains characters other than [A-Z0-9.-]');this.build(!b);return this};g.userinfo=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){var d=c.buildUserinfo(this._parts);return d?d.substring(0,d.length-1):d}"@"!==a[a.length-1]&&
(a+="@");c.parseUserinfo(a,this._parts);this.build(!b);return this};g.resource=function(a,b){if(void 0===a)return this.path()+this.search()+this.hash();var d=c.parse(a);this._parts.path=d.path;this._parts.query=d.query;this._parts.fragment=d.fragment;this.build(!b);return this};g.subdomain=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var d=this._parts.hostname.length-this.domain().length-1;return this._parts.hostname.substring(0,
d)||""}d=this._parts.hostname.length-this.domain().length;d=this._parts.hostname.substring(0,d);d=new RegExp("^"+m(d));a&&"."!==a.charAt(a.length-1)&&(a+=".");if(-1!==a.indexOf(":"))throw new TypeError("Domains cannot contain colons");a&&c.ensureValidHostname(a,this._parts.protocol);this._parts.hostname=this._parts.hostname.replace(d,a);this.build(!b);return this};g.domain=function(a,b){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(b=a,a=void 0);if(void 0===a){if(!this._parts.hostname||
this.is("IP"))return"";var d=this._parts.hostname.match(/\./g);if(d&&2>d.length)return this._parts.hostname;d=this._parts.hostname.length-this.tld(b).length-1;d=this._parts.hostname.lastIndexOf(".",d-1)+1;return this._parts.hostname.substring(d)||""}if(!a)throw new TypeError("cannot set domain empty");if(-1!==a.indexOf(":"))throw new TypeError("Domains cannot contain colons");c.ensureValidHostname(a,this._parts.protocol);!this._parts.hostname||this.is("IP")?this._parts.hostname=a:(d=new RegExp(m(this.domain())+
"$"),this._parts.hostname=this._parts.hostname.replace(d,a));this.build(!b);return this};g.tld=function(a,b){if(this._parts.urn)return void 0===a?"":this;"boolean"===typeof a&&(b=a,a=void 0);if(void 0===a){if(!this._parts.hostname||this.is("IP"))return"";var c=this._parts.hostname.lastIndexOf(".");c=this._parts.hostname.substring(c+1);return!0!==b&&l&&l.list[c.toLowerCase()]?l.get(this._parts.hostname)||c:c}if(a)if(a.match(/[^a-zA-Z0-9-]/))if(l&&l.is(a))c=new RegExp(m(this.tld())+"$"),this._parts.hostname=
this._parts.hostname.replace(c,a);else throw new TypeError('TLD "'+a+'" contains characters other than [A-Z0-9]');else{if(!this._parts.hostname||this.is("IP"))throw new ReferenceError("cannot set TLD on non-domain host");c=new RegExp(m(this.tld())+"$");this._parts.hostname=this._parts.hostname.replace(c,a)}else throw new TypeError("cannot set TLD empty");this.build(!b);return this};g.directory=function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path&&
!this._parts.hostname)return"";if("/"===this._parts.path)return"/";var d=this._parts.path.length-this.filename().length-1;d=this._parts.path.substring(0,d)||(this._parts.hostname?"/":"");return a?c.decodePath(d):d}d=this._parts.path.length-this.filename().length;d=this._parts.path.substring(0,d);d=new RegExp("^"+m(d));this.is("relative")||(a||(a="/"),"/"!==a.charAt(0)&&(a="/"+a));a&&"/"!==a.charAt(a.length-1)&&(a+="/");a=c.recodePath(a);this._parts.path=this._parts.path.replace(d,a);this.build(!b);
return this};g.filename=function(a,b){if(this._parts.urn)return void 0===a?"":this;if("string"!==typeof a){if(!this._parts.path||"/"===this._parts.path)return"";var d=this._parts.path.lastIndexOf("/");d=this._parts.path.substring(d+1);return a?c.decodePathSegment(d):d}d=!1;"/"===a.charAt(0)&&(a=a.substring(1));a.match(/\.?\//)&&(d=!0);var e=new RegExp(m(this.filename())+"$");a=c.recodePath(a);this._parts.path=this._parts.path.replace(e,a);d?this.normalizePath(b):this.build(!b);return this};g.suffix=
function(a,b){if(this._parts.urn)return void 0===a?"":this;if(void 0===a||!0===a){if(!this._parts.path||"/"===this._parts.path)return"";var d=this.filename(),e=d.lastIndexOf(".");if(-1===e)return"";d=d.substring(e+1);d=/^[a-z0-9%]+$/i.test(d)?d:"";return a?c.decodePathSegment(d):d}"."===a.charAt(0)&&(a=a.substring(1));if(d=this.suffix())e=a?new RegExp(m(d)+"$"):new RegExp(m("."+d)+"$");else{if(!a)return this;this._parts.path+="."+c.recodePath(a)}e&&(a=c.recodePath(a),this._parts.path=this._parts.path.replace(e,
a));this.build(!b);return this};g.segment=function(a,b,c){var d=this._parts.urn?":":"/",f=this.path(),g="/"===f.substring(0,1);f=f.split(d);void 0!==a&&"number"!==typeof a&&(c=b,b=a,a=void 0);if(void 0!==a&&"number"!==typeof a)throw Error('Bad segment "'+a+'", must be 0-based integer');g&&f.shift();0>a&&(a=Math.max(f.length+a,0));if(void 0===b)return void 0===a?f:f[a];if(null===a||void 0===f[a])if(p(b)){f=[];a=0;for(var h=b.length;a<h;a++)if(b[a].length||f.length&&f[f.length-1].length)f.length&&!f[f.length-
1].length&&f.pop(),f.push(C(b[a]))}else{if(b||"string"===typeof b)b=C(b),""===f[f.length-1]?f[f.length-1]=b:f.push(b)}else b?f[a]=C(b):f.splice(a,1);g&&f.unshift("");return this.path(f.join(d),c)};g.segmentCoded=function(a,b,d){var e;"number"!==typeof a&&(d=b,b=a,a=void 0);if(void 0===b){a=this.segment(a,b,d);if(p(a)){var f=0;for(e=a.length;f<e;f++)a[f]=c.decode(a[f])}else a=void 0!==a?c.decode(a):void 0;return a}if(p(b))for(f=0,e=b.length;f<e;f++)b[f]=c.encode(b[f]);else b="string"===typeof b||b instanceof
String?c.encode(b):b;return this.segment(a,b,d)};var M=g.query;g.query=function(a,b){if(!0===a)return c.parseQuery(this._parts.query,this._parts.escapeQuerySpace);if("function"===typeof a){var d=c.parseQuery(this._parts.query,this._parts.escapeQuerySpace),e=a.call(this,d);this._parts.query=c.buildQuery(e||d,this._parts.duplicateQueryParameters,this._parts.escapeQuerySpace);this.build(!b);return this}return void 0!==a&&"string"!==typeof a?(this._parts.query=c.buildQuery(a,this._parts.duplicateQueryParameters,
this._parts.escapeQuerySpace),this.build(!b),this):M.call(this,a,b)};g.setQuery=function(a,b,d){var e=c.parseQuery(this._parts.query,this._parts.escapeQuerySpace);if("string"===typeof a||a instanceof String)e[a]=void 0!==b?b:null;else if("object"===typeof a)for(var f in a)q.call(a,f)&&(e[f]=a[f]);else throw new TypeError("URI.addQuery() accepts an object, string as the name parameter");this._parts.query=c.buildQuery(e,this._parts.duplicateQueryParameters,this._parts.escapeQuerySpace);"string"!==typeof a&&
(d=b);this.build(!d);return this};g.addQuery=function(a,b,d){var e=c.parseQuery(this._parts.query,this._parts.escapeQuerySpace);c.addQuery(e,a,void 0===b?null:b);this._parts.query=c.buildQuery(e,this._parts.duplicateQueryParameters,this._parts.escapeQuerySpace);"string"!==typeof a&&(d=b);this.build(!d);return this};g.removeQuery=function(a,b,d){var e=c.parseQuery(this._parts.query,this._parts.escapeQuerySpace);c.removeQuery(e,a,b);this._parts.query=c.buildQuery(e,this._parts.duplicateQueryParameters,
this._parts.escapeQuerySpace);"string"!==typeof a&&(d=b);this.build(!d);return this};g.hasQuery=function(a,b,d){var e=c.parseQuery(this._parts.query,this._parts.escapeQuerySpace);return c.hasQuery(e,a,b,d)};g.setSearch=g.setQuery;g.addSearch=g.addQuery;g.removeSearch=g.removeQuery;g.hasSearch=g.hasQuery;g.normalize=function(){return this._parts.urn?this.normalizeProtocol(!1).normalizePath(!1).normalizeQuery(!1).normalizeFragment(!1).build():this.normalizeProtocol(!1).normalizeHostname(!1).normalizePort(!1).normalizePath(!1).normalizeQuery(!1).normalizeFragment(!1).build()};
g.normalizeProtocol=function(a){"string"===typeof this._parts.protocol&&(this._parts.protocol=this._parts.protocol.toLowerCase(),this.build(!a));return this};g.normalizeHostname=function(a){this._parts.hostname&&(this.is("IDN")&&k?this._parts.hostname=k.toASCII(this._parts.hostname):this.is("IPv6")&&n&&(this._parts.hostname=n.best(this._parts.hostname)),this._parts.hostname=this._parts.hostname.toLowerCase(),this.build(!a));return this};g.normalizePort=function(a){"string"===typeof this._parts.protocol&&
this._parts.port===c.defaultPorts[this._parts.protocol]&&(this._parts.port=null,this.build(!a));return this};g.normalizePath=function(a){var b=this._parts.path;if(!b)return this;if(this._parts.urn)return this._parts.path=c.recodeUrnPath(this._parts.path),this.build(!a),this;if("/"===this._parts.path)return this;b=c.recodePath(b);var d="";if("/"!==b.charAt(0)){var e=!0;b="/"+b}if("/.."===b.slice(-3)||"/."===b.slice(-2))b+="/";b=b.replace(/(\/(\.\/)+)|(\/\.$)/g,"/").replace(/\/{2,}/g,"/");e&&(d=b.substring(1).match(/^(\.\.\/)+/)||
"")&&(d=d[0]);for(;;){var f=b.search(/\/\.\.(\/|$)/);if(-1===f)break;else if(0===f){b=b.substring(3);continue}var g=b.substring(0,f).lastIndexOf("/");-1===g&&(g=f);b=b.substring(0,g)+b.substring(f+3)}e&&this.is("relative")&&(b=d+b.substring(1));this._parts.path=b;this.build(!a);return this};g.normalizePathname=g.normalizePath;g.normalizeQuery=function(a){"string"===typeof this._parts.query&&(this._parts.query.length?this.query(c.parseQuery(this._parts.query,this._parts.escapeQuerySpace)):this._parts.query=
null,this.build(!a));return this};g.normalizeFragment=function(a){this._parts.fragment||(this._parts.fragment=null,this.build(!a));return this};g.normalizeSearch=g.normalizeQuery;g.normalizeHash=g.normalizeFragment;g.iso8859=function(){var a=c.encode,b=c.decode;c.encode=escape;c.decode=decodeURIComponent;try{this.normalize()}finally{c.encode=a,c.decode=b}return this};g.unicode=function(){var a=c.encode,b=c.decode;c.encode=E;c.decode=unescape;try{this.normalize()}finally{c.encode=a,c.decode=b}return this};
g.readable=function(){var a=this.clone();a.username("").password("").normalize();var b="";a._parts.protocol&&(b+=a._parts.protocol+"://");a._parts.hostname&&(a.is("punycode")&&k?(b+=k.toUnicode(a._parts.hostname),a._parts.port&&(b+=":"+a._parts.port)):b+=a.host());a._parts.hostname&&a._parts.path&&"/"!==a._parts.path.charAt(0)&&(b+="/");b+=a.path(!0);if(a._parts.query){for(var d="",e=0,f=a._parts.query.split("&"),g=f.length;e<g;e++){var h=(f[e]||"").split("=");d+="&"+c.decodeQuery(h[0],this._parts.escapeQuerySpace).replace(/&/g,
"%26");void 0!==h[1]&&(d+="="+c.decodeQuery(h[1],this._parts.escapeQuerySpace).replace(/&/g,"%26"))}b+="?"+d.substring(1)}return b+=c.decodeQuery(a.hash(),!0)};g.absoluteTo=function(a){var b=this.clone(),d=["protocol","username","password","hostname","port"],e,f;if(this._parts.urn)throw Error("URNs do not have any generally defined hierarchical components");a instanceof c||(a=new c(a));if(b._parts.protocol)return b;b._parts.protocol=a._parts.protocol;if(this._parts.hostname)return b;for(e=0;f=d[e];e++)b._parts[f]=
a._parts[f];b._parts.path?(".."===b._parts.path.substring(-2)&&(b._parts.path+="/"),"/"!==b.path().charAt(0)&&(d=(d=a.directory())?d:0===a.path().indexOf("/")?"/":"",b._parts.path=(d?d+"/":"")+b._parts.path,b.normalizePath())):(b._parts.path=a._parts.path,b._parts.query||(b._parts.query=a._parts.query));b.build();return b};g.relativeTo=function(a){var b=this.clone().normalize();if(b._parts.urn)throw Error("URNs do not have any generally defined hierarchical components");a=(new c(a)).normalize();var d=
b._parts;var e=a._parts;var f=b.path();a=a.path();if("/"!==f.charAt(0))throw Error("URI is already relative");if("/"!==a.charAt(0))throw Error("Cannot calculate a URI relative to another relative URI");d.protocol===e.protocol&&(d.protocol=null);if(d.username===e.username&&d.password===e.password&&null===d.protocol&&null===d.username&&null===d.password&&d.hostname===e.hostname&&d.port===e.port)d.hostname=null,d.port=null;else return b.build();if(f===a)return d.path="",b.build();f=c.commonPath(f,a);
if(!f)return b.build();e=e.path.substring(f.length).replace(/[^\/]*$/,"").replace(/.*?\//g,"../");d.path=e+d.path.substring(f.length)||"./";return b.build()};g.equals=function(a){var b=this.clone(),d=new c(a);a={};var e;b.normalize();d.normalize();if(b.toString()===d.toString())return!0;var f=b.query();var g=d.query();b.query("");d.query("");if(b.toString()!==d.toString()||f.length!==g.length)return!1;b=c.parseQuery(f,this._parts.escapeQuerySpace);g=c.parseQuery(g,this._parts.escapeQuerySpace);for(e in b)if(q.call(b,
e)){if(!p(b[e])){if(b[e]!==g[e])return!1}else if(!B(b[e],g[e]))return!1;a[e]=!0}for(e in g)if(q.call(g,e)&&!a[e])return!1;return!0};g.preventInvalidHostname=function(a){this._parts.preventInvalidHostname=!!a;return this};g.duplicateQueryParameters=function(a){this._parts.duplicateQueryParameters=!!a;return this};g.escapeQuerySpace=function(a){this._parts.escapeQuerySpace=!!a;return this};return c});

View File

@@ -3,7 +3,8 @@ from sqlalchemy import or_
from sqlalchemy.orm import subqueryload
from sqlalchemy.sql.expression import func
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, ContentWarning, PackageState
from .models import db, PackageType, Package, ForumTopic, License, MinetestRelease, PackageRelease, User, Tag, \
ContentWarning, PackageState, PackageDevState
from .utils import isYes, get_int_or_abort
@@ -20,7 +21,7 @@ class QueryBuilder:
types = [PackageType.get(tname) for tname in types]
types = [type for type in types if type is not None]
if len(types) > 0:
title = ", ".join([type.value + "s" for type in types])
title = ", ".join([str(type.plural) for type in types])
# Get tags types
tags = args.getlist("tag")
@@ -28,8 +29,7 @@ class QueryBuilder:
tags = [tag for tag in tags if tag is not None]
# Hide
hide_flags = args.getlist("hide")
self.hide_flags = set(args.getlist("hide"))
self.title = title
self.types = types
@@ -37,13 +37,24 @@ class QueryBuilder:
self.random = "random" in args
self.lucky = "lucky" in args
self.limit = 1 if self.lucky else None
self.limit = 1 if self.lucky else get_int_or_abort(args.get("limit"), None)
self.order_by = args.get("sort")
self.order_dir = args.get("order") or "desc"
self.hide_nonfree = "nonfree" in hide_flags
self.hide_flags = set(hide_flags)
if "android_default" in self.hide_flags:
self.hide_flags.update(["*", "deprecated"])
self.hide_flags.discard("android_default")
if "desktop_default" in self.hide_flags:
self.hide_flags.update(["deprecated"])
self.hide_flags.discard("desktop_default")
self.hide_nonfree = "nonfree" in self.hide_flags
self.hide_wip = "wip" in self.hide_flags
self.hide_deprecated = "deprecated" in self.hide_flags
self.hide_flags.discard("nonfree")
self.hide_flags.discard("wip")
self.hide_flags.discard("deprecated")
# Filters
self.search = args.get("q")
@@ -101,7 +112,7 @@ class QueryBuilder:
else:
query = Package.query.filter_by(state=PackageState.APPROVED)
query = query.options(subqueryload(Package.main_screenshot))
query = query.options(subqueryload(Package.main_screenshot), subqueryload(Package.aliases))
query = self.orderPackageQuery(self.filterPackageQuery(query))
@@ -124,7 +135,7 @@ class QueryBuilder:
for tag in self.tags:
query = query.filter(Package.tags.any(Tag.id == tag.id))
if "android_default" in self.hide_flags:
if "*" in self.hide_flags:
query = query.filter(~ Package.content_warnings.any())
else:
for flag in self.hide_flags:
@@ -136,6 +147,11 @@ class QueryBuilder:
query = query.filter(Package.license.has(License.is_foss == True))
query = query.filter(Package.media_license.has(License.is_foss == True))
if self.hide_wip:
query = query.filter(or_(Package.dev_state == None, Package.dev_state != PackageDevState.WIP))
if self.hide_deprecated:
query = query.filter(or_(Package.dev_state == None, Package.dev_state != PackageDevState.DEPRECATED))
if self.version:
query = query.join(Package.releases) \
.filter(PackageRelease.approved == True) \
@@ -207,7 +223,8 @@ class QueryBuilder:
query = query.order_by(db.asc(ForumTopic.created_at))
if self.search:
query = query.filter(ForumTopic.title.ilike('%' + self.search + '%'))
query = query.filter(or_(ForumTopic.title.ilike('%' + self.search + '%'),
ForumTopic.name == self.search.lower()))
if len(self.types) > 0:
query = query.filter(ForumTopic.type.in_(self.types))

View File

@@ -9,6 +9,7 @@
border: 1px solid #444 !important;
overflow-x: hidden;
overflow-y: auto;
max-width: 700px;
max-height: 400px;
li {
@@ -22,6 +23,13 @@
padding: 0.3em 0.5em !important;
margin: 0 !important;
}
a:hover, a:focus, li:focus a {
font-weight: normal !important;
text-decoration: none;
padding: 0.3em 0.5em !important;
margin: 0 !important;
}
}
.bulletselector {
@@ -62,7 +70,7 @@
}
.NOT_JOINED a, .NOT_JOINED {
color: #7ac !important;
color: #aaa !important;
}
.ADMIN a, .ADMIN{
@@ -73,6 +81,10 @@
color: #e90 !important;
}
.APPROVER a, .APPROVER {
color: #69f !important;
}
.EDITOR a, .EDITOR {
color: #b6f !important;
}
@@ -209,3 +221,54 @@ blockquote {
border-top-right-radius: 0;
}
}
.info-sidebar {
color: #999;
h3 {
color: #aaa;
}
& > h3:first-of-type {
margin-top: 0;
}
dt {
font-weight: normal;
}
dd {
font-weight: bold;
max-height: 300px;
overflow-y: auto;
}
.badge, .list-group-item-action {
color: #ddd;
}
& > .btn.btn-secondary {
color: #ddd;
background-color: #303030;
border-color: #444;
&:hover {
background-color: #444;
}
}
}
.panel {
background: #2c2c2c;
padding: 1.25rem;
border: 1px solid rgba(0,0,0,0.125);
border-radius: .25rem;
& > *:first-child {
margin-top: 0 !important;
}
& > *:last-child {
margin-bottom: 0 !important;
}
}

View File

@@ -55,6 +55,13 @@ a:hover .badge-notify {
color: black;
}
.badge-emoji {
padding: 0;
background: transparent;
font-size: 15px;
top: -10px;
}
p, .content li {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased !important;
@@ -64,7 +71,15 @@ p, .content li {
line-height: 1.6em;
}
pre code {
.markdown {
word-break: break-word;
img {
max-width: 100%;
}
}
pre {
display: block;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(51, 51, 51, 0.25);
@@ -158,4 +173,33 @@ pre code {
}
}
#featuredCarousel {
.embed-responsive-item {
filter: brightness(0.85);
object-fit: cover;
}
.carousel-item, .embed-responsive-item {
max-height: 50vh;
}
.carousel-inner {
background-color: #000;
border-radius: 3px;
overflow: hidden;
}
.carousel-indicators {
margin-bottom: 0 !important;
opacity: 0.6;
}
}
@media (min-width: 768px) {
#featuredCarousel h3 {
font-size: calc(1.325rem + .9vw) !important;
}
}
.text-shadow {
text-shadow: 0 0 10px rgba(10, 10, 10, 0.2), 3px 3px 3px rgba(10, 10, 10, 0.4);
}
@import "dracula.scss";

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