Compare commits

...

1200 Commits

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

Translated using Weblate (Chinese (Simplified))

Currently translated at 28.1% (205 of 727 strings)

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

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

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

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

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

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

Added translation using Weblate (Ukrainian)

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

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

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

Translated using Weblate (German)

Currently translated at 97.7% (711 of 727 strings)

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

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

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

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

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

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

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

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

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

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

Translated using Weblate (French)

Currently translated at 89.8% (626 of 697 strings)

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

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

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

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

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

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

Added translation using Weblate (Chinese (Simplified))

Added translation using Weblate (Chinese (Literary))

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

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

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

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

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

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

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

Added translation using Weblate (Turkish)

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

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

Added translation using Weblate (Chinese (Traditional))

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

Translated using Weblate (Malay)

Currently translated at 95.4% (653 of 684 strings)

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

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

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

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

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

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

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

Translated using Weblate (German)

Currently translated at 69.3% (455 of 656 strings)

Translated using Weblate (German)

Currently translated at 68.1% (447 of 656 strings)

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

Translated using Weblate (German)

Currently translated at 69.3% (455 of 656 strings)

Translated using Weblate (German)

Currently translated at 68.1% (447 of 656 strings)

Translated using Weblate (German)

Currently translated at 65.0% (427 of 656 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Added translation using Weblate (Russian)

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

Added translation using Weblate (German)

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

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

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

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

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

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

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

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

Translated using Weblate (Indonesian)

Currently translated at 57.2% (376 of 657 strings)

Added translation using Weblate (Indonesian)

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

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

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

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

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

Added translation using Weblate (Spanish)

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

Translated using Weblate (French)

Currently translated at 69.4% (223 of 321 strings)

Translated using Weblate (French)

Currently translated at 64.4% (207 of 321 strings)

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

Added translation using Weblate (Norwegian Bokmål)

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

Translated using Weblate (French)

Currently translated at 69.4% (223 of 321 strings)

Translated using Weblate (French)

Currently translated at 64.4% (207 of 321 strings)

Translated using Weblate (French)

Currently translated at 28.3% (91 of 321 strings)

Added translation using Weblate (French)

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

Translated using Weblate (Malay)

Currently translated at 0.0% (0 of 321 strings)

Added translation using Weblate (Malay)

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-10 16:33:19 +01:00
rubenwardy 8dbd22f56c Add custom 404 page 2021-04-10 16:30:19 +01:00
rubenwardy c2994a27fd Fix maintainers not being able to delete releases 2021-03-07 15:00:36 +00:00
rubenwardy 9cb9f8a4f6 Hotfix: Prevent webhooks from running on non-master/main branches 2021-03-07 14:48:04 +00:00
rubenwardy 4d2833de88 Add all releases API 2021-03-05 12:55:21 +00:00
rubenwardy adcbf7455e Fix incorrect status code for Found 2021-03-02 22:37:37 +00:00
rubenwardy df8ef542dd Disallow spaces in usernames 2021-03-02 22:36:21 +00:00
rubenwardy c11e5c1f99 Revert "Fix Git clone error when checking out reference"
This reverts commit 63cfb5eac0.
2021-03-02 16:42:38 +00:00
rubenwardy 63cfb5eac0 Fix Git clone error when checking out reference 2021-03-01 18:10:46 +00:00
rubenwardy 6861524641 Improve release ratelimit message, increase limit 2021-03-01 18:10:33 +00:00
rubenwardy 032d8bf67b Fix minmum length on packages titles
Fixes #263
2021-02-28 21:32:28 +00:00
rubenwardy 47797f1fb1 Make it more clear that .conf will override release min/max vers 2021-02-28 21:30:54 +00:00
rubenwardy 92764465e0 Disable code highlighting for non-annotated code fences
Fixes #279
2021-02-28 21:24:57 +00:00
rubenwardy 04e108c31e Fix API crash due to missing upper() in enum coercion 2021-02-28 16:04:40 +00:00
rubenwardy aead579f0b Use pen icon instead of edit 2021-02-28 05:19:57 +00:00
rubenwardy f9089319d3 Improve package page sidebar 2021-02-28 05:19:57 +00:00
rubenwardy ff2f7caee1 Add support for README files when importing meta
Fixes #87
2021-02-28 02:36:31 +00:00
rubenwardy da81df535a API Screenshots: Fix crash on not a number 2021-02-28 01:14:43 +00:00
rubenwardy 7078ed3ac3 Fix duplicate name checking in register form 2021-02-27 19:11:53 +00:00
rubenwardy da6b4b210f Allow specifying display name on register 2021-02-27 19:03:52 +00:00
rubenwardy 04f659bc2b API: Add support for specifying commit for zip releases 2021-02-27 18:31:56 +00:00
rubenwardy 8ef74deec1 Fix duplicate check for changing display names 2021-02-25 23:31:29 +00:00
rubenwardy 7e20a09499 Increase severity of display name change audit log 2021-02-25 23:29:27 +00:00
rubenwardy dea5a52c86 Allow users to change their display name
Fixes #269
2021-02-25 23:25:33 +00:00
rubenwardy 96b5b4ea5b Add link to monitor and API 2021-02-25 23:03:32 +00:00
rubenwardy aec346e2d4 Fix names not being included in topic query 2021-02-25 14:11:10 +00:00
rubenwardy b41e4b50d9 Fix comment box padding 2021-02-23 00:46:16 +00:00
rubenwardy 3c095544d0 Enable word break in markdown content 2021-02-23 00:08:36 +00:00
rubenwardy 77dcb85912 Add username validation to signup page 2021-02-22 23:45:20 +00:00
rubenwardy 3ed73c4145 Fix crash on login with redirect 2021-02-11 23:26:23 +00:00
rubenwardy c37f589765 Add package audit page 2021-02-08 00:47:34 +00:00
rubenwardy 7ff92bc7c1 Add ability to filter by no tags on package tags page 2021-02-05 17:03:50 +00:00
rubenwardy 3839dfbf90 Fix tag selector dropdown style 2021-02-05 16:42:48 +00:00
rubenwardy 0ff4f40652 Fix screenshot reordering 2021-02-05 16:05:20 +00:00
rubenwardy 2797792322 Add search box and edit tags button to Package Tags page 2021-02-05 16:01:12 +00:00
rubenwardy 3ce653ba74 Update documentation 2021-02-05 15:44:00 +00:00
rubenwardy 0918b8b676 Update front-end dependencies 2021-02-05 14:21:34 +00:00
rubenwardy 63204575eb Prevent duplicate commit releases from webhooks 2021-02-04 19:54:26 +00:00
rubenwardy fb3b0be50e Update minetest_client.md docs 2021-02-03 18:30:21 +00:00
rubenwardy 0c08738a66 Update API docs 2021-02-03 12:50:16 +00:00
rubenwardy 21cf5b57c1 Redesign sign in screen 2021-02-03 00:58:49 +00:00
rubenwardy b5f47b1b73 Fix typo 2021-02-03 00:14:14 +00:00
rubenwardy 05c140da78 Update docs, rename desc to long_description 2021-02-03 00:11:48 +00:00
rubenwardy 8225e4098b Add support for .cdb.json
Fixes #231
2021-02-02 23:58:59 +00:00
rubenwardy 90aeb6e1a7 Add missing fields to packages API 2021-02-02 23:30:11 +00:00
rubenwardy 12e364969b Add licenses API 2021-02-02 22:41:48 +00:00
rubenwardy ca58c70206 Add validation to package API 2021-02-02 22:34:51 +00:00
rubenwardy 551996ca14 Add start of package edit API 2021-02-02 21:35:29 +00:00
rubenwardy bb79d564a8 Fix missing bcrypt dep 2021-02-02 21:07:15 +00:00
rubenwardy 878872799e Fix crash on wtfpl page 2021-02-02 20:43:10 +00:00
rubenwardy aa7b8a0fc0 Update dependencies 2021-02-02 20:18:53 +00:00
rubenwardy 14810b2cc5 Add table of contents to help pages 2021-02-02 20:05:24 +00:00
rubenwardy 5017a9ba7e Allow codehighlighting in markdown, enable linkify 2021-02-02 18:16:57 +00:00
rubenwardy a040c7dd2e Clean up audit_log reasons 2021-02-02 17:29:03 +00:00
rubenwardy 912ebbc409 Add API to delete releases 2021-02-02 17:09:28 +00:00
rubenwardy e1fe63ab19 Add more screenshot APIs 2021-02-02 17:09:25 +00:00
rubenwardy 509f03ce65 Add API to create screenshots 2021-02-02 17:09:23 +00:00
rubenwardy 64a897b52f Improve token form 2021-02-02 17:09:21 +00:00
rubenwardy 2f66db5989 Update API docs, add support for code highlighting, add markdown table support 2021-02-02 17:09:19 +00:00
rubenwardy 033f40c263 Add support for zip uploads in the API
Fixes #261
2021-02-02 00:19:32 +00:00
rubenwardy a78fe8ceb9 Split importtasks.py 2021-02-01 22:43:43 +00:00
rubenwardy c6a973f7e1 Add ability to filter by minetest-mods in outdated page 2021-02-01 21:18:27 +00:00
rubenwardy d7647520c8 Fix erroneous updates from Git Update Detector 2021-02-01 11:34:55 +00:00
rubenwardy 70f491fd27 Add Editor help page 2021-01-31 17:51:25 +00:00
rubenwardy f07f2803f8 Fix crash in get_latest_tag due to missing tuple 2021-01-31 11:24:04 +00:00
rubenwardy 4364ce5d6f Fix incorrect query in check_update_config task 2021-01-30 23:51:09 +00:00
rubenwardy 7c3d738756 Fix index out of range error in get_latest_tag 2021-01-30 23:42:00 +00:00
rubenwardy ede010c25d Fix condition in apply_all_updates 2021-01-30 22:59:55 +00:00
rubenwardy db09b8eb84 Add ability to bulk create releases for outdated packages 2021-01-30 22:58:55 +00:00
rubenwardy a0cd155730 Add ability to bulk set update config 2021-01-30 22:44:54 +00:00
rubenwardy b7814d9541 Check for existing releases with commit in update checker 2021-01-30 19:56:27 +00:00
rubenwardy 912b917a47 Fix API packages with no arguments 2021-01-30 19:49:15 +00:00
rubenwardy c0112828eb Optimise package query speed 2021-01-30 19:32:04 +00:00
rubenwardy b3237b0c49 Split utils.py into package 2021-01-30 18:25:00 +00:00
rubenwardy b22ef5ae83 Add release creation audit logs 2021-01-30 17:10:38 +00:00
rubenwardy 8d6661511a Update copyrights 2021-01-30 17:00:58 +00:00
rubenwardy 607c534174 Improve outdated messages: don't show last commit on tag triggers 2021-01-30 16:50:32 +00:00
rubenwardy 3b213889ca Improve outdated messages 2021-01-30 16:48:11 +00:00
rubenwardy 36dc51ef4a Fix broken query condition in user todo 2021-01-30 16:07:39 +00:00
rubenwardy 663cbd91f5 Add missing tags section to user todo list 2021-01-30 16:03:09 +00:00
rubenwardy 82fe0e7bbf Add All Package Tags to todo tabs 2021-01-30 15:53:13 +00:00
rubenwardy 324815d58d Default to sort=score on All Outdated Packages 2021-01-30 15:45:48 +00:00
rubenwardy a67e3af172 Improve update config form 2021-01-30 15:41:55 +00:00
rubenwardy 0cd23f7883 Add hints to update config form 2021-01-30 15:22:19 +00:00
rubenwardy 1b296fcae5 Update documentation 2021-01-30 15:18:56 +00:00
rubenwardy 84d7030f7d Consistently use "Git Update Detection" 2021-01-30 15:09:31 +00:00
rubenwardy 2fddc276de Add note to outdated packages in user todo 2021-01-30 15:05:22 +00:00
rubenwardy a92ef0a8a1 Fix crash due to wrong method call to get screenshot thumbnail 2021-01-30 14:57:38 +00:00
rubenwardy 99eee9c758 Use utc date format when creating releases using Update Config 2021-01-30 01:21:26 +00:00
rubenwardy 56ff354021 Fix new release not reseting outdated flag 2021-01-30 01:19:43 +00:00
rubenwardy ac4d5c8c88 Add page with list of all update configs 2021-01-30 01:08:00 +00:00
rubenwardy c5fa76dab0 Add permissions check in outdated macro 2021-01-30 00:50:50 +00:00
rubenwardy 33bf3304a1 Add help message and button to outdated packages in to do list 2021-01-30 00:43:21 +00:00
rubenwardy 53d2d18b89 Fix create release title in warning on package page 2021-01-30 00:25:48 +00:00
rubenwardy fa23a00014 Fix create release link in warning on package page 2021-01-30 00:24:38 +00:00
rubenwardy 81b24c6cb3 Add outdated warning to package page 2021-01-30 00:15:07 +00:00
rubenwardy 60a33a6492 Add ability to sort outdated packages list 2021-01-30 00:02:40 +00:00
rubenwardy 9acb7698ef Fix updates beat 2021-01-29 23:35:23 +00:00
rubenwardy 9e6ded6544 Fix empty message on user to do page 2021-01-29 23:30:30 +00:00
rubenwardy ff5f98558d Fix notifications page package name width 2021-01-29 23:29:06 +00:00
rubenwardy a088b1b0b5 Remove outdated packages section from editor work queue, as it's a tab now 2021-01-29 23:26:44 +00:00
rubenwardy 29adccb6d1 Add PackageUpdateConfig.auto_created 2021-01-29 23:18:37 +00:00
rubenwardy c6d39fcba3 Add title and ref query args to create release 2021-01-29 23:12:26 +00:00
rubenwardy fe2acddb5b Add datetime to outdated packages macro 2021-01-29 23:00:23 +00:00
rubenwardy 3dde8c05ad Improve wizard behaviour 2021-01-29 22:54:14 +00:00
rubenwardy f49da74c3a Add help page for update detection 2021-01-29 22:50:18 +00:00
rubenwardy 53babc1113 Fix outdated packages page query 2021-01-29 22:37:17 +00:00
rubenwardy 09f8302e74 Add support for New Tag update detection trigger 2021-01-29 22:20:44 +00:00
rubenwardy 665bfd64d2 Bump CSS version 2021-01-29 20:27:33 +00:00
rubenwardy cf5360f6f6 Don't disable update config on webhooks 2021-01-29 20:26:45 +00:00
rubenwardy f1edfcebc0 Fix broken package icons 2021-01-29 20:21:17 +00:00
rubenwardy ef9860b6cc Replace update bot message with notification only 2021-01-29 20:01:48 +00:00
rubenwardy 4f920f011f Add new to do list UI 2021-01-29 19:38:14 +00:00
rubenwardy b613ac4b89 Fix default branch detection 2021-01-29 17:17:51 +00:00
rubenwardy e8dca43f44 Remove webhook creation wizard 2021-01-29 16:36:23 +00:00
rubenwardy 46b60f9d24 Improve updateconfig docs further 2021-01-29 16:36:23 +00:00
rubenwardy a02942b7e0 Disable updateconfig if webhooks are used 2021-01-29 16:36:23 +00:00
rubenwardy 693cf4250a Improve updateconfig docs 2021-01-29 16:36:23 +00:00
rubenwardy ee9f6454e0 Change bot thread title 2021-01-29 16:36:23 +00:00
rubenwardy c5d99e00d8 Document that new tag isn't implemented yet 2021-01-29 16:36:23 +00:00
rubenwardy 7be0616d38 Improve bot profile picture contrast 2021-01-29 16:36:23 +00:00
rubenwardy ea527f9598 Only run update configs on approved packages 2021-01-29 16:36:23 +00:00
rubenwardy 8fcea988ca Add admin option to set updateconfigs on all relevant packages 2021-01-29 16:36:23 +00:00
rubenwardy 6b8b98c15b Add updateconfig error reporting 2021-01-29 16:36:23 +00:00
rubenwardy 17798df342 Add ref to update config 2021-01-29 16:36:23 +00:00
rubenwardy 2f2b8dc983 Add link to updateconfig from new release 2021-01-29 16:36:23 +00:00
rubenwardy 6e763b8453 Add notification type for bot messages 2021-01-29 16:36:23 +00:00
rubenwardy 09a9219fcd Add outdated flag to UpdateConfig to stop notification spam 2021-01-29 16:36:23 +00:00
rubenwardy c8406b45d4 Add set up releases wizard 2021-01-29 16:36:23 +00:00
rubenwardy 14a67b99ba Add package update configuration for polling 2021-01-29 16:36:23 +00:00
rubenwardy 7461acdd1f Add link to Installing X on the package page 2021-01-29 16:27:38 +00:00
rubenwardy 88a8e85b12 Fix crash on forum profile not found 2021-01-27 17:42:47 +00:00
rubenwardy 5a1656b8d0 Add more information to minetest_client.md 2021-01-26 17:35:06 +00:00
rubenwardy 8fad3a15cd Improve documentation 2021-01-26 17:04:34 +00:00
rubenwardy ce4c2142e2 Rename "Work in Progress" state to "Draft" 2021-01-25 16:58:58 +00:00
rubenwardy 6f9c01c375 Add pending verification note to email settings 2021-01-24 13:25:17 +00:00
rubenwardy 5e255a07f6 Add link to issue tracker in review form 2021-01-24 13:10:20 +00:00
rubenwardy 5314fda342 Fix broken user delete 2021-01-24 13:01:24 +00:00
rubenwardy dfc6f6fd6e Fix crash on importing texture pack with no .conf 2021-01-21 20:47:44 +00:00
rubenwardy 05a08b4c05 Check for release key in minetestcheck 2021-01-16 18:19:51 +00:00
rubenwardy 07d7282383 Add APIs for tags and homepage 2021-01-16 00:34:09 +00:00
rubenwardy 01bed3e307 Add user-agnostic redirect to todo tags 2021-01-10 03:09:02 +00:00
dependabot[bot] aabf70d458 Bump lxml from 4.5.2 to 4.6.2
Bumps [lxml](https://github.com/lxml/lxml) from 4.5.2 to 4.6.2.
- [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.5.2...lxml-4.6.2)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-10 03:07:50 +00:00
rubenwardy 6d2558a921 Update top packages page 2021-01-10 03:03:07 +00:00
rubenwardy 28995ffdd6 Update policy and guidance 2021-01-10 03:00:28 +00:00
rubenwardy e4d0b57f3c Improve Minetest config parsing error messages 2021-01-10 02:59:57 +00:00
rubenwardy 0054f362a7 Add account form to account settings page 2021-01-01 17:02:08 +00:00
rubenwardy 12bcdf2d47 Prevent moderators and admins from being deleted 2021-01-01 16:53:14 +00:00
rubenwardy e709fc9ce3 Update new thread message 2021-01-01 16:36:39 +00:00
Lars Mueller e0b490fdc0 Add support for multiline values in .conf files 2021-01-01 16:24:49 +00:00
rubenwardy 7964f5979a Add missing 'ago' to datetimes 2020-12-31 18:21:34 +00:00
rubenwardy 6ebab36877 Fix postReleaseCheckUpdate running twice on release creation 2020-12-31 18:19:07 +00:00
rubenwardy afb699f8d3 Include mod name as prefix to zips created by git-archive-all 2020-12-29 20:42:43 +00:00
rubenwardy d772f157eb Improve user dropdown 2020-12-22 16:09:28 +00:00
rubenwardy 1b81ff4d3b
Update privacy policy 2020-12-22 13:14:49 +00:00
rubenwardy 8c5d997c6e Improve threads list design further 2020-12-22 12:54:48 +00:00
rubenwardy c065519cca Add threads to navbar 2020-12-22 12:50:59 +00:00
rubenwardy df79159e2e Improve thread list appearance 2020-12-22 12:39:32 +00:00
rubenwardy 1064885a2c Improve footer 2020-12-22 11:14:16 +00:00
rubenwardy 60362abef1 Improve claim user UX 2020-12-22 10:58:43 +00:00
rubenwardy d7d9131de8 Add quote styling 2020-12-17 20:10:46 +00:00
rubenwardy c44cc8082c Enable email notifications on claim login 2020-12-15 16:24:49 +00:00
rubenwardy 7a4335b8bc Improve form error messages 2020-12-15 12:56:17 +00:00
rubenwardy 8e3930d092 Fix failed login to unclaimed account 2020-12-15 12:29:30 +00:00
rubenwardy 5cbdaae5b3 Allow any maintainer/editor to set up GitHub webhooks 2020-12-14 21:22:11 +00:00
rubenwardy c7aecd32be Fix error creating releases from certain git references
Fixes #249
2020-12-14 21:05:56 +00:00
rubenwardy 4820d11ce3 Tweak homepage row limits 2020-12-14 11:52:38 +00:00
rubenwardy fc8cd3cfb8 Add top reviewed section to homepage 2020-12-14 11:48:26 +00:00
rubenwardy fc9b8c2a5a Fix ungraceful crash when registering taken username 2020-12-13 14:01:18 +00:00
rubenwardy 9ec91fc52d Fix 'Remember Me' 2020-12-10 23:36:56 +00:00
rubenwardy 2ae4a2ed5a Fix broken includes in user models 2020-12-10 23:08:53 +00:00
rubenwardy dfa5d0c5a7 Fix not being able to delete cover image 2020-12-10 23:07:13 +00:00
rubenwardy fc3a481e6f Fix profile page crash when not logged in 2020-12-10 22:43:02 +00:00
rubenwardy 5ab8c2f0f1 Fix metapackages crash due to missing lazy=dynamic 2020-12-10 22:42:53 +00:00
rubenwardy 5a0aa636f3 Add ability to change cover image
Fixes #125
2020-12-10 22:40:20 +00:00
rubenwardy fb1d33d27a Fix Package not using lazyloading for some relationships 2020-12-10 22:10:12 +00:00
rubenwardy 8d8577a941 Clean up database constraints 2020-12-10 22:10:12 +00:00
rubenwardy 70ac8fa6ab Convert models.py into package 2020-12-10 22:10:12 +00:00
rubenwardy 7088ffd321 Add ability for admin to hard delete packages 2020-12-10 22:10:12 +00:00
rubenwardy e175e489e8 Use input-group for forum topic 2020-12-10 22:10:12 +00:00
rubenwardy 7efdf5cfef Fix minor things 2020-12-10 22:10:12 +00:00
rubenwardy 5fb01f01bf Fix broken audit links for normal users 2020-12-10 22:10:12 +00:00
rubenwardy 333dd60b32 Add logging of log ins 2020-12-10 22:10:12 +00:00
rubenwardy 4433c32afc Add ability to filter audit log by user 2020-12-10 22:10:12 +00:00
rubenwardy d5190b0d76 Add audit log to account settings page 2020-12-10 22:10:12 +00:00
rubenwardy 58e1b924ca Add link to API docs in API Tokens page 2020-12-10 22:10:12 +00:00
rubenwardy ac7714b997 Add account page to settings 2020-12-10 22:10:12 +00:00
rubenwardy 778a602aa6 Add user deletion / deactivation 2020-12-10 22:10:12 +00:00
rubenwardy fd0b203f1e Add ability to delete threads
This reverts commit 78630b3071.
2020-12-10 22:10:12 +00:00
rubenwardy b28732ee74 Use explicit back references in Database 2020-12-10 22:10:12 +00:00
rubenwardy d8f33a4111 Add forum user redirect page 2020-12-07 20:11:40 +00:00
rubenwardy 396a620cf4 Fix visual glitch with discarded topics 2020-12-07 18:19:36 +00:00
rubenwardy f7b3f4573d Add celery task maillogging 2020-12-07 18:17:17 +00:00
rubenwardy 9ead6c1481 Fix broken tag input due to jQuery UI update 2020-12-07 10:10:52 +00:00
rubenwardy 55dc6460d2 Change default notification settings 2020-12-06 15:04:09 +00:00
rubenwardy 3aa12be544 Add daily notification digests 2020-12-06 15:02:02 +00:00
rubenwardy 35e1659b77 Fix crash on missing GitLab field 2020-12-06 04:54:32 +00:00
rubenwardy 2a9e52d36b Add note about thumbnails to screenshot page 2020-12-06 04:48:40 +00:00
rubenwardy 3f48905331 Add screenshot placeholder on package page 2020-12-06 04:41:58 +00:00
rubenwardy cf307e25d0 Add delete button to screenshot list 2020-12-06 04:30:47 +00:00
rubenwardy 4046c00a01 Improve edit/new screenshot appearance 2020-12-06 04:22:56 +00:00
rubenwardy 4226e945e6 Improve new screenshot behaviour 2020-12-06 04:08:31 +00:00
rubenwardy f93a2d8717 Add ability to reorder screenshots 2020-12-06 03:37:05 +00:00
rubenwardy 2910fcc1a4 Improve notification description 2020-12-06 01:23:18 +00:00
rubenwardy 8ff61b4517 Fix incorrect sort order in notifications 2020-12-05 23:40:03 +00:00
rubenwardy 4944463f56 Partition Editor notifications away from normal notifications in the notifications list 2020-12-05 23:09:29 +00:00
rubenwardy b3bd7ac615 Sort notifications in reverse order 2020-12-05 22:36:00 +00:00
rubenwardy 64a180ba8f Add reference validation
Fixes #158
2020-12-05 22:29:37 +00:00
rubenwardy 5a2ce15f96 Fix users being able to modify other user's email settings 2020-12-05 22:24:21 +00:00
rubenwardy f6f4fe4fc6 Fix Email settings tab changing user 2020-12-05 22:20:43 +00:00
rubenwardy a17260a4ee Add digest settings (despite not being implemented) 2020-12-05 22:03:05 +00:00
rubenwardy 4019e82f4a Add username-less redirect to email settings 2020-12-05 22:02:33 +00:00
rubenwardy 79230c1b0e Add bulk notification sending 2020-12-05 22:02:33 +00:00
rubenwardy da3175e7bd Add email changed email 2020-12-05 22:02:33 +00:00
rubenwardy d654113204 Remove email from user on unsubscribe 2020-12-05 22:02:33 +00:00
rubenwardy 6e3d32a9d5 Check for blacklisted emails in change email forms 2020-12-05 22:02:33 +00:00
rubenwardy e1d6c4f5f5 Reorder 'Settings' in user dropdown 2020-12-05 22:02:33 +00:00
rubenwardy 085f0b49c6 Add unsubscribe 2020-12-05 22:02:33 +00:00
rubenwardy 5fe3b0b459 Add email send reasons 2020-12-05 22:02:33 +00:00
rubenwardy 3efda30b98 Add ability to send email in bulk 2020-12-05 22:02:33 +00:00
rubenwardy 683b855584 Enable email notifications for new users 2020-12-05 22:02:33 +00:00
rubenwardy 9c10e190bc Implement email notifications 2020-12-05 22:02:33 +00:00
rubenwardy 19308b645b Add privacy policy 2020-12-05 22:02:33 +00:00
rubenwardy c46430c663 Add email to email tab, merge settings into settings.py file 2020-12-05 22:02:33 +00:00
rubenwardy d976269f1a Add notice to notification settings 2020-12-05 22:02:33 +00:00
rubenwardy c8e93a9f52 Add notification settings 2020-12-05 22:02:33 +00:00
rubenwardy d32bb30071 Add notification types 2020-12-05 22:02:33 +00:00
rubenwardy d5263acdf8 Fix switching between users in settings template 2020-12-05 03:16:09 +00:00
rubenwardy 8872ad33ad Tweak settings template 2020-12-05 03:12:33 +00:00
rubenwardy 7e29a621c3 Add missing login_required to profile edit 2020-12-05 02:42:44 +00:00
rubenwardy dfb216a8df Log sensitive account changes 2020-12-05 02:42:32 +00:00
rubenwardy f75bdec756 Add settings template 2020-12-05 02:20:21 +00:00
rubenwardy 0082870864 Add nav dropdown separators 2020-12-05 02:20:21 +00:00
rubenwardy d0e1a95d9c Add missing migration 2020-12-05 02:20:21 +00:00
rubenwardy f69fb47d69 Fix links missing icons in new profile 2020-12-05 02:20:21 +00:00
rubenwardy 4f52f82a15 Split profile into view and edit 2020-12-05 02:20:21 +00:00
rubenwardy 7c07ac22ad Add password suggestions to change and set password forms 2020-12-05 02:20:21 +00:00
rubenwardy afb87c525d Improve verify email wording 2020-12-05 02:20:21 +00:00
rubenwardy 9b0ce41fd7 Fix signature parsing 2020-12-05 02:20:21 +00:00
rubenwardy 5f7c0a3b24 Implement password resets 2020-12-05 02:20:21 +00:00
rubenwardy f7d90f2f53 Register: Fix behaviour on email conflict, add password suggestion 2020-12-05 02:20:21 +00:00
rubenwardy 43aab057c8 Implement change password 2020-12-05 02:20:21 +00:00
rubenwardy bfcdd642fd Use NULL for non-existant passwords 2020-12-05 02:20:21 +00:00
rubenwardy a8537659e2 Use correct mixin 2020-12-05 02:20:21 +00:00
rubenwardy 9620ceb842 Implement user registration and email confirmation 2020-12-05 02:20:21 +00:00
rubenwardy 5ef15e91d4 Remove flask_user and use flask_login directly, with partial feature support 2020-12-05 02:20:21 +00:00
rubenwardy 2358ed1b24 Remove WIP topic warning 2020-12-04 04:17:17 +00:00
rubenwardy af8d8c330d Add created_at to approval queue list 2020-12-04 03:59:02 +00:00
rubenwardy 14f643592c Fix user conflict on forum import 2020-12-04 03:14:04 +00:00
rubenwardy 8c5cdb630e Fix forum parser 2020-12-04 02:57:36 +00:00
rubenwardy b18903b59b Clean up JavaScript 2020-12-04 02:34:08 +00:00
rubenwardy 42f96618e2 Clean up code 2020-12-04 02:26:06 +00:00
rubenwardy 0c0d3e1715 Add setting SECRET_KEY to the Getting Started guide
Fixes #244
2020-12-04 02:00:05 +00:00
rubenwardy 2b06bca015 Set default git reference in create release form to None 2020-12-04 01:43:20 +00:00
rubenwardy 78630b3071 Revert "Add ability to delete threads"
This reverts commit 15821fe796.
2020-12-04 01:18:02 +00:00
rubenwardy 15063d92cd Require packages to have all hard deps in approval process 2020-12-04 01:00:57 +00:00
rubenwardy 15821fe796 Add ability to delete threads 2020-12-04 00:01:21 +00:00
rubenwardy 7d558ad7a2 Add pagination to audit log 2020-12-03 23:43:05 +00:00
rubenwardy 4242898e5d Add pagination to reviews 2020-12-03 23:41:11 +00:00
rubenwardy d24f024cca Move screenshots to top of approval queue page 2020-12-03 23:36:24 +00:00
rubenwardy ff93be7a89 Validate forum usernames in the claim form 2020-12-03 23:31:01 +00:00
rubenwardy a47d222a47 Ignore IDEA files 2020-12-03 23:09:40 +00:00
rubenwardy 9f62c251f2 Fix error emails not preserving whitespace 2020-12-03 23:09:17 +00:00
rubenwardy aff20f1a6d Remove error on game missing hard deps
Fixes #241
2020-12-03 21:08:20 +00:00
rubenwardy 6841a295ff Use contextlib to safely delete dirs in importtasks 2020-12-03 21:07:42 +00:00
rubenwardy 7a584e1a6e Fix failing UI test 2020-12-03 20:40:50 +00:00
rubenwardy 00be054135 Fix crash in GitLab webhook 2020-12-03 20:40:50 +00:00
dependabot[bot] 6eb4a803fd
Bump cryptography from 2.9.2 to 3.2 (#242)
Bumps [cryptography](https://github.com/pyca/cryptography) from 2.9.2 to 3.2.
- [Release notes](https://github.com/pyca/cryptography/releases)
- [Changelog](https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/2.9.2...3.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-27 22:55:53 +00:00
rubenwardy 6503a82094 Fix crash on email templating 2020-10-19 15:30:45 +01:00
rubenwardy 31f52580c2 Change open package approval thread message 2020-09-23 19:17:06 +01:00
rubenwardy 2aa0c3cc84 Fix texture pack license not present issue
Fixes #236
2020-09-22 21:53:04 +01:00
rubenwardy a3b3525b78 Add work queue icon to navigation bar 2020-09-19 19:30:33 +01:00
rubenwardy d76f10c312 Improve documentation 2020-09-16 23:32:39 +01:00
rubenwardy a1e0e37223 Fix broken state comparison due to enum ordering 2020-09-16 22:19:14 +01:00
rubenwardy 9a1c1c56e6 Allow admin to make a package WIP 2020-09-16 22:11:47 +01:00
rubenwardy 3a5fe25e12 Fix migration 2020-09-16 22:09:00 +01:00
rubenwardy f56b6021d8 Fix crash on missing PackageState 2020-09-16 22:03:36 +01:00
rubenwardy 380c88b5a3 Improve release approval section appearance 2020-09-16 21:55:52 +01:00
rubenwardy dd1288dc3c Sort notifications by date 2020-09-16 18:16:41 +01:00
rubenwardy 258a23cd9a Allow all users to delete their packages 2020-09-16 18:12:53 +01:00
rubenwardy 92fb54556a Implement package states for easier reviews 2020-09-16 17:51:03 +01:00
rubenwardy e81eb9c8d5 Fix crash on no signature 2020-09-15 15:48:03 +01:00
rubenwardy 8ec4006cc7 Disable email in default config 2020-09-01 15:26:10 +01:00
rubenwardy b3fdb991d6 Update README.md 2020-09-01 15:05:31 +01:00
rubenwardy 5b086bb559 Fix migration error when migrating from scratch 2020-09-01 14:57:03 +01:00
rubenwardy 934d581737 Fix screenshot form not validating length 2020-08-19 13:06:21 +01:00
rubenwardy e85d1755f0 Increase thread/comment ratelimiting based on rank 2020-08-18 18:10:42 +01:00
rubenwardy 1c4fe1b80c Fix reimport not unapproving releases 2020-08-18 17:39:20 +01:00
rubenwardy f6ff5cba82 Add unfulfilled dependencies todo page 2020-08-18 17:28:42 +01:00
rubenwardy 193e4e39b1 Split hard and soft dependers on meta package page 2020-08-18 17:13:37 +01:00
rubenwardy ab7d5a3feb Show optional dependencies on games 2020-08-18 17:09:13 +01:00
rubenwardy 2279208b00 Check for game hard dependencies 2020-08-18 17:08:17 +01:00
rubenwardy a8e1863341 Fix bug in meta package counting 2020-08-18 16:54:05 +01:00
rubenwardy 506974a50d Add forum topic list to meta packages page 2020-08-18 16:42:33 +01:00
rubenwardy 996ba82663 Add list of dependers to meta package page
Fixes #229
2020-08-18 16:29:51 +01:00
rubenwardy 68524adadf Remove provides/dependencies from Package form 2020-08-18 16:14:47 +01:00
rubenwardy b8ee612b45 Update meta on release import 2020-08-18 16:12:27 +01:00
rubenwardy 5db633d911 Add ability to delete unused metapackages 2020-08-18 14:22:16 +01:00
rubenwardy 2f208d9239 Add regex checking for dependency names 2020-08-18 13:57:56 +01:00
rubenwardy 0c81d0ae2b Improve MinetestCheck name validation 2020-08-18 13:34:04 +01:00
rubenwardy 6167bdc7f0 Remove empty dependencies in MinetestCheck 2020-08-18 11:43:13 +01:00
rubenwardy b50a306e66 Print updateMetaFromRelease info 2020-08-18 00:41:37 +01:00
rubenwardy 0b06cfffba Fix reimport packages not importing unapproved package 2020-08-18 00:28:27 +01:00
rubenwardy 85551539f0 Fix incorrect game names detected by MinetestCheck 2020-08-18 00:25:13 +01:00
rubenwardy 3914659718 Fix dependencies still being added if in provides
Fixes #226
2020-08-18 00:16:03 +01:00
rubenwardy 8fd229b739 Fix crash on null user agent 2020-08-16 13:13:25 +01:00
rubenwardy d69da8e3ea Redirect to correct URL when _game is missing from package name 2020-08-02 18:03:44 +01:00
rubenwardy 9a64809542 Add badges/shields support 2020-08-02 17:41:06 +01:00
rubenwardy ce034fddd4 Add downloads to package JSON 2020-08-02 17:10:47 +01:00
rubenwardy e931d6a88b Fix comment length checking 2020-07-29 17:33:12 +01:00
rubenwardy a8a3067ac9 Fix validation error in release form on incomplete URL 2020-07-18 15:42:49 +01:00
rubenwardy 64dab0c4b6 Filter tags by available packages in package search 2020-07-18 03:14:56 +01:00
rubenwardy dd7146205a Add description title tooltips to tags 2020-07-18 02:54:40 +01:00
rubenwardy 68a132f271 Add tags list to homepage 2020-07-18 02:48:22 +01:00
rubenwardy c7b1dcec4f Sort "recently added" by approved_at 2020-07-18 01:48:37 +01:00
rubenwardy 7d0a93483a Reorder homepage sections 2020-07-18 01:27:23 +01:00
rubenwardy 836caf0fe0 Add last updated section to homepage 2020-07-18 01:24:23 +01:00
rubenwardy 980e1c9eb1 Add ability to search admin tag list by views 2020-07-17 23:17:25 +01:00
rubenwardy e2a9ea91cf Fix descriptions being required in warning and tag editors 2020-07-17 22:29:02 +01:00
rubenwardy 2a7318eca2 Add descriptions to tags, and show in multiselect 2020-07-17 22:08:34 +01:00
rubenwardy b067fd2e77 Add support for filtering content warnings 2020-07-17 21:18:27 +01:00
rubenwardy 6a674c3c79 Add Content Warnings 2020-07-17 20:48:51 +01:00
rubenwardy 0ac2827468 Fix crash on bad wtforms validator instace 2020-07-17 20:07:51 +01:00
rubenwardy 054dfa4cbd Fix wrong character limit on review form 2020-07-16 18:41:27 +01:00
rubenwardy 74371d3fcb Check user-agent for crawlers before incrementing counters 2020-07-16 14:35:12 +01:00
rubenwardy 9d3ba8991d Add requirements lock file 2020-07-16 14:26:26 +01:00
rubenwardy 0e4722ea98 Add nofollow to tags 2020-07-16 14:07:21 +01:00
rubenwardy 208a47b41d Fix tag views redis cache 2020-07-16 13:52:18 +01:00
rubenwardy 7fb2f3170c Allow Editors to edit tags 2020-07-15 19:54:36 +01:00
rubenwardy 9663e87838 Count tag views 2020-07-15 19:06:00 +01:00
rubenwardy 8dd1cd9045 Increase comment length limit to 2000 2020-07-15 16:01:45 +01:00
rubenwardy 643380038b Fix broken ordering by reverting change to default 2020-07-15 15:44:59 +01:00
rubenwardy 27dfbabe2f Improve tags page layout and add link to profile 2020-07-15 00:54:26 +01:00
rubenwardy 15bbc35e65 Use query builder in tag list, add link to todo page 2020-07-15 00:21:20 +01:00
rubenwardy c9e4638b34 Add start of bulk tag editor 2020-07-14 23:45:54 +01:00
rubenwardy ff2cd6dc2f Fix dropdown menu alignment 2020-07-14 04:28:27 +01:00
rubenwardy aa6892da82 Add admin function to import foreign release URLs 2020-07-14 00:28:56 +01:00
rubenwardy 3fbc5f7751 Filter out packages with no releases in ContentDB 2020-07-13 02:10:59 +01:00
rubenwardy a57e06d09b Restrict seeing the email addresses of others to admins only 2020-07-13 00:34:05 +01:00
rubenwardy bbc89bb2c2 Fix misattribution of review due to missing reply ordering 2020-07-12 23:53:20 +01:00
rubenwardy ab58570a0c Redesign user list 2020-07-12 21:02:50 +01:00
rubenwardy cd520a0251 Redesign version edit page 2020-07-12 20:36:32 +01:00
rubenwardy 8bcf12e1a7 Redesign tags and license edit pages 2020-07-12 20:34:16 +01:00
rubenwardy ec087e4687 Move tag list to top of package list page 2020-07-12 20:19:00 +01:00
rubenwardy ae4352068e Add tag filter list to package page 2020-07-12 20:10:19 +01:00
rubenwardy 2faa0e4219 Fix query sorting further 2020-07-12 17:56:06 +01:00
rubenwardy 2e3a9035c4 Fix pagination widget syntax error 2020-07-12 17:52:30 +01:00
rubenwardy 2e6f99d09e Fix fulltext search order being overriden 2020-07-12 17:52:15 +01:00
rubenwardy f437850a50 Add global url_set_query Jinja template function 2020-07-12 17:15:30 +01:00
rubenwardy 820c968f73 Replace "Content DB" with "ContentDB" 2020-07-12 16:34:25 +01:00
rubenwardy 9d1f098d8a Fix clear notifications creating null user_ids 2020-07-12 16:33:17 +01:00
rubenwardy d7ecf8041a Improve admin list design 2020-07-12 03:47:59 +01:00
rubenwardy a123f42291 Fix incorrect mod name fold in MinetestCheck 2020-07-12 03:09:01 +01:00
rubenwardy e6a7df6144 Fix migration misordering 2020-07-12 02:45:19 +01:00
rubenwardy 4bd9411d87 Add check constraint on MetaPackage name 2020-07-12 02:43:51 +01:00
rubenwardy 284683e7e5 Add reimport of package meta from latest release
Fixes #127
2020-07-12 02:22:35 +01:00
rubenwardy 868ced76a8 Fix bugs related to package owner not being a maintainer 2020-07-11 16:56:36 +01:00
rubenwardy 729241c0fe Remove full review form from package page 2020-07-11 04:41:47 +01:00
rubenwardy 8d48723158 Swap edit and delete buttons in comments 2020-07-11 04:29:59 +01:00
rubenwardy 2fb2f1ae49 Remove admin from being able to edit any comment 2020-07-11 04:26:50 +01:00
rubenwardy d5b8dd8909 Add title text to audit log severity icons 2020-07-11 04:14:57 +01:00
rubenwardy dfbcbbbb47 Add ability to edit comments 2020-07-11 03:53:03 +01:00
rubenwardy 08f6bd8bef Move DELETE_REPLY permission to ThreadReply 2020-07-11 03:35:14 +01:00
rubenwardy 31b8a7931b Add ability for moderators to delete comments 2020-07-11 03:29:38 +01:00
rubenwardy a4dd4f0429 Add audit log 2020-07-11 02:32:17 +01:00
rubenwardy bf927c50f0 Add the ability to lock threads 2020-07-11 01:42:47 +01:00
rubenwardy 5f7be4b433 Add package and created_at to Notifications 2020-07-11 00:53:03 +01:00
rubenwardy 9bf20df941 Fix typo in previous commit 2020-07-11 00:06:21 +01:00
rubenwardy adc31962c0 Merge padlock symbol with icons in thread lists 2020-07-11 00:05:44 +01:00
rubenwardy 0e9b8a1a82 Fix title wrapping in /threads/ 2020-07-11 00:01:03 +01:00
rubenwardy 6150447c85 Fix bootstrap toggle button not matching backing radio button 2020-07-10 23:55:46 +01:00
rubenwardy dd86fb0e14 Fix profile picture alignment in notifications page 2020-07-10 23:50:02 +01:00
rubenwardy b483d5413f Add badge to notification icon 2020-07-10 23:46:36 +01:00
rubenwardy c80ff2e709 Fix empty view in thread lists 2020-07-10 23:18:40 +01:00
rubenwardy 2181e57e42 Redesign notifications page 2020-07-10 23:12:15 +01:00
rubenwardy c490df7f50 Add ability for moderators to change linked accounts 2020-07-10 22:59:41 +01:00
rubenwardy b9e1be57e4 Fix generation of forum profile URLs
Fixes #196
2020-07-10 22:44:58 +01:00
rubenwardy c3d96c7459 Add more sort options to package API, correct documentation
Fixes #204
2020-07-10 22:32:54 +01:00
rubenwardy b9386d5a47 Use middleware to clear notifications
Fixes #70
2020-07-10 22:23:52 +01:00
rubenwardy 1d8abd8f4b Fix screenshot approval checkbox always being unchecked
Fixes #212
2020-07-10 22:19:47 +01:00
rubenwardy 0bf61dda08 Fix ellipsis in pagination 2020-07-10 22:11:34 +01:00
rubenwardy 660b813ff7 Fix pagination losing query arguments
Fixes #205
2020-07-10 22:08:52 +01:00
rubenwardy ba3b108239 Fix tag selector losing all tags on remove
Fixes #148
2020-07-10 21:27:41 +01:00
rubenwardy 42b08f9bcd Fix tags being lost on Edit Package
Fixes #211
2020-07-10 21:02:40 +01:00
rubenwardy 849cdd257d Ignore FileExistsError in thumbnails 2020-07-10 20:50:25 +01:00
rubenwardy 16b174d882 Improve recommends styling on review edit form 2020-07-10 20:47:03 +01:00
rubenwardy 61e2c8a1c0 Remove accidental limit of 5 reviews on /reviews/ 2020-07-10 20:33:47 +01:00
rubenwardy c7a7609763 Add /reviews/ to list all reviews 2020-07-10 20:31:29 +01:00
rubenwardy 13130a217c Use FontAwesome for navbar icons 2020-07-10 20:23:19 +01:00
rubenwardy daa2d2989e Clean up view package button on reviews 2020-07-10 20:16:06 +01:00
rubenwardy ee6de95a52 Allow editors to unapprove and delete packages 2020-07-10 20:13:48 +01:00
rubenwardy 1daf59b7db Improve thread list design further 2020-07-10 20:10:51 +01:00
rubenwardy 94e91e33b8 Fix view thread page title 2020-07-10 19:46:23 +01:00
rubenwardy d91f537bdd Improve thread list style 2020-07-10 19:46:14 +01:00
rubenwardy 436a4cce2b Add ability to delete reviews 2020-07-10 19:26:37 +01:00
rubenwardy 71f9fe469a Change comments button color if there are comments 2020-07-10 19:14:23 +01:00
rubenwardy 76b0c8446c Hide review form on own package 2020-07-10 19:12:08 +01:00
rubenwardy 069c7de78c Add reviews to user profile 2020-07-10 19:10:36 +01:00
rubenwardy 3eeaf3be22 Hide reviews from package thread list 2020-07-10 19:06:27 +01:00
rubenwardy 1989eabf86 Add more obvious edit buttons for reviews 2020-07-10 19:01:58 +01:00
rubenwardy 491f9ed679 Fix GitHub claim method being broken by phpBB update 2020-07-10 18:41:08 +01:00
rubenwardy 000259fc88 Fix crash on sending notification 2020-07-09 05:54:39 +01:00
rubenwardy 078765fe44 Prevent users from reviewing their own packages 2020-07-09 05:47:26 +01:00
rubenwardy 45877bb3a4 Fix missing import 2020-07-09 05:45:46 +01:00
rubenwardy eb3d067e26 Fix crash on addNotification non-iterable 2020-07-09 05:45:04 +01:00
rubenwardy db80c441ec Fix crash when guests view package page 2020-07-09 05:34:55 +01:00
rubenwardy 849b814034 Fix margin above CDB stats on homepage 2020-07-09 05:34:25 +01:00
rubenwardy 37a4dbe66b Add distinction between review buttons 2020-07-09 05:31:41 +01:00
rubenwardy 75ab56cad1 Add recent positive reviews to homepage 2020-07-09 05:30:13 +01:00
rubenwardy 25b481ac0a Add package title and link to review page 2020-07-09 05:01:18 +01:00
rubenwardy 893507691b Show "Edit Review" button when a user already has a review 2020-07-09 04:50:49 +01:00
rubenwardy ac7adde4b1 Add score bonus to reviews 2020-07-09 04:32:13 +01:00
rubenwardy d0aecd0ee5 Rename triggerNotif to addNotification, add array support 2020-07-09 04:16:45 +01:00
rubenwardy 307b8f8dde Add reviews
Fixes #173
2020-07-09 04:10:09 +01:00
rubenwardy 9d033acfff Separate rolling average downloads from score 2020-07-09 01:26:01 +01:00
rubenwardy 2617c53abf Add downloads column to Package
Fixes #200
2020-07-09 01:11:50 +01:00
rubenwardy bbf1143090 Fix incorrect link in maintainers list 2020-07-09 00:02:56 +01:00
rubenwardy 2a37608cb0 Remove package author from maintainers edit field 2020-07-08 23:58:53 +01:00
rubenwardy 3dd5e7445e Fix check when showing remove myself from maintainers 2020-07-08 23:44:13 +01:00
rubenwardy 8dcbcd8b62 Add ability for users to remove themselves as maintainers 2020-07-08 23:42:30 +01:00
rubenwardy d00428eb7e Add info about maintainers to edit maintainers page 2020-07-08 23:28:30 +01:00
rubenwardy 0e2ea27f54 Add notifications for editing maintainers 2020-07-08 23:20:29 +01:00
rubenwardy b2809ed12e Fix maintainers field requiring lowercase names 2020-07-08 23:00:45 +01:00
rubenwardy a72b9a174a Add support for package maintainers
Fixes #159
2020-07-08 22:45:24 +01:00
rubenwardy ecb3d83c57 Fix FileNotFoundError on missing thumbnail source 2020-06-25 14:58:09 +01:00
rubenwardy 2cfb59d042 Return dictionary of package to deps in API 2020-06-05 16:09:27 +01:00
rubenwardy 4c3063cadf Fix typo 2020-06-05 04:48:53 +01:00
rubenwardy 66885fedaa Fix bugs, and document 2020-06-05 04:47:50 +01:00
rubenwardy 064eb9df04 Add ability to not include optional deps in deps API 2020-06-05 04:44:39 +01:00
rubenwardy c3cef1eed6 Return package IDs only in dependency API 2020-06-05 04:29:52 +01:00
rubenwardy ba8c4d3d24 Fix crash in MinetestRelease.get() 2020-06-04 20:40:56 +01:00
rubenwardy c99a2a554b Fix typos 2020-06-03 18:28:08 +01:00
rubenwardy 749e7c6cd0 Add Package Config help page 2020-06-03 18:22:23 +01:00
rubenwardy 4d29087431 Fix crash on missing x_minetest_version meta 2020-06-03 17:53:03 +01:00
rubenwardy 183b769ee2 Add support for setting min/max minetest versions in conf 2020-06-03 17:46:59 +01:00
rubenwardy 14cf3912f0 Add support for getting MinetestRelease using engine_version 2020-06-03 17:33:33 +01:00
rubenwardy 720457e876 Add helper link to API tokens page 2020-06-03 16:41:06 +01:00
rubenwardy 27d004d299 Fix bad URL construction in GitLab webhooks 2020-06-03 16:40:52 +01:00
rubenwardy 7f650a619e Fix webhook issues, make repo URLs matched case insensitive 2020-06-03 16:32:39 +01:00
rubenwardy d7977dec84 Fix broken link to content flags in API help page 2020-05-31 21:29:55 +01:00
rubenwardy 99a8f3d5d6 Fix broken release auto-approval due to permissions check 2020-05-31 16:06:04 +01:00
rubenwardy c1b4256d44 Add delete unused uploads admin function 2020-05-30 16:48:37 +01:00
rubenwardy ed78a2e06f Remove non-free score penalisation 2020-05-30 15:32:50 +01:00
rubenwardy 55a90e0464 Update to make the non-free policy clearer 2020-05-29 16:09:19 +01:00
rubenwardy fb78136870 Fix bullet points in non-free page 2020-05-29 15:56:07 +01:00
rubenwardy b477556698 Add help page on non-free licenses 2020-05-29 15:52:30 +01:00
rubenwardy fc5cca9def Fix display_name rather than username being used in API 2020-05-27 17:47:31 +01:00
rubenwardy dc455bcd87 Fix wrong API error response 2020-05-27 17:47:20 +01:00
TumeniNodes bda82d2792
Fix spelling error (#165) 2020-05-23 17:02:01 +01:00
rubenwardy a36e233051 Fix API auth crash and add more error messages 2020-05-19 17:24:57 +01:00
rubenwardy 8484c0f0aa Fix minor security vulnerability 2020-05-19 16:46:47 +01:00
rubenwardy ffb5b49521 Fix crash on invalid protocol_version 2020-05-19 16:39:39 +01:00
rubenwardy c15dd183a0 Update top packages 2020-04-30 22:41:55 +01:00
rubenwardy 0eca2d49ba Add celery exporter 2020-04-24 00:49:40 +01:00
rubenwardy 57e7cbfd09 Make OpenGraph URLs absolute 2020-04-23 23:51:10 +01:00
Lars Mueller e94bd9b845 Add meta to package view pages 2020-04-23 23:51:03 +01:00
rubenwardy 05bf8e3b3d Add prometheus support 2020-04-23 23:30:37 +01:00
rubenwardy 3992b19be3 Optimise SQL queries 2020-04-21 20:35:05 +01:00
rubenwardy a678a61c23 Correct documentation on users allowed to use webhooks 2020-04-21 19:27:34 +01:00
rubenwardy b5ce0a786a Improve legibility of textual content 2020-04-21 19:18:06 +01:00
rubenwardy d58579d308 Document top packages algorithm 2020-04-21 18:26:03 +01:00
rubenwardy 0620c3e00f Add API to see scores 2020-04-21 18:15:13 +01:00
rubenwardy a8374ec779 Allow all members to approve own releases 2020-04-21 17:07:04 +01:00
David Leal 24090235d1
Add build status badge on README.md (#194) 2020-04-20 23:01:59 +01:00
rubenwardy bbaa687aa7 Format exception emails better 2020-04-14 14:45:06 +01:00
rubenwardy dadfe72b48 Improve user authentication error handling 2020-04-14 14:39:59 +01:00
rubenwardy 9cc3eba009 Fix email sign up 2020-04-11 17:56:35 +01:00
rubenwardy 54a636d79e Fix access token not being shown after creation
Fixes #190
2020-04-11 17:45:25 +01:00
rubenwardy 0087c1ef9d Allow unlimited API tokens in GitHub webhooks 2020-04-11 15:24:44 +01:00
rubenwardy 39881e0d04 Improve error messages 2020-04-11 14:51:10 +01:00
rubenwardy 39a09c5d92 Add ability to search by tag 2020-04-07 18:23:06 +01:00
rubenwardy 663a9ba07b Fix exposing abs_url_for to templates 2020-03-28 19:01:39 +00:00
rubenwardy 144ae69f5c Fix case-insensitive comparison bug 2020-03-28 18:15:15 +00:00
rubenwardy 3e07bed51b Add ability to search packages by author 2020-03-28 18:13:03 +00:00
rubenwardy 9de219fd80 Increase package name and title length limits in form validation 2020-03-27 15:30:08 +00:00
rubenwardy 4a25435f7a Fix release validation for repos with submodules 2020-03-27 15:23:18 +00:00
rubenwardy b0f32affcb Fix scores not degrading due to missing session.commit() 2020-03-22 19:47:52 +00:00
rubenwardy 99548ea65f Fix licenses being prefilled in package editor 2020-02-23 20:40:14 +00:00
rubenwardy 325ee02b49 Fix lack of download counter checks on non-release package download 2020-02-23 20:14:56 +00:00
rubenwardy a60786d32c Fix non-admin users not being able to set profile URLs 2020-02-23 20:12:32 +00:00
rubenwardy 2976afd5d1 Update git-archive-all 2020-02-15 15:23:43 +00:00
rubenwardy 744c52ba18 Add links to GitHub oauth connection settings 2020-01-30 21:39:51 +00:00
rubenwardy c31c1fd92a Change API Token warning to be friendlier 2020-01-30 21:01:50 +00:00
rubenwardy 36615ef656 Fix access token being exposed after APIToken edit 2020-01-25 18:26:55 +00:00
rubenwardy 53a5dffb26 Rename 'new tag' event to contain 'GitHub release' 2020-01-25 17:25:05 +00:00
rubenwardy 74f3a77a84 Fix 404 on GituHub log in 2020-01-25 17:23:14 +00:00
rubenwardy a15f1ac223 Fix crash on existing GitHub App Integration 2020-01-25 03:09:59 +00:00
rubenwardy 19a626e237 Fix auto-webhook creation failure due to wrong scheme 2020-01-25 03:03:45 +00:00
rubenwardy 43c2ee6b7b Improve documentation 2020-01-25 02:36:10 +00:00
rubenwardy b1555bfcd5 Fix git-created release regression 2020-01-25 02:36:10 +00:00
rubenwardy d5541791b6 Prevent multiple webhooks from being created 2020-01-25 02:36:10 +00:00
rubenwardy 62b1cae0ab Fix automatic creation of tag webhooks 2020-01-25 01:35:05 +00:00
rubenwardy 933a23c9c7 Promote webhooks in release creation page 2020-01-25 01:29:55 +00:00
rubenwardy f2799349ab Add tag push support to webhooks 2020-01-25 01:14:01 +00:00
rubenwardy 1d223cc16f Use git init/fetch instead of git clone 2020-01-25 01:07:39 +00:00
rubenwardy b7101a403b Add GitLab webhook support 2020-01-25 00:44:46 +00:00
rubenwardy 493917d8b1 Restrict webhooks to trusted users 2020-01-25 00:04:56 +00:00
rubenwardy e12aec4ccd Add automatic GitHub webhook creation 2020-01-24 23:19:06 +00:00
rubenwardy d4936e18ee Add Github webhook support 2020-01-24 21:39:35 +00:00
rubenwardy beb9c0e959 Rename 'vcs' release-creation mode to 'git' 2020-01-24 20:27:37 +00:00
rubenwardy 14faae3fd1 Add API to create releases 2020-01-24 20:21:40 +00:00
rubenwardy 6f1472addb Add ability to limit APITokens to a package 2020-01-24 19:26:00 +00:00
rubenwardy 2fa2c3afec Fix broken user registration
Fixes #181
2020-01-24 18:29:02 +00:00
rubenwardy 6e938ba74c Split up users blueprint 2020-01-24 18:15:12 +00:00
rubenwardy 53a63367dc Fix wtforms validatoion of package name 2020-01-24 18:03:40 +00:00
rubenwardy ddf5c7f665 Show discarded topics in package results 2020-01-23 23:56:10 +00:00
rubenwardy 4e331c7f14 Fix deprecation warnings 2020-01-23 00:19:04 +00:00
rubenwardy 5e60cb83de Add XSS strings to test data 2020-01-22 23:45:40 +00:00
rubenwardy 595d6ea3b6 Use server-side markdown renderer in WYSIWYG preview
Fixes #117
2020-01-22 23:10:06 +00:00
rubenwardy 71fa62fd6a Update EasyMDE 2020-01-22 22:47:24 +00:00
rubenwardy be5bb11fe3 Add data* to docker ignore 2020-01-22 22:11:01 +00:00
rubenwardy 981ae74e5c Improve markdown escaping
Fixes #118
2020-01-22 22:10:02 +00:00
rubenwardy 2b66193969 Fix version being styled differently 2020-01-21 23:42:41 +00:00
rubenwardy ed304f7687 Sort dependencies in package editor 2020-01-21 23:05:34 +00:00
rubenwardy 7ac7af4774 Sort dependencies in package page 2020-01-21 23:02:12 +00:00
rubenwardy 5fa0a7866a Sort provides relationship 2020-01-21 23:02:05 +00:00
rubenwardy f24148d431
Improve package page styling 2020-01-21 22:40:51 +00:00
rubenwardy 980023a80c Improve package buttons contrast 2020-01-20 20:56:56 +00:00
rubenwardy b68a1d7ab9 Reduce chance of accidental release deletion 2020-01-19 20:16:03 +00:00
rubenwardy 2ef90902aa Fix approved checkbox deselection bug 2020-01-19 20:08:58 +00:00
rubenwardy e115b0678c Fix password issues caused by Flask-User migration 2020-01-19 19:48:41 +00:00
rubenwardy 0bda16de6d Add API tests 2020-01-19 19:09:04 +00:00
rubenwardy fd6ba459f9 Add Gitlab CI support 2020-01-19 18:15:18 +00:00
rubenwardy d503908a65 Add populated homepage test 2020-01-19 15:46:29 +00:00
rubenwardy 215839c423 Add end-to-end test framework 2020-01-19 15:03:38 +00:00
rubenwardy 783bc86aaf Update dependencies 2020-01-19 02:46:07 +00:00
rubenwardy 6e626c0f89 Add admin option to check all releases 2020-01-19 02:20:20 +00:00
rubenwardy facdd35b11 Add validation to zip releases 2020-01-19 01:37:15 +00:00
rubenwardy ec8a88a7a8 Allow deleting releases with broken tasks 2020-01-19 01:23:56 +00:00
rubenwardy 1b1c94ffa0 Add release contents validation 2020-01-19 01:22:33 +00:00
rubenwardy bcd003685e Add support for submodules in makeVCSRelease() 2020-01-19 00:28:26 +00:00
rubenwardy 59039a14a5 Add ability to delete releases 2020-01-19 00:02:37 +00:00
rubenwardy 0d6e217405 Fix missing name in search weightings 2020-01-18 23:20:49 +00:00
rubenwardy 64e1805b53 Add more util scripts 2020-01-18 23:20:34 +00:00
rubenwardy 22d02edbd8 Add constraint for release tasks and approval 2020-01-18 23:10:11 +00:00
rubenwardy 5a496f6858 Fix broken search weighting
Fixes #176
2020-01-18 17:54:46 +00:00
rubenwardy f4209d7a67 Add documentation for reload.sh and update.sh 2020-01-18 01:42:47 +00:00
rubenwardy 077bdeb01c Add reloading support to Docker container 2020-01-18 01:38:00 +00:00
rubenwardy 095494f96f Improve Docker configurations 2020-01-18 01:20:32 +00:00
rubenwardy 6f230ee4b2 Fix uploadPackageScores task 2020-01-18 01:16:33 +00:00
rubenwardy 311e0218af Fiddle with package button styling 2020-01-18 00:15:29 +00:00
rubenwardy 3fee369dc1 Fix crash on clearing all notifications 2019-12-17 20:49:59 +00:00
rubenwardy e57f2dfe7d Fix crash due to missing import 2019-11-27 01:16:59 +00:00
rubenwardy dd5de1787f Add database diagram 2019-11-27 01:06:58 +00:00
rubenwardy 62f1aecfaf Fix debug mode in entrypoint.sh 2019-11-27 01:06:58 +00:00
rubenwardy 4ce388c8aa Add API Token creation 2019-11-27 01:06:58 +00:00
rubenwardy cb5451fe5d Fix pkgtasks crash due to it not being imported 2019-11-22 01:16:17 +00:00
rubenwardy 5466a2d64d Rename run.sh to entrypoint.sh 2019-11-21 23:16:39 +00:00
rubenwardy 77f8a79c51 Add useful scripts 2019-11-21 22:27:38 +00:00
rubenwardy 33b2b38308
Improve package scoring 2019-11-21 22:16:35 +00:00
rubenwardy 94426e97aa Add support for randomly sorting queries 2019-11-21 21:43:58 +00:00
rubenwardy 5b68e494db Fix crash on accessing notifications 2019-11-21 19:50:54 +00:00
rubenwardy 39d4cf362b Fix url_for crash on "home_page" 2019-11-21 19:38:32 +00:00
rubenwardy b977a42738 Add celery beat and celery flower to docker compose 2019-11-18 22:44:37 +00:00
rubenwardy ff2a74367f Fix download forgery 2019-11-18 21:42:56 +00:00
rubenwardy 3f666d2302 Fix exception on badly-formed query string 2019-11-17 21:40:55 +00:00
rubenwardy a7d22973ff Fix user profile after blueprints commit 2019-11-16 00:05:59 +00:00
rubenwardy 20583784f5 Fix hardcoded progress bar in work queue, and related crash 2019-11-16 00:05:35 +00:00
rubenwardy 64f131ae27 Refactor endpoints to use blueprints instead 2019-11-15 23:51:42 +00:00
rubenwardy 015abe5a25 Indicate stuck releases in todo list and allow admins to delete them 2019-11-14 23:39:41 +00:00
rubenwardy 719a652235 Enable hot code reloading 2019-11-14 23:38:29 +00:00
rubenwardy 50892ce9fc Add debug warning to template 2019-11-14 23:38:11 +00:00
rubenwardy 2e14836ed6 Fix permission issues by not mounting source code 2019-11-14 23:02:36 +00:00
rubenwardy 35e1aba4ad Fix CDB running as root in docker container 2019-11-14 22:53:46 +00:00
rubenwardy 913537f96f Sort packages in approval queue by creation date 2019-11-14 22:43:05 +00:00
rubenwardy b36a60d3a2 Fix worker start command in docker-compose.yml 2019-11-14 22:42:49 +00:00
rubenwardy df247b021e Improve docker image and deployment scripts 2019-11-14 22:24:37 +00:00
rubenwardy 9f678d8fde Add issue templates 2019-11-12 22:49:47 +00:00
rubenwardy d89442438f Add security policy 2019-11-12 22:46:42 +00:00
rubenwardy 08a9ae7b94 Make review threads public by default 2019-11-12 22:39:17 +00:00
rubenwardy 904e09f0dd Create utils folder 2019-11-12 22:36:30 +00:00
Alex 038ef5b739 Specify excessive horror 2019-10-22 21:18:56 +01:00
TumeniNodes f8958ae1bc Fix error in thread privacy message 2019-10-22 21:17:08 +01:00
rubenwardy 03eccbd56a Fix text in emails 2019-09-15 18:43:22 +01:00
rubenwardy fb31ea3c22 Fix git clone breaking when branch is None 2019-09-15 18:30:42 +01:00
rubenwardy 4082863b5a Add basic dependency resolution 2019-09-03 00:42:51 +01:00
rubenwardy cc564af44e Fix broken reference based git import
Fixes #130
2019-08-31 22:09:19 +01:00
rubenwardy 655ed2255a Fix crash by truncating notification titles 2019-08-31 18:38:15 +01:00
rubenwardy 96b22744ec Fix crash on null release task_id 2019-08-31 18:32:59 +01:00
rubenwardy 130d0bc7a0 Fix wtfforms setting fields to empty string instead of None 2019-08-30 21:11:38 +01:00
rubenwardy 1469e37c38 Fix accidental limit on password length 2019-08-12 14:10:28 +01:00
rubenwardy 6ce495fcd3 Fix crash on reading mod.conf from Github 2019-08-09 11:27:54 +01:00
rubenwardy 776a3eff2a Fail gracefully when given a bad git reference 2019-08-09 11:25:19 +01:00
rubenwardy 04e8ae5bdd Fix unexpected crash on bad Github URL 2019-08-09 11:17:39 +01:00
rubenwardy 18b9fb3876 Fix typo in zip uploading 2019-08-09 11:10:45 +01:00
rubenwardy 1da86f27a7 Fix topic ID parse error in import topics task 2019-07-29 23:31:42 +01:00
rubenwardy 85340a2fe9 Add note about media license
Fixes #150
2019-07-29 22:48:05 +01:00
rubenwardy c4a4d9c116 Fix broken link on create thread
Fixes #147
2019-07-29 22:39:56 +01:00
rubenwardy 87a184595c Add file extension filters to file upload dialogs
Thanks to @b3u
2019-07-29 22:34:39 +01:00
rubenwardy b3b1e421f2 Check that uploaded images are valid images 2019-07-29 22:21:56 +01:00
rubenwardy 60483ef542 Add translation support 2019-07-29 21:44:39 +01:00
rubenwardy 3c8a8b8988 Fix name field always being readonly 2019-07-29 21:03:04 +01:00
rubenwardy 2f8bdd8f0f Increase CSS version 2019-07-29 20:41:48 +01:00
rubenwardy e87db8b87f Prevent users from changing the name of approved packages 2019-07-29 20:29:55 +01:00
rubenwardy b36273a848 Add website and donation support 2019-07-02 00:45:16 +01:00
Hugo Locurcio 7b087158d7 Optimize images losslessly using `oxipng -o6 --zopfli --strip` 2019-06-12 00:10:56 +01:00
rubenwardy 2fbc44bd54 Make user list public 2019-06-10 00:11:57 +01:00
rubenwardy 950512c2a7 Add favicon 2019-06-07 16:54:33 +01:00
rubenwardy f4010d498f Update policy and guidance 2019-04-23 01:30:17 +01:00
rubenwardy f04d4ff3cd Allow release auto-approval on unapproved packages 2019-03-30 15:42:31 +00:00
rubenwardy f8b290fc45 Add badges next to packages awaiting approval list 2019-03-30 15:41:38 +00:00
rubenwardy 7e4eb29db7 Limit releases on package view 2019-03-29 21:01:19 +00:00
rubenwardy 93a74b7681 Fix release auto-approval 2019-03-29 20:52:08 +00:00
rubenwardy 2677e088a8 Fix small style issue on todo page 2019-03-29 20:33:15 +00:00
rubenwardy 0fd4984e5a Redesign todo page, add ability to Approve All screenshots 2019-03-29 20:32:13 +00:00
rubenwardy 896a65fd99 Fix progress bar total 2019-03-29 20:02:10 +00:00
rubenwardy 885209a614 Add unified topic search in QueryBuilder 2019-03-29 19:48:21 +00:00
rubenwardy 4c109d6bd3 Fix release being null in API when release is unapproved
Fixes #129
2019-03-13 14:37:27 +00:00
rubenwardy 9c2c8c21f1 Add content flag support in the API 2019-02-03 13:03:30 +00:00
rubenwardy e40b247a97 Add OpenSearch and Google site search support 2019-02-02 17:05:18 +00:00
rubenwardy a79cc758ed Add placeholder content ratings page 2019-01-30 17:57:56 +00:00
rubenwardy bafd426eaf Add automatic approval of releases and screenshots 2019-01-29 18:30:30 +00:00
rubenwardy 36f9572cbb Fix replace problem in migration 2019-01-29 03:02:46 +00:00
rubenwardy 2586a11bcf Add fulltext search support 2019-01-29 03:00:01 +00:00
rubenwardy d36138d5e1 Add version information to package page 2019-01-29 02:03:10 +00:00
rubenwardy 7810bb54e0 Add download counter to home page 2019-01-29 01:43:21 +00:00
rubenwardy 2844773e4d Fix wrong release ID returned by API on explicit protocol version 2019-01-29 01:29:49 +00:00
rubenwardy 23c406bff9 Add download counting 2019-01-29 00:49:44 +00:00
rubenwardy 0f3adda592 Improve spacing on bulk change releases 2019-01-29 00:27:26 +00:00
rubenwardy 441ed3beeb Add option to only change None entries with bulk change releases 2019-01-29 00:24:59 +00:00
rubenwardy d1f5585fda Fix typos in text 2019-01-29 00:18:49 +00:00
rubenwardy 0fd3ed8f6b Fix bulk change form 2019-01-28 23:54:00 +00:00
rubenwardy 0e5c1f83ff Add MinetestRelease editor 2019-01-28 23:49:27 +00:00
rubenwardy f112756b04 Disable fields in bulk change on checkbox 2019-01-28 23:40:31 +00:00
rubenwardy f822027ec5 Hide create release fields depending on radio buttons 2019-01-28 23:17:00 +00:00
rubenwardy 034315d421 Add notes about min/max, and hide invalid options 2019-01-28 22:28:47 +00:00
rubenwardy 5cd8b35d1f Add ability to bulk change releases 2019-01-28 21:49:29 +00:00
rubenwardy 84b996c489 Add Minetest version checking to packages API 2019-01-28 21:33:50 +00:00
rubenwardy d77403c0be Add min and max Minetest version support 2019-01-28 20:48:07 +00:00
rubenwardy e9fe936aa9 Increase visibility of thread creation 2019-01-28 19:41:24 +00:00
rubenwardy 8afe17b984 Add comment ratelimiting, allow any member to open threads 2019-01-28 19:01:37 +00:00
rubenwardy 2691105513 Remove limit on provides field 2019-01-09 22:44:23 +00:00
rubenwardy 5f7efd4f31 Reduce README size 2019-01-09 22:35:11 +00:00
rubenwardy 7d52931a20 Add celery support to docker config 2019-01-09 22:29:32 +00:00
rubenwardy a45df0e173 Add Docker support 2019-01-09 21:58:11 +00:00
rubenwardy 0db49efe4a Fix weird ordering of screenshots 2019-01-08 21:35:46 +00:00
rubenwardy 9639cf04f1 Improve views subfoldering 2019-01-08 17:37:33 +00:00
rubenwardy 9866e43b4b Split up packages/__init__.py 2019-01-08 17:17:36 +00:00
rubenwardy 014370ea06 Add email template 2019-01-04 19:17:04 +00:00
rubenwardy fbf374ff5d Add manual email support 2019-01-04 17:57:00 +00:00
rubenwardy a68ac9cb4d Add number of packages to bottom of homepage 2018-12-31 14:01:19 +00:00
rubenwardy 7943598528 Improve package edit page layout 2018-12-29 19:06:17 +00:00
rubenwardy 4bc8b58af7 Remove limit on dependency size 2018-12-29 19:00:13 +00:00
rubenwardy ec0e89c21d Fix bug in package_create.js 2018-12-29 18:41:09 +00:00
rubenwardy 2975f94d9e Add short description tips 2018-12-29 15:57:16 +00:00
rubenwardy a9a045eefd Fix wizard deleting values from topic create 2018-12-28 14:32:11 +00:00
rubenwardy d09ede00fb Add sort toggle bar to topics list 2018-12-27 15:32:15 +00:00
rubenwardy 515248eb8b Add open link to forum topic ID field 2018-12-27 00:03:16 +00:00
rubenwardy 66ee706a6c Fix profile picture bugs 2018-12-25 23:02:49 +00:00
rubenwardy d44178cb0c Fix relative links again 2018-12-25 20:26:36 +00:00
rubenwardy c926a812d3 Fix relative links 2018-12-25 20:25:17 +00:00
rubenwardy 0b83d2f2b5 Add task to bulk import avatars from forum 2018-12-25 19:49:17 +00:00
rubenwardy 21960f2404 Add support for using forum profile pictures 2018-12-25 19:28:32 +00:00
rubenwardy f94885a58f Fix gravatar link being a button 2018-12-25 18:36:02 +00:00
rubenwardy f7d4b4bf6d Show placeholder message in unadded topics profile section when empty 2018-12-25 18:34:38 +00:00
rubenwardy d04e060854 Improve profile pic styling on user profile page 2018-12-25 18:25:25 +00:00
rubenwardy 7801be3d39 Fix create button show logic in topic list 2018-12-25 18:15:11 +00:00
rubenwardy b10660030a Fix params in topic list being lost on page change 2018-12-25 18:12:25 +00:00
rubenwardy f5744f5188 Fix non-editors seeing create buttons for topics 2018-12-25 17:58:44 +00:00
rubenwardy 272be09ba1 Add links to topic lists in user dropdown 2018-12-25 17:56:51 +00:00
rubenwardy 09150a4dbb Allow users to discard their own topics 2018-12-25 17:51:29 +00:00
rubenwardy c726f56b3e Fix bugs in topic todo 2018-12-25 16:43:41 +00:00
rubenwardy daded6d193 Add sort by option to topic list 2018-12-25 16:40:19 +00:00
rubenwardy b0a5980833 Add unlimited results toggle in topics list 2018-12-25 15:20:58 +00:00
rubenwardy 1eaed55bc6 Add ability to unapprove package from GUI 2018-12-25 15:13:30 +00:00
rubenwardy c2265313d8 Truncate long links in topic list 2018-12-24 00:37:52 +00:00
rubenwardy 49d5a123e5 Add progress bar to topics page 2018-12-24 00:27:55 +00:00
rubenwardy c79c970171 Fix .wiptopic affecting buttons 2018-12-24 00:13:45 +00:00
rubenwardy fa0506f58a Add create date to topic list 2018-12-24 00:11:15 +00:00
rubenwardy 50889ccca5 Add topic searching and topic discarding 2018-12-23 23:54:20 +00:00
rubenwardy b8ca5d24c5 Add pagination and search to topics 2018-12-23 18:04:56 +00:00
rubenwardy 63969529ad Add random feature 2018-12-23 17:34:44 +00:00
rubenwardy 08434300d8 Rename "I'm feeling lucky" to "First" 2018-12-23 17:11:52 +00:00
rubenwardy 86566bcd39 Improve markdown editor style, switch to EasyMDE, add to comment reply fields 2018-12-23 17:02:02 +00:00
rubenwardy a7fcce4448 Improve package grid style 2018-12-23 16:28:15 +00:00
rubenwardy 366ed9913e Update to Flask 1.0 2018-12-22 23:03:38 +00:00
rubenwardy 79f4e16286 Improve style of forms 2018-12-22 22:29:30 +00:00
rubenwardy 137a6928bc Replace Popular with Top Mods 2018-12-22 21:41:30 +00:00
rubenwardy de9135f44f Decrease package tile rounding 2018-12-22 21:26:00 +00:00
rubenwardy 31f57e1f12 Add multi-level thumbnails 2018-12-22 21:20:25 +00:00
rubenwardy 89cae279cd Add top games to home page 2018-12-22 21:13:56 +00:00
rubenwardy fd901726b0 Add sort and order query params to package list 2018-12-22 21:09:29 +00:00
rubenwardy 5f40d68441 Improve home page 2018-12-22 21:03:01 +00:00
rubenwardy 8eedbf64a4 Improve package grid 2018-12-22 20:49:19 +00:00
rubenwardy c551201f79 Improve thread styling 2018-12-22 20:25:22 +00:00
rubenwardy a21a5c24d8 Add pagination styling 2018-12-22 13:33:27 +00:00
rubenwardy 0a969e597b Fix flask_user template 2018-12-22 13:22:08 +00:00
rubenwardy a1700b5f7e Fix small issues 2018-12-22 13:17:10 +00:00
rubenwardy d61f77a805 Improve claim page 2018-12-22 13:14:08 +00:00
rubenwardy f6384e2e15
Merge minetest/bootstrap into master 2018-12-22 12:39:35 +00:00
rubenwardy 09a201759b Improve card and user profile formatting 2018-12-22 12:38:03 +00:00
rubenwardy 5dcff01436 Improve button colours and position in package view 2018-12-22 12:20:26 +00:00
rubenwardy f355721cdb Fix button style in policy alert 2018-12-22 12:10:34 +00:00
rubenwardy a25f77ce3c Allow pasting of forum URLs in input box 2018-12-22 12:08:21 +00:00
rubenwardy 692628653c Improve package creation form 2018-12-22 12:00:20 +00:00
rubenwardy 35f798c862 Improve button layouts 2018-12-21 20:59:12 +00:00
rubenwardy 3a0e0377f9 Improve button placement 2018-12-21 17:00:16 +00:00
rubenwardy c6a26786ec Improve package page style again 2018-12-21 16:55:22 +00:00
rubenwardy e5cb7a3721 Improve jumbotron 2018-12-21 16:36:54 +00:00
rubenwardy 03a155c17b Improve package page style further 2018-12-21 16:06:52 +00:00
rubenwardy 266d579e9d Move cards to sidebar 2018-12-21 16:00:18 +00:00
rubenwardy c97eefc7b2 Format package page 2018-12-21 15:58:43 +00:00
rubenwardy 9da6b45cc3 Add bootstrap, change base template 2018-12-21 14:45:54 +00:00
rubenwardy c9bf7a3245 Add skip button to importer 2018-12-21 14:10:46 +00:00
rubenwardy dd368d87aa Fix various issues 2018-12-21 14:02:57 +00:00
rubenwardy e5b279d013 Fix capitalisation in API 2018-11-25 13:21:24 +00:00
rubenwardy 8ca3437689 Revert "Add flask-admin"
This reverts commit dd6257a0a0.
2018-11-14 00:56:28 +00:00
ClobberXD aeafb8247f Fix grammar in jumbotron 2018-11-09 10:58:39 +00:00
rubenwardy 75bab28d82
Add celery beat for topic import 2018-10-09 21:49:26 +01:00
rubenwardy 328d05bdf6
Add option to hide non-free packages in API 2018-10-03 17:06:16 +01:00
rubenwardy 2229b32c90
Add SimpleMDE to edit markdown 2018-09-14 23:10:30 +01:00
rubenwardy ed409df323
Update scoring algorithm to take licenses and screenshots into account 2018-09-03 01:50:53 +01:00
rubenwardy b8decafd75
Add WTFPL warning on new packages 2018-09-03 01:40:48 +01:00
rubenwardy 5aaee010c1
Fix accidental regression in phpbbparser 2018-08-25 21:25:12 +01:00
rubenwardy a01fe4043e
Fix owner not seeing create link in 'more content' list 2018-08-25 19:12:42 +01:00
rubenwardy e0ef0e018d
Fix permissions check in 'more content' list 2018-08-25 19:10:11 +01:00
rubenwardy 0210a3e601
Add I'm feeling lucky 2018-08-25 18:50:05 +01:00
rubenwardy 36000b1592
Add list of relevant forum topics to last page of results 2018-08-25 18:20:45 +01:00
rubenwardy b296b9b299
Fix two bugs 2018-07-30 00:42:11 +01:00
rubenwardy dd6257a0a0
Add flask-admin 2018-07-30 00:16:22 +01:00
rubenwardy 23b324cc9c
Update policy: remote too much detail about name exceptions 2018-07-29 17:34:06 +01:00
rubenwardy f61f9e8654
Fix typo in template path for tags list 2018-07-28 19:20:49 +01:00
rubenwardy 286207ffa2
Add release specific download URL 2018-07-28 18:33:36 +01:00
rubenwardy a3e82ad42f
Add support for multiple types in packages list 2018-07-28 18:12:22 +01:00
rubenwardy 404200b8f0
Fix license editor setting is_foss to true on edit 2018-07-28 17:47:08 +01:00
rubenwardy dfecf470fa
Redirect to license list on save 2018-07-28 17:34:00 +01:00
rubenwardy c737f58fc0
Update policy 2018-07-28 17:31:27 +01:00
rubenwardy ab59b7f4ba
Prevent approval of packages with an 'Other' license 2018-07-28 17:30:43 +01:00
rubenwardy 514a24e2c4
Add license editor 2018-07-28 17:26:28 +01:00
rubenwardy 742a327cbb
Add warning on other license 2018-07-28 16:46:46 +01:00
rubenwardy 864e067412
Fix typo in running task link on edit release page 2018-07-28 16:06:23 +01:00
rubenwardy 1c7a192854
Add link to original screenshot in edit screenshot page 2018-07-28 16:05:09 +01:00
rubenwardy c298f64295
Fix thumbnails
Fixes #97
2018-07-28 16:03:48 +01:00
rubenwardy e82166f87e
Add subscribe/unsubscribe button 2018-07-28 15:30:59 +01:00
rubenwardy 909a2b4ce9
Add support for post-approval threads 2018-07-28 15:19:30 +01:00
rubenwardy df8d05f09d
Add thread list to package view 2018-07-28 15:08:08 +01:00
rubenwardy 8c3b1c8c95
Add commit hash to releases 2018-07-28 14:48:03 +01:00
rubenwardy ecdb755dd3
Remove unused release approval checklist 2018-07-28 14:29:40 +01:00
rubenwardy 901e115a21
Prevent trusted users from approving their own packages 2018-07-28 14:25:51 +01:00
rubenwardy d4c2166019
Add default title to screenshots 2018-07-28 14:13:26 +01:00
rubenwardy cbc98ef624
Enable markdown in comments 2018-07-28 14:07:29 +01:00
nOOb3167 794bc8a018
Add default password to admin user 2018-07-24 20:39:48 +01:00
nOOb3167 34900222dc
Add upper version limit to Flask requirement 2018-07-24 20:39:29 +01:00
rubenwardy f9a1d25c57
Fix unreadable dropdown text
Fixes #74
2018-07-24 20:33:26 +01:00
rubenwardy 8fe7bcfb71
Fix forum topic scanner only scanning one page 2018-07-24 20:11:48 +01:00
rubenwardy 28ee65809e
Fix 2 filter_by bugs
Fixes #101
2018-07-13 21:28:11 +01:00
rubenwardy 1b42f3310a
Add admin feature to bulk create releases 2018-07-08 17:28:39 +01:00
rubenwardy 8d2144895e
Fix creation of corrupt zip files
Fixes #103
2018-07-08 17:10:38 +01:00
rubenwardy 13837ce88b
Add forum topic validation 2018-07-07 00:28:27 +01:00
rubenwardy 73c65e3561
Add topics API 2018-07-07 00:01:56 +01:00
rubenwardy 67a229b8a3
Add WIP forum topic support 2018-07-06 23:17:56 +01:00
rubenwardy 9dd3570a52
Add email on Flask error 2018-07-06 22:55:55 +01:00
rubenwardy a6c8b12cdd
Reorder new and popular, change number of packages in each 2018-07-04 01:20:55 +01:00
rubenwardy 7813c766ac
Add package scores and split homepage into new and popular 2018-07-04 01:08:34 +01:00
rubenwardy 9fc9826d30
Clarify home page on subject of free software 2018-07-04 00:42:46 +01:00
rubenwardy 19e1ed8b32
Implement forum parser to increase accuracy 2018-07-04 00:38:51 +01:00
cx384 eb6b1d6375 Fix wrong section reference in inclusion policy 2018-06-20 16:37:53 +01:00
rubenwardy 8c6d352d07
Fix crash on packages page 2018-06-15 23:44:13 +01:00
rubenwardy cfa7654efc
Move API to dedicated file, and reduce download size 2018-06-15 22:57:43 +01:00
rubenwardy 87af23248e
Fix package owners not being able to see review threads 2018-06-12 22:20:06 +01:00
rubenwardy ba08becd3a
Fix migration error 2018-06-11 23:42:20 +01:00
rubenwardy 68b7a5e922
Add thread watchers 2018-06-11 23:39:41 +01:00
rubenwardy e8cc685f89
Prevent new threads being created on approved packages 2018-06-11 23:22:48 +01:00
rubenwardy 86dd137f75
Add dates to comments 2018-06-11 23:20:18 +01:00
rubenwardy b48f684c0a
Add note about review thread being private 2018-06-11 23:14:14 +01:00
rubenwardy e0e6f3392d
Improve comment CSS 2018-06-11 23:11:15 +01:00
rubenwardy b1c349cc35
Add comment system 2018-06-11 22:52:37 +01:00
rubenwardy 40aac38d43
Fix worker stopping due to gitpython asking for credentials 2018-06-07 23:25:00 +01:00
rubenwardy 051df7ab87
Increase timeout in polltask.js 2018-06-07 23:25:00 +01:00
rubenwardy bb1f6702f6
Add name to create link 2018-06-05 23:51:40 +01:00
rubenwardy c9542427b4
Add create links to topic table 2018-06-05 23:45:15 +01:00
rubenwardy 8601c5e075
Add support for importing generic git releases 2018-06-05 23:13:39 +01:00
rubenwardy 3d97eca387
Add git screenshot importing 2018-06-05 22:39:08 +01:00
rubenwardy 99b21f996c
Fix screenshot import being broken 2018-06-05 19:59:07 +01:00
rubenwardy 700cd7ce1f
Add game detection 2018-06-05 19:51:01 +01:00
rubenwardy 8d9da5a750
Make git error public, delete dir after clone 2018-06-05 19:47:02 +01:00
rubenwardy 9a36bb7d72
Add git support for importing meta 2018-06-05 00:10:47 +01:00
rubenwardy e424dc57e7
Set remember me to true in loginUser 2018-06-04 19:34:29 +01:00
rubenwardy 7d60e2f671
Fix crash on any type search 2018-06-04 19:02:02 +01:00
rubenwardy 8b2018852e
Add redirection to set password after login if not set 2018-06-04 18:49:42 +01:00
rubenwardy 0aeefa2387
Add email usage note 2018-06-04 18:36:26 +01:00
rubenwardy 4420f489ac
Require email in set password 2018-06-04 18:34:04 +01:00
rubenwardy aad4fd2a70
Add list of similar packages in details page 2018-06-03 19:27:56 +01:00
rubenwardy d2bda0fded
Update inclusion policy 2018-06-03 15:19:17 +01:00
rubenwardy b84727b187
Fix username being case-sensitive 2018-06-03 01:50:58 +01:00
rubenwardy 6fd36dbfff
Add WIP things note to policy 2018-06-02 21:40:48 +01:00
rubenwardy 8e134a7c85
Fix todo topics sort order 2018-06-02 19:44:57 +01:00
rubenwardy 389258a10c
Fix button CSS issue 2018-06-02 19:42:46 +01:00
rubenwardy 3657316fa2
Clean up todo topics related HTML 2018-06-02 19:41:13 +01:00
rubenwardy a6f4249afb
Increase link string length limit 2018-06-02 18:32:07 +01:00
rubenwardy 70afb94d3b
Add topics todo list based on forum parser 2018-06-02 18:26:17 +01:00
rubenwardy 8984adaa72 Update policy document 2018-06-02 17:17:32 +01:00
rubenwardy c523624696
Fix button in alert borders 2018-05-30 04:00:27 +01:00
rubenwardy 072f189006
Add alternatives section to package page 2018-05-30 02:59:11 +01:00
rubenwardy 9967101d9f
Add package inclusion policy and guidance 2018-05-30 01:20:47 +01:00
rubenwardy 1ed09b646b
Fix double single quote 2018-05-29 23:21:24 +01:00
rubenwardy f554bfc92b
Fix max package grid cell size 2018-05-29 23:18:13 +01:00
rubenwardy c80ea2c1b1
Sort meta list, and packages on profile 2018-05-29 23:15:41 +01:00
rubenwardy edd51b86d0
Add package grid to profile page 2018-05-29 22:58:46 +01:00
rubenwardy 944b8a4eb0
Add placeholder to release title 2018-05-29 22:43:42 +01:00
rubenwardy a627893355
Add trusted member color 2018-05-29 21:40:10 +01:00
rubenwardy 1600687449
Add non-free warning 2018-05-29 21:25:47 +01:00
rubenwardy fa2f17526f
Disable edit requests 2018-05-29 20:51:42 +01:00
rubenwardy 002e6828b6
Fix user claim verification token not being remembered due to multiple nodes 2018-05-29 20:32:15 +01:00
rubenwardy a947472c67
Fix crash on JSON packages due to lack of None check 2018-05-29 20:18:36 +01:00
rubenwardy e7acd7faa3
Add separate media license
Fixes #91
2018-05-29 20:17:18 +01:00
rubenwardy f755c7d429
Fix flash being hidden behind elements
Fixes #84
2018-05-29 18:50:45 +01:00
rubenwardy b6652547fa
Improve sign in form 2018-05-29 18:31:48 +01:00
rubenwardy be20146f25
Add migration 2018-05-29 18:29:14 +01:00
rubenwardy df291db69b
Add email/password sign up 2018-05-29 18:27:39 +01:00
rubenwardy 63a3b5e872
Add claim call to action on unclaimed accounts 2018-05-29 18:16:05 +01:00
rubenwardy 6353ac29e9
Add set password form 2018-05-29 18:07:23 +01:00
rubenwardy a4b583bac5
Add github-less claim method 2018-05-29 17:42:27 +01:00
rubenwardy 52fdc8c212
Add clear all button to notifications page 2018-05-29 17:20:11 +01:00
rubenwardy 7e80adad56
Fix soft deleted and unapproved packages appearing where they shouldn't 2018-05-29 17:15:53 +01:00
rubenwardy bf5080aa18
Increase thumbnail resolution 2018-05-29 16:56:35 +01:00
rubenwardy 89f95a22dc
Add pagination 2018-05-29 16:52:53 +01:00
rubenwardy f1b21b73b2
Add max package tile size 2018-05-29 16:23:29 +01:00
rubenwardy 6a13dca2d5
Add thumbnail support 2018-05-29 16:19:17 +01:00
rubenwardy 048b604a75
Fix changing email not working due to validation issue 2018-05-28 15:25:45 +01:00
rubenwardy f7bb29c839
Fix empty release titles being allowed
Fixes #86
2018-05-28 14:55:31 +01:00
Ezhh ba506cb16d
Update ranks and permissions help page 2018-05-28 01:40:02 +01:00
Pavel Puchkin 179d0be933 Make search case-insensitive by using ilike 2018-05-28 00:06:57 +01:00
rubenwardy d6790903a6
Add trusted member rank 2018-05-27 23:56:13 +01:00
rubenwardy 48573fe922
Use proper datetime formatting 2018-05-27 23:45:12 +01:00
rubenwardy dff967d3df
Clarify name/title 2018-05-27 23:36:35 +01:00
rubenwardy a2b873bf38
Add 'set provides from name' admin action 2018-05-27 23:13:13 +01:00
rubenwardy d0969263ba
Fix crash due to remaining raise() in getDepends() 2018-05-27 23:02:11 +01:00
rubenwardy d046de8057
Merge pull request #78 from minetest/dev
Add meta packages, remove current dependencies
2018-05-27 22:55:46 +01:00
rubenwardy 05e536b121
Add helpful text to field labels 2018-05-27 22:55:11 +01:00
rubenwardy 2d6b55e67b
Reorder package fields 2018-05-27 22:51:50 +01:00
rubenwardy 44c9f7e58f
Hide unneeded fields depending on package type 2018-05-27 22:48:53 +01:00
rubenwardy 92daa87db0
Add migration 2018-05-27 22:39:16 +01:00
rubenwardy 746cf7f4b5
Add bulk dependency importer from Github 2018-05-27 22:34:24 +01:00
rubenwardy fb5cba4cc8
Add dependency detection to importer 2018-05-27 22:04:03 +01:00
rubenwardy fb8aa25b71
Remove required by for now 2018-05-27 21:42:31 +01:00
rubenwardy 5d944d79d3
Improve placeholder text 2018-05-27 21:36:58 +01:00
rubenwardy ca7708437b
Fix potentiall XSS vulnerability 2018-05-27 21:33:50 +01:00
rubenwardy 63af1535b9
Add dependencies 2018-05-27 21:31:11 +01:00
rubenwardy 82159d488d
Add meta package selector 2018-05-27 20:22:01 +01:00
rubenwardy 5e4613a6ef
Add ability to edit provides 2018-05-27 18:52:23 +01:00
rubenwardy e85298d890
Allow new members to edit their packages if it hasn't been approved yet 2018-05-27 18:06:46 +01:00
rubenwardy f4c9348b7f
Add metapackages pages 2018-05-27 18:01:27 +01:00
rubenwardy 7b6ad051c4
Remove dependencies, add meta packages 2018-05-27 18:01:27 +01:00
rubenwardy 65fdba5882
Require screenshots for games and texture packs 2018-05-27 18:00:38 +01:00
Ezhh 54b7e7c3f7 Update package tags help page 2018-05-27 17:12:31 +01:00
rubenwardy 19848a154d
Add tag editor 2018-05-27 17:12:44 +01:00
rubenwardy 6ce2eb7133
Remove logo from navbar 2018-05-26 18:16:11 +01:00
rubenwardy c358c8b148
Revert "Add jumbotron background"
This reverts commit e5f61f2dc6.
2018-05-26 18:14:32 +01:00
rubenwardy 3d5a6dc32a
Fix two small usability issues 2018-05-26 03:08:14 +01:00
rubenwardy 506d5e518e
Fix crash on approving screenshots on approve package 2018-05-26 02:31:18 +01:00
rubenwardy ef0a32524e
Add banning
Fixes #13
2018-05-26 01:58:56 +01:00
rubenwardy 2abcd8ee47
Add screenshot recommendation to package alert banner 2018-05-26 01:29:18 +01:00
rubenwardy 2b7cc31b4b
Add height limit to autocomplete 2018-05-26 00:32:22 +01:00
rubenwardy 48ebc751e4
Show autocomplete on multichoice_selector focus 2018-05-26 00:28:52 +01:00
444 changed files with 94516 additions and 5281 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
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/" ]

13
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,13 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: Unconfirmed Bug
assignees: ''
---
## Summary
Describe your problem here
##### Steps to reproduce
For bug reports or build issues, explain how the problem happened

View File

@ -0,0 +1,25 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: Feature
assignees: ''
---
## Problem
A clear and concise description of what the problem is.
ie: Why is this needed?
Ex. I'm always frustrated when [...]
## Solutions
A clear and concise description of what you want to happen.
## Alternatives
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.

7
.github/ISSUE_TEMPLATE/policy.md vendored Normal file
View File

@ -0,0 +1,7 @@
---
name: Policy suggestion
about: Suggest a change to the guidelines
title: ''
labels: Policy
assignees: ''
---

19
.github/SECURITY.md vendored Normal file
View File

@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
We only support the latest production version, deployed to <https://content.minetest.net>.
See the [releases page](https://github.com/minetest/contentdb/releases).
## Reporting a Vulnerability
We ask that you report vulnerabilities privately, by contacting rubenwardy,
to give us time to fix them. You can do that by using one of the methods outlined in the following link:
* https://rubenwardy.com/contact/
Depending on severity, we will either create a private issue for the vulnerability
and release a security update, or give you permission to file the issue publicly.
For more information on the justification of this policy, see
[Responsible Disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure).

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

18
.gitignore vendored
View File

@ -1,11 +1,17 @@
config.cfg
config.prod.cfg
/config.cfg
/*.env
*.sqlite
main.css
.vscode
custom.css
tmp
log.txt
*.rdb
uploads
app/public/uploads
app/public/thumbnails
celerybeat-schedule
/data
.idea
*.mo
# Created by https://www.gitignore.io/api/linux,macos,python,windows
@ -100,10 +106,6 @@ coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Flask stuff:
instance/
.webassets-cache

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.10
RUN groupadd -g 5123 cdb && \
useradd -r -u 5123 -g cdb cdb
WORKDIR /home/cdb
RUN mkdir /var/cdb
RUN chown -R cdb:cdb /var/cdb
COPY requirements.lock.txt requirements.lock.txt
RUN pip install -r requirements.lock.txt
RUN pip install gunicorn
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

660
LICENSE.md Normal file
View File

@ -0,0 +1,660 @@
### GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
### Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains
free software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing
under this license.
The precise terms and conditions for copying, distribution and
modification follow.
### TERMS AND CONDITIONS
#### 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public
License.
"Copyright" also means copyright-like laws that apply to other kinds
of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of
an exact copy. The resulting work is called a "modified version" of
the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user
through a computer network, with no transfer of a copy, is not
conveying.
An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
#### 1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same
work.
#### 2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having
them make modifications exclusively for you, or provide you with
facilities for running those works, provided that you comply with the
terms of this License in conveying all material for which you do not
control copyright. Those thus making or running the covered works for
you must do so exclusively on your behalf, under your direction and
control, on terms that prohibit them from making any copies of your
copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10 makes
it unnecessary.
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such
circumvention is effected by exercising rights under this License with
respect to the covered work, and you disclaim any intention to limit
operation or modification of the work as a means of enforcing, against
the work's users, your or third parties' legal rights to forbid
circumvention of technological measures.
#### 4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
#### 5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
#### 6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of
sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal,
family, or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or
updates for a work that has been modified or installed by the
recipient, or for the User Product in which it has been modified or
installed. Access to a network may be denied when the modification
itself materially and adversely affects the operation of the network
or violates the rules and protocols for communication across the
network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
#### 7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
#### 8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally,
unless and until the copyright holder explicitly and finally
terminates your license, and (b) permanently, if the copyright holder
fails to notify you of the violation by some reasonable means prior to
60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
#### 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
#### 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
#### 11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically
granted under this License. You may not convey a covered work if you
are a party to an arrangement with a third party that is in the
business of distributing software, under which you make payment to the
third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
#### 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under
this License and any other pertinent obligations, then as a
consequence you may not convey it at all. For example, if you agree to
terms that obligate you to collect a royalty for further conveying
from those to whom you convey the Program, the only way you could
satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
#### 13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your
version supports such interaction) an opportunity to receive the
Corresponding Source of your version by providing access to the
Corresponding Source from a network server at no charge, through some
standard or customary means of facilitating copying of software. This
Corresponding Source shall include the Corresponding Source for any
work covered by version 3 of the GNU General Public License that is
incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
#### 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Affero General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever
published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions
of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
#### 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
#### 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
#### 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
### How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively state
the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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/>.
Also add information on how to contact you by electronic and paper
mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for
the specific requirements.
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, and how to apply and follow
the GNU AGPL, see <https://www.gnu.org/licenses/>.

View File

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

130
README.md
View File

@ -1,58 +1,94 @@
# Content Database
![Build Status](https://github.com/minetest/contentdb/actions/workflows/test.yml/badge.svg)
## Setup
Content database for Minetest mods, games, and more.\
Developed by rubenwardy, license AGPLv3.0+.
First create a Python virtual env:
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
virtualenv env -ppython3
source env/bin/activate
then use pip:
pip3 install -r requirements.txt
### Development
* Copy config.example.cfg to config.cfg
* Fill SECRET_KEY and WTF_CSRF_SECRET_KEY in with a random string
* Make a Github OAuth Client at <https://github.com/settings/developers>:
* Homepage URL - `http://localhost:5000/`
* Authorization callback URL - `http://localhost:5000/user/github/callback/`
* Put client id and client secret in GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET
* Setup the database: python3 setup.py
## Running
### Development
You need to enter the virtual environment if you haven't yet in
the current session:
source env/bin/activate
If you need to, reset the db like so:
python3 setup.py -d
Then run the server:
./rundebug.py
Then view in your web browser: http://localhost:5000/
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
## How-tos
### Create migration
```sh
# if sqlite
python setup.py -t
rm db.sqlite && python setup.py -t && FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db stamp head
# Hot/live reload (only works with FLASK_DEBUG=1)
./utils/reload.sh
# Create migration
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate
# Cold update a running version of CDB with minimal downtime (production)
./utils/update.sh
# Run migration
FLASK_CONFIG=../config.cfg FLASK_APP=app/__init__.py flask db migrate
# Enter docker
./utils/bash.sh
# Run migrations
./utils/run_migrations.sh
# Create new migration
./utils/create_migration.sh
```
### VSCode: Setting up Linting
* (optional) Install the [Docker extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker)
* Install the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
* Click no to installing pylint (we don't want it to be installed outside of a virtual env)
* Set up a virtual env
* Replace `psycopg2` with `psycopg2_binary` in requirements.txt (because postgresql won't be installed on the system)
* `python3 -m venv env`
* Click yes to prompt to select virtual env for workspace
* Click yes to any prompts about installing pylint
* `source env/bin/activate`
* `pip install -r requirements`
* `pip install pylint` (if a prompt didn't appear)
* Undo changes to requirements.txt
### VSCode: Material Icon Folder Designations
```json
"material-icon-theme.folders.associations": {
"packages": "",
"tasks": "",
"api": "",
"meta": "",
"blueprints": "routes",
"scss": "sass",
"flatpages": "markdown",
"data": "temp",
"migrations": "archive",
"textures": "images",
"sounds": "audio"
}
```
## Database
```mermaid
classDiagram
User "1" --> "*" Package
User --> UserEmailVerification
User "1" --> "*" Notification
Package "1" --> "*" Release
Package "1" --> "*" Dependency
Package "1" --> "*" Tag
Package "1" --> "*" MetaPackage : provides
Release --> MinetestVersion
Package --> License
Dependency --> Package
Dependency --> MetaPackage
MetaPackage "1" --> "*" Package
Package "1" --> "*" Screenshot
Package "1" --> "*" Thread
Thread "1" --> "*" Reply
Thread "1" --> "*" User : watchers
User "1" --> "*" Thread
User "1" --> "*" Reply
User "1" --> "*" ForumTopic
User --> "0..1" EmailPreferences
User "1" --> "*" APIToken
APIToken --> Package
```

View File

@ -1,41 +1,172 @@
# Content DB
# Copyright (C) 2018 rubenwardy
# 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# 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_user import *
import flask_menu as menu
from flask_gravatar import Gravatar
from flask_mail import Mail
from flask.ext import markdown
from flask_github import GitHub
from flask_wtf.csrf import CsrfProtect
from flask_wtf.csrf import CSRFProtect
from flask_flatpages import FlatPages
import os
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"] = 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",
"ru": "русский язык",
}
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
menu.Menu(app=app)
markdown.Markdown(app, extensions=["fenced_code"], safe_mode=True, output_format="html5")
r = redis.Redis.from_url(app.config["REDIS_URL"])
github = GitHub(app)
csrf = CsrfProtect(app)
csrf = CSRFProtect(app)
mail = Mail(app)
pages = FlatPages(app)
babel = Babel(app)
gravatar = Gravatar(app,
size=64,
rating="g",
default="retro",
force_default=False,
force_lower=False,
use_ssl=True,
base_url=None)
init_markdown(app)
from . import models, tasks
from .views import *
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "users.login"
from .sass import init_app as sass
sass(app)
if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
from .maillogger import build_handler
app.logger.addHandler(build_handler(app))
from . import models, template_filters
@login_manager.user_loader
def load_user(user_id):
return models.User.query.filter_by(username=user_id).first()
from .blueprints import create_blueprints
create_blueprints(app)
@app.route("/uploads/<path:path>")
def send_upload(path):
return send_from_directory(app.config["UPLOAD_DIR"], path)
@app.route("/<path:path>/")
def flatpage(path):
page = pages.get_or_404(path)
template = page.meta.get("template", "flatpage.html")
return render_template(template, page=page)
@app.before_request
def check_for_ban():
if current_user.is_authenticated:
if current_user.rank == models.UserRank.BANNED:
flash(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, 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():
if not request:
return None
locales = app.config["LANGUAGES"].keys()
if current_user.is_authenticated and current_user.locale in locales:
return current_user.locale
locale = request.cookies.get("locale")
if locale not in locales:
locale = request.accept_languages.best_match(locales)
if locale and current_user.is_authenticated:
new_session = models.db.create_session({})()
new_session.query(models.User) \
.filter(models.User.username == current_user.username) \
.update({ "locale": locale })
new_session.commit()
new_session.close()
return locale
@app.route("/set-locale/", methods=["POST"])
@csrf.exempt
def set_locale():
locale = request.form.get("locale")
if locale not in app.config["LANGUAGES"].keys():
flash("Unknown locale {}".format(locale), "danger")
locale = None
next_url = request.form.get("r")
if next_url and is_safe_url(next_url):
resp = make_response(redirect(next_url))
else:
resp = make_response(redirect(url_for("homepage.home")))
if locale:
expire_date = datetime.datetime.now()
expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("locale", locale, expires=expire_date)
if current_user.is_authenticated:
current_user.locale = locale
models.db.session.commit()
return resp

View File

@ -0,0 +1,10 @@
import os, importlib
def create_blueprints(app):
dir = os.path.dirname(os.path.realpath(__file__))
modules = next(os.walk(dir))[1]
for modname in modules:
if all(c.islower() for c in modname):
module = importlib.import_module("." + modname, __name__)
app.register_blueprint(module.bp)

View File

@ -0,0 +1,22 @@
# 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/>.
from flask import Blueprint
bp = Blueprint("admin", __name__)
from . import admin, audit, licenseseditor, tagseditor, versioneditor, warningseditor, email

View File

@ -0,0 +1,338 @@
# 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
import sys
from typing import List
import requests
from celery import group
from flask import redirect, url_for, flash, current_app, jsonify
from sqlalchemy import or_, and_
from app.logic.game_support import GameSupportResolver
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \
NotificationType, PackageUpdateConfig, License, UserRank, PackageType, PackageGameSupport
from app.tasks.emails import send_pending_digests
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
from app.utils.image import get_image_size
actions = {}
def action(title: str):
def func(f):
name = f.__name__
actions[name] = {
"title": title,
"func": f,
}
return f
return func
@action("Delete stuck releases")
def del_stuck_releases():
PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
db.session.commit()
return redirect(url_for("admin.admin_page"))
@action("Check ZIP releases")
def check_releases():
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = []
for release in releases:
tasks.append(checkZipRelease.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("Check the first release of all packages")
def reimport_packages():
tasks = []
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
release = package.releases.first()
if release:
tasks.append(checkZipRelease.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("Import forum 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.is_(None)) \
.all()
for package in packages:
importRepoScreenshot.delay(package.id)
return redirect(url_for("admin.admin_page"))
@action("Remove unused uploads")
def clean_uploads():
upload_dir = current_app.config['UPLOAD_DIR']
(_, _, filenames) = next(os.walk(upload_dir))
existing_uploads = set(filenames)
if len(existing_uploads) != 0:
def get_filenames_from_column(column):
results = db.session.query(column).filter(column.isnot(None), column != "").all()
return set([os.path.basename(x[0]) for x in results])
release_urls = get_filenames_from_column(PackageRelease.url)
screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
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 unused 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("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.is_(None), User.forum_topics.is_(None),
User.rank == UserRank.NOT_JOINED).all()
for user in users:
db.session.delete(user)
db.session.commit()
@action("Send Video URL notification")
def remind_video_url():
users = User.query.filter(User.maintained_packages.any(
and_(Package.video_url.is_(None), Package.type==PackageType.GAME, Package.state==PackageState.APPROVED)))
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
or_(Package.author==user, Package.maintainers.any(User.id==user.id)),
Package.video_url.is_(None),
Package.type == PackageType.GAME,
Package.state == PackageState.APPROVED) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"You should add a video to {packages_list}",
url_for('users.profile', username=user.username))
db.session.commit()
@action("Update screenshot sizes")
def update_screenshot_sizes():
import sys
for screenshot in PackageScreenshot.query.all():
width, height = get_image_size(screenshot.file_path)
print(f"{screenshot.url}: {width}, {height}", file=sys.stderr)
screenshot.width = width
screenshot.height = height
db.session.commit()
@action("Detect game support")
def detect_game_support():
resolver = GameSupportResolver()
resolver.update_all()
db.session.commit()
@action("Send pending notif digests")
def do_send_pending_digests():
send_pending_digests.delay()

View File

@ -0,0 +1,130 @@
# 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/>.
from flask import redirect, render_template, url_for, request, flash
from flask_login import current_user, login_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import InputRequired, Length
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"])
@rank_required(UserRank.ADMIN)
def admin_page():
if request.method == "POST":
action = request.form["action"]
if action == "restore":
package = Package.query.get(request.form["package"])
if package is None:
flash("Unknown package", "danger")
else:
package.state = PackageState.READY_FOR_REVIEW
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action 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, actions=actions)
class SwitchUserForm(FlaskForm):
username = StringField("Username")
submit = SubmitField("Switch")
@bp.route("/admin/switchuser/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def switch_user():
form = SwitchUserForm(formdata=request.form)
if form.validate_on_submit():
user = User.query.filter_by(username=form["username"].data).first()
if user is None:
flash("Unable to find user", "danger")
elif login_user(user):
return redirect(url_for("users.profile", username=current_user.username))
else:
flash("Unable to login as user", "danger")
# Process GET or invalid POST
return render_template("admin/switch_user.html", form=form)
class SendNotificationForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 300)])
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
submit = SubmitField("Send")
@bp.route("/admin/send-notification/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def send_bulk_notification():
form = SendNotificationForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
addNotification(users, 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

@ -0,0 +1,46 @@
# ContentDB
# Copyright (C) 2020 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, request, abort
from app.models import db, AuditLogEntry, UserRank, User
from app.utils import rank_required, get_int_or_abort
from . import bp
@bp.route("/admin/audit/")
@rank_required(UserRank.MODERATOR)
def audit():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at))
if "username" in request.args:
user = User.query.filter_by(username=request.args.get("username")).first()
if not user:
abort(404)
query = query.filter_by(causer=user)
pagination = query.paginate(page, num, True)
return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
@bp.route("/admin/audit/<int:id_>/")
@rank_required(UserRank.MODERATOR)
def audit_view(id_):
entry = AuditLogEntry.query.get(id_)
return render_template("admin/audit_view.html", entry=entry)

View File

@ -0,0 +1,78 @@
# ContentDB
# Copyright (C) 2020 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 request, abort, url_for, redirect, render_template, flash
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField, StringField
from wtforms.validators import InputRequired, Length
from app.markdown import render_markdown
from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog
from . import bp
from ...models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm):
subject = StringField("Subject", [InputRequired(), Length(1, 300)])
text = TextAreaField("Message", [InputRequired()])
submit = SubmitField("Send")
@bp.route("/admin/send-email/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def send_single_email():
username = request.args["username"]
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
next_url = url_for("users.profile", username=user.username)
if user.email is None:
flash("User has no email address!", "danger")
return redirect(next_url)
form = SendEmailForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
text = form.text.data
html = render_markdown(text)
task = send_user_email.delay(user.email, user.locale or "en",form.subject.data, text, html)
return redirect(url_for("tasks.check", id=task.id, r=next_url))
return render_template("admin/send_email.html", form=form, user=user)
@bp.route("/admin/send-bulk-email/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def send_bulk_email():
form = SendEmailForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
text = form.text.data
html = render_markdown(text)
for user in User.query.filter(User.email.isnot(None)).all():
send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
return redirect(url_for("admin.admin_page"))
return render_template("admin/send_bulk_email.html", form=form)

View File

@ -0,0 +1,66 @@
# 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/>.
from flask import redirect, render_template, abort, url_for, request, flash
from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField, SubmitField, URLField
from wtforms.validators import InputRequired, Length, Optional
from app.utils import rank_required, nonEmptyOrNone
from . import bp
from ...models import UserRank, License, db
@bp.route("/licenses/")
@rank_required(UserRank.MODERATOR)
def license_list():
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
class LicenseForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3, 100)])
is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
submit = SubmitField("Save")
@bp.route("/licenses/new/", methods=["GET", "POST"])
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def create_edit_license(name=None):
license = None
if name is not None:
license = License.query.filter_by(name=name).first()
if license is None:
abort(404)
form = LicenseForm(formdata=request.form, obj=license)
if request.method == "GET" and license is None:
form.is_foss.data = True
elif form.validate_on_submit():
if license is None:
license = License(form.name.data)
db.session.add(license)
flash("Created license " + form.name.data, "success")
else:
flash("Updated license " + form.name.data, "success")
form.populate_obj(license)
db.session.commit()
return redirect(url_for("admin.license_list"))
return render_template("admin/licenses/edit.html", license=license, form=form)

View File

@ -0,0 +1,82 @@
# 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/>.
from flask import redirect, render_template, abort, url_for, request
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from . import bp
from ...models import Permission, Tag, db
@bp.route("/tags/")
@login_required
def tag_list():
if not Permission.EDIT_TAGS.check(current_user):
abort(403)
query = Tag.query
if request.args.get("sort") == "views":
query = query.order_by(db.desc(Tag.views))
else:
query = query.order_by(db.asc(Tag.title))
return render_template("admin/tags/list.html", tags=query.all())
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"])
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"])
@login_required
def create_edit_tag(name=None):
tag = None
if name is not None:
tag = Tag.query.filter_by(name=name).first()
if tag is None:
abort(404)
if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
abort(403)
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):
return redirect(url_for("admin.create_edit_tag", name=tag.name))
else:
return redirect(url_for("homepage.home"))
return render_template("admin/tags/edit.html", tag=tag, form=form)

View File

@ -0,0 +1,63 @@
# 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/>.
from flask import redirect, render_template, abort, url_for, request, flash
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import InputRequired, Length
from app.utils import rank_required
from . import bp
from ...models import UserRank, MinetestRelease, db
@bp.route("/versions/")
@rank_required(UserRank.MODERATOR)
def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3, 100)])
protocol = IntegerField("Protocol")
submit = SubmitField("Save")
@bp.route("/versions/new/", methods=["GET", "POST"])
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def create_edit_version(name=None):
version = None
if name is not None:
version = MinetestRelease.query.filter_by(name=name).first()
if version is None:
abort(404)
form = VersionForm(formdata=request.form, obj=version)
if form.validate_on_submit():
if version is None:
version = MinetestRelease(form.name.data)
db.session.add(version)
flash("Created version " + form.name.data, "success")
else:
flash("Updated version " + form.name.data, "success")
form.populate_obj(version)
db.session.commit()
return redirect(url_for("admin.version_list"))
return render_template("admin/versions/edit.html", version=version, form=form)

View File

@ -0,0 +1,63 @@
# ContentDB
# Copyright (C) 2020 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, render_template, abort, url_for, request, flash
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.utils import rank_required
from . import bp
from ...models import UserRank, ContentWarning, db
@bp.route("/admin/warnings/")
@rank_required(UserRank.ADMIN)
def warning_list():
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
class WarningForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20),
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN)
def create_edit_warning(name=None):
warning = None
if name is not None:
warning = ContentWarning.query.filter_by(name=name).first()
if warning is None:
abort(404)
form = WarningForm(formdata=request.form, obj=warning)
if form.validate_on_submit():
if warning is None:
warning = ContentWarning(form.title.data, form.description.data)
db.session.add(warning)
else:
form.populate_obj(warning)
db.session.commit()
return redirect(url_for("admin.warning_list"))
return render_template("admin/warnings/edit.html", warning=warning, form=form)

View File

@ -0,0 +1,21 @@
# 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/>.
from flask import Blueprint
bp = Blueprint("api", __name__)
from . import tokens, endpoints

View File

@ -0,0 +1,46 @@
# ContentDB
# Copyright (C) 2019 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from functools import wraps
from flask import request, abort
from app.models import APIToken
from .support import error
def is_api_authd(f):
@wraps(f)
def decorated_function(*args, **kwargs):
token = None
value = request.headers.get("authorization")
if value is None:
pass
elif value[0:7].lower() == "bearer ":
access_token = value[7:]
if len(access_token) < 10:
error(400, "API token is too short")
token = APIToken.query.filter_by(access_token=access_token).first()
if token is None:
error(403, "Unknown API token")
else:
abort(403, "Unsupported authentication method")
return f(token=token, *args, **kwargs)
return decorated_function

View File

@ -0,0 +1,569 @@
# 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 math
from typing import List
import flask_sqlalchemy
from flask import request, jsonify, current_app
from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func
from app import csrf
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, 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, api_set_cover_image
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()
if request.args.get("fmt") == "keys":
return jsonify([package.getAsDictionaryKey() for package in query.all()])
pkgs = qb.convertToDictionary(query.all())
if "engine_version" in request.args or "protocol_version" in request.args:
pkgs = [package for package in pkgs if package.get("release")]
return jsonify(pkgs)
@bp.route("/api/packages/<author>/<name>/")
@is_package_page
@cors_allowed
def package(package):
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def edit_package(token, package):
if not token:
error(401, "Authentication needed")
return api_edit_package(token, package, request.json)
def resolve_package_deps(out, package, only_hard, depth=1):
id = package.getId()
if id in out:
return
ret = []
out[id] = ret
if package.type != PackageType.MOD:
return
for dep in package.dependencies:
if only_hard and dep.optional:
continue
if dep.package:
name = dep.package.name
fulfilled_by = [ dep.package.getId() ]
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]
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")
ret.append({
"name": name,
"is_optional": dep.optional,
"packages": fulfilled_by
})
@bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page
@cors_allowed
def package_dependencies(package):
only_hard = request.args.get("only_hard")
out = {}
resolve_package_deps(out, package, only_hard)
return jsonify(out)
@bp.route("/api/topics/")
@cors_allowed
def topics():
qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True)
return jsonify([t.getAsDictionary() for t in query.all()])
@bp.route("/api/topic_discard/", methods=["POST"])
@login_required
def topic_set_discard():
tid = request.args.get("tid")
discard = request.args.get("discard")
if tid is None or discard is None:
error(400, "Missing topic ID or discard bool")
topic = ForumTopic.query.get(tid)
if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
error(403, "Permission denied, need: TOPIC_DISCARD")
topic.discarded = discard == "true"
db.session.commit()
return jsonify(topic.getAsDictionary())
@bp.route("/api/whoami/")
@is_api_authd
@cors_allowed
def whoami(token):
if token is None:
return jsonify({ "is_authenticated": False, "username": None })
else:
return jsonify({ "is_authenticated": True, "username": token.owner.username })
@bp.route("/api/markdown/", methods=["POST"])
@csrf.exempt
def markdown():
return render_markdown(request.data.decode("utf-8"))
@bp.route("/api/releases/")
@cors_allowed
def list_all_releases():
query = PackageRelease.query.filter_by(approved=True) \
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
.order_by(db.desc(PackageRelease.releaseDate))
if "author" in request.args:
author = User.query.filter_by(username=request.args["author"]).first()
if author is None:
error(404, "Author not found")
query = query.filter(PackageRelease.package.has(author=author))
if "maintainer" in request.args:
maintainer = User.query.filter_by(username=request.args["maintainer"]).first()
if maintainer is None:
error(404, "Maintainer not found")
query = query.join(Package)
query = query.filter(Package.maintainers.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() ])
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def create_release(token, package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
error(403, "You do not have the permission to approve releases")
data = request.json or request.form
if "title" not in data:
error(400, "Title is required in the POST data")
if data.get("method") == "git":
for option in ["method", "ref"]:
if option not in data:
error(400, option + " is required in the POST data")
return api_create_vcs_release(token, package, data["title"], data["ref"])
elif request.files:
file = request.files.get("file")
if file is None:
error(400, "Missing 'file' in multipart body")
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.")
@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:
error(404, "Release not found")
return jsonify(release.getAsDictionary())
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def delete_release(token: APIToken, package: Package, id: int):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
error(404, "Release not found")
if not token:
error(401, "Authentication needed")
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
if not release.checkPerm(token.owner, Permission.DELETE_RELEASE):
error(403, "Unable to delete the release, make sure there's a newer release available")
db.session.delete(release)
db.session.commit()
return jsonify({"success": True})
@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])
@bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def create_screenshot(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to create screenshots")
data = request.form
if "title" not in data:
error(400, "Title is required in the POST data")
file = request.files.get("file")
if file is None:
error(400, "Missing 'file' in multipart body")
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@is_package_page
@cors_allowed
def screenshot(package, id):
ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package:
error(404, "Screenshot not found")
return jsonify(ss.getAsDictionary(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def delete_screenshot(token: APIToken, package: Package, id: int):
ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package:
error(404, "Screenshot not found")
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to delete screenshots")
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
if package.cover_image == ss:
package.cover_image = None
db.session.merge(package)
db.session.delete(ss)
db.session.commit()
return jsonify({ "success": True })
@bp.route("/api/packages/<author>/<name>/screenshots/order/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def order_screenshots(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots")
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
json = request.json
if json is None or not isinstance(json, list):
error(400, "Expected order body to be array")
return api_order_screenshots(token, package, request.json)
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def set_cover_image(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots")
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
json = request.json
if json is None or not isinstance(json, dict) or "cover_image" not in json:
error(400, "Expected body to be an object with cover_image as a key")
return api_set_cover_image(token, package, request.json["cover_image"])
@bp.route("/api/packages/<author>/<name>/reviews/")
@is_package_page
@cors_allowed
def list_reviews(package):
reviews = package.reviews
return jsonify([review.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()
pkgs = [package.getScoreDict() for package in query.all()]
return jsonify(pkgs)
@bp.route("/api/tags/")
@cors_allowed
def tags():
return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
@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()
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
high_reviewed = query.order_by(db.desc(Package.score - Package.score_downloads)) \
.filter(Package.reviews.any()).limit(4).all()
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter_by(state=PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all()
updated = updated[:4]
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: List[Package]):
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
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/welcome/v1/")
@cors_allowed
def welcome_v1():
featured = Package.query \
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
Package.tags.any(name="featured")) \
.order_by(func.random()) \
.limit(5).all()
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
featured.insert(2, mtg)
def map_packages(packages: List[Package]):
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({
"featured": map_packages(featured),
})
@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

@ -0,0 +1,119 @@
# 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/>.
from flask import jsonify, abort, make_response, url_for, current_app
from app.logic.packages import do_edit_package
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
def error(code: int, msg: str):
abort(make_response(jsonify({ "success": False, "error": msg }), code))
# Catches LogicErrors and aborts with JSON error
def guard(f):
def ret(*args, **kwargs):
try:
return f(*args, **kwargs)
except LogicError as e:
error(e.code, e.message)
return ret
def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
rel = guard(do_create_vcs_release)(token.owner, package, title, ref, min_v, max_v, reason)
return jsonify({
"success": True,
"task": url_for("tasks.check", id=rel.task_id),
"release": rel.getAsDictionary()
})
def api_create_zip_release(token: APIToken, package: Package, title: str, file,
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, commit_hash)
return jsonify({
"success": True,
"task": url_for("tasks.check", id=rel.task_id),
"release": rel.getAsDictionary()
})
def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
return jsonify({
"success": True,
"screenshot": ss.getAsDictionary()
})
def api_order_screenshots(token: APIToken, package: Package, order: [any]):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
guard(do_order_screenshots)(token.owner, package, order)
return jsonify({
"success": True
})
def api_set_cover_image(token: APIToken, package: Package, cover_image):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
guard(do_set_cover_image)(token.owner, package, cover_image)
return jsonify({
"success": True
})
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
package = guard(do_edit_package)(token.owner, package, False, False, data, reason)
return jsonify({
"success": True,
"package": package.getAsDictionary(current_app.config["BASE_URL"])
})

View File

@ -0,0 +1,151 @@
# 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/>.
from flask import render_template, redirect, request, session, url_for, abort
from flask_babel import lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.models import db, User, APIToken, Package, Permission
from app.utils import randomString
from . import bp
from ..users.settings import get_setting_tabs
class CreateAPIToken(FlaskForm):
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(lazy_gettext("Save"))
@bp.route("/user/tokens/")
@login_required
def list_tokens_redirect():
return redirect(url_for("api.list_tokens", username=current_user.username))
@bp.route("/users/<username>/tokens/")
@login_required
def list_tokens(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens")
@bp.route("/users/<username>/tokens/new/", methods=["GET", "POST"])
@bp.route("/users/<username>/tokens/<int:id>/edit/", methods=["GET", "POST"])
@login_required
def create_edit_token(username, id=None):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
token = None
access_token = None
if not is_new:
token = APIToken.query.get(id)
if token is None:
abort(404)
elif token.owner != user:
abort(403)
access_token = session.pop("token_" + str(token.id), None)
form = CreateAPIToken(formdata=request.form, obj=token)
form.package.query_factory = lambda: user.maintained_packages.all()
if form.validate_on_submit():
if is_new:
token = APIToken()
token.owner = user
token.access_token = randomString(32)
form.populate_obj(token)
db.session.add(token)
db.session.commit() # save
if is_new:
# Store token so it can be shown in the edit page
session["token_" + str(token.id)] = token.access_token
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
return render_template("api/create_edit_token.html", user=user, form=form, token=token, access_token=access_token)
@bp.route("/users/<username>/tokens/<int:id>/reset/", methods=["POST"])
@login_required
def reset_token(username, id):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
token = APIToken.query.get(id)
if token is None:
abort(404)
elif token.owner != user:
abort(403)
token.access_token = randomString(32)
db.session.commit() # save
# Store token so it can be shown in the edit page
session["token_" + str(token.id)] = token.access_token
return redirect(url_for("api.create_edit_token", username=username, id=token.id))
@bp.route("/users/<username>/tokens/<int:id>/delete/", methods=["POST"])
@login_required
def delete_token(username, id):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403)
is_new = id is None
token = APIToken.query.get(id)
if token is None:
abort(404)
elif token.owner != user:
abort(403)
db.session.delete(token)
db.session.commit()
return redirect(url_for("api.list_tokens", username=username))

View File

@ -0,0 +1,165 @@
# 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/>.
from flask import Blueprint
from flask_babel import gettext
bp = Blueprint("github", __name__)
from flask import redirect, url_for, request, flash, jsonify, current_app
from flask_login import current_user
from sqlalchemy import func, or_, and_
from app import github, csrf
from app.models import db, User, APIToken, Package, Permission, AuditSeverity
from app.utils import abs_url_for, addAuditLog, login_user_set_active
from app.blueprints.api.support import error, api_create_vcs_release
import hmac, requests
@bp.route("/github/start/")
def start():
return github.authorize("", redirect_uri=abs_url_for("github.callback"))
@bp.route("/github/view/")
def view_permissions():
url = "https://github.com/settings/connections/applications/" + \
current_app.config["GITHUB_CLIENT_ID"]
return redirect(url)
@bp.route("/github/callback/")
@github.authorized_handler
def callback(oauth_token):
next_url = request.args.get("next")
if oauth_token is None:
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login"))
# Get Github username
url = "https://api.github.com/user"
r = requests.get(url, headers={"Authorization": "token " + oauth_token})
username = r.json()["login"]
# Get user by github username
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
# If logged in, connect
if current_user and current_user.is_authenticated:
if userByGithub is None:
current_user.github_username = username
db.session.commit()
flash(gettext("Linked GitHub to account"), "success")
return redirect(url_for("homepage.home"))
else:
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(gettext("Unable to find an account for that GitHub user"), "danger")
return redirect(url_for("users.claim_forums"))
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
def webhook():
json = request.json
# Get package
github_url = "github.com/" + json["repository"]["full_name"]
package = Package.query.filter(Package.repo.ilike("%{}%".format(github_url))).first()
if package is None:
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
# Get all tokens for package
tokens_query = APIToken.query.filter(or_(APIToken.package==package,
and_(APIToken.package==None, APIToken.owner==package.author)))
possible_tokens = tokens_query.all()
actual_token = None
#
# Check signature
#
header_signature = request.headers.get('X-Hub-Signature')
if header_signature is None:
return error(403, "Expected payload signature")
sha_name, signature = header_signature.split('=')
if sha_name != 'sha1':
return error(403, "Expected SHA1 payload signature")
for token in possible_tokens:
mac = hmac.new(token.access_token.encode("utf-8"), msg=request.data, digestmod='sha1')
if hmac.compare_digest(str(mac.hexdigest()), signature):
actual_token = token
break
if actual_token is None:
return error(403, "Invalid authentication, couldn't validate API token")
if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
#
# Check event
#
event = request.headers.get("X-GitHub-Event")
if event == "push":
ref = json["after"]
title = json["head_commit"]["message"].partition("\n")[0]
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."
.format(event or "null"))
#
# Perform release
#
if package.releases.filter_by(commit_hash=ref).count() > 0:
return
return api_create_vcs_release(actual_token, package, title, ref, reason="Webhook")

View File

@ -0,0 +1,85 @@
# ContentDB
# Copyright (C) 2020 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, jsonify
bp = Blueprint("gitlab", __name__)
from app import csrf
from app.models import Package, APIToken, Permission
from app.blueprints.api.support import error, api_create_vcs_release
def webhook_impl():
json = request.json
# Get package
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
if package is None:
return error(400,
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
# Get all tokens for package
secret = request.headers.get("X-Gitlab-Token")
if secret is None:
return error(403, "Token required")
token = APIToken.query.filter_by(access_token=secret).first()
if token is None:
return error(403, "Invalid authentication")
if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases")
#
# Check event
#
event = json["event_name"]
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', 'create:tag', and 'ping' are supported."
.format(event or "null"))
#
# Perform release
#
if package.releases.filter_by(commit_hash=ref).count() > 0:
return
return api_create_vcs_release(token, package, title, ref, reason="Webhook")
@bp.route("/gitlab/webhook/", methods=["POST"])
@csrf.exempt
def webhook():
try:
return webhook_impl()
except KeyError as err:
return error(400, "Missing field: {}".format(err.args[0]))

View File

@ -0,0 +1,44 @@
from flask import Blueprint, render_template, redirect
bp = Blueprint("homepage", __name__)
from app.models import *
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func
@bp.route("/")
def home():
def join(query):
return query.options(
joinedload(Package.license),
joinedload(Package.media_license))
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()
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
.filter(Package.reviews.any()).limit(4).all()
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter_by(state=PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all()
updated = updated[:4]
reviews = PackageReview.query.filter_by(recommends=True).order_by(db.desc(PackageReview.created_at)).limit(5).all()
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
tags = db.session.query(func.count(Tags.c.tag_id), Tag) \
.select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all()
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

@ -0,0 +1,64 @@
# 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/>.
from flask import *
from sqlalchemy import func
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic
bp = Blueprint("metapackages", __name__)
@bp.route("/metapackages/")
def list_all():
mpackages = db.session.query(MetaPackage, func.count(Package.id)) \
.select_from(MetaPackage).outerjoin(MetaPackage.packages) \
.order_by(db.asc(MetaPackage.name)) \
.group_by(MetaPackage.id).all()
return render_template("metapackages/list.html", mpackages=mpackages)
@bp.route("/metapackages/<name>/")
def view(name):
mpackage = MetaPackage.query.filter_by(name=name).first()
if mpackage is None:
abort(404)
dependers = db.session.query(Package) \
.select_from(MetaPackage) \
.filter(MetaPackage.name==name) \
.join(MetaPackage.dependencies) \
.join(Dependency.depender) \
.filter(Dependency.optional==False, Package.state==PackageState.APPROVED) \
.all()
optional_dependers = db.session.query(Package) \
.select_from(MetaPackage) \
.filter(MetaPackage.name==name) \
.join(MetaPackage.dependencies) \
.join(Dependency.depender) \
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
.all()
similar_topics = ForumTopic.query \
.filter_by(name=name) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
return render_template("metapackages/view.html", mpackage=mpackage,
dependers=dependers, optional_dependers=optional_dependers,
similar_topics=similar_topics)

View File

@ -0,0 +1,74 @@
# ContentDB
# Copyright (C) 2020 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, make_response
from sqlalchemy.sql.expression import func
from app.models import Package, db, User, UserRank, PackageState
bp = Blueprint("metrics", __name__)
def generate_metrics(full=False):
def write_single_stat(name, help, type, value):
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
return fmt.format(name=name, help=help, type=type, value=value)
def gen_labels(labels):
pieces = [key + "=" + str(val) for key, val in labels.items()]
return ",".join(pieces)
def write_array_stat(name, help, type, data):
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
.format(name=name, help=help, type=type)
for entry in data:
assert(len(entry) == 2)
ret += "{name}{{{labels}}} {value}\n" \
.format(name=name, labels=gen_labels(entry[0]), value=entry[1])
return ret + "\n"
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]
packages = Package.query.filter_by(state=PackageState.APPROVED).count()
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
ret = ""
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) \
.filter(Package.state==PackageState.APPROVED).all()
ret += write_array_stat("contentdb_package_score", "Package score", "gauge",
[({ "author": score[0], "name": score[1] }, score[2]) for score in scores])
else:
score_result = db.session.query(func.sum(Package.score)).one_or_none()
score = 0 if not score_result or not score_result[0] else score_result[0]
ret += write_single_stat("contentdb_score", "Total package score", "gauge", score)
return ret
@bp.route("/metrics")
def metrics():
response = make_response(generate_metrics(), 200)
response.mimetype = "text/plain"
return response

View File

@ -0,0 +1,49 @@
# 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/>.
from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user, login_required
from sqlalchemy import or_, desc
from app.models import db, Notification, NotificationType
bp = Blueprint("notifications", __name__)
@bp.route("/notifications/")
@login_required
def list_all():
notifications = Notification.query.filter(Notification.user == current_user,
Notification.type != NotificationType.EDITOR_ALERT, Notification.type != NotificationType.EDITOR_MISC) \
.order_by(desc(Notification.created_at)) \
.all()
editor_notifications = Notification.query.filter(Notification.user == current_user,
or_(Notification.type == NotificationType.EDITOR_ALERT, Notification.type == NotificationType.EDITOR_MISC)) \
.order_by(desc(Notification.created_at)) \
.all()
return render_template("notifications/list.html",
notifications=notifications, editor_notifications=editor_notifications)
@bp.route("/notifications/clear/", methods=["POST"])
@login_required
def clear():
Notification.query.filter_by(user=current_user).delete()
db.session.commit()
return redirect(url_for("notifications.list_all"))

View File

@ -0,0 +1,68 @@
# 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/>.
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, game_hub

View File

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

View File

@ -0,0 +1,613 @@
# 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/>.
from urllib.parse import quote as urlescape
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
from sqlalchemy.orm import joinedload, subqueryload
from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.validators import *
from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_key
from app.tasks.importtasks import importRepoScreenshot
from app.utils import *
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
@bp.route("/packages/")
def list_all():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
title = qb.title
query = query.options(
joinedload(Package.license),
joinedload(Package.media_license),
subqueryload(Package.tags))
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
if ip is not None and not is_user_bot():
edited = False
for tag in qb.tags:
edited = True
key = "tag/{}/{}".format(ip, tag.name)
if not has_key(key):
set_key(key, "true")
Tag.query.filter_by(id=tag.id).update({
"views": Tag.views + 1
})
if edited:
db.session.commit()
if qb.lucky:
package = query.first()
if package:
return redirect(package.getURL("packages.view"))
topic = qb.buildTopicQuery().first()
if qb.search and topic:
return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = query.paginate(page, num, True)
search = request.args.get("q")
type_name = request.args.get("type")
authors = []
if search:
authors = User.query \
.filter(or_(*[func.lower(User.username) == name.lower().strip() for name in search.split(" ")])) \
.all()
authors = [(author.username, search.lower().replace(author.username.lower(), "")) for author in authors]
topics = None
if qb.search and not query.has_next:
qb.show_discarded = True
topics = qb.buildTopicQuery().all()
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
.select_from(Tag).join(Tags).join(Package).group_by(Tag.id).order_by(db.asc(Tag.title))
tags = qb.filterPackageQuery(tags_query).all()
selected_tags = set(qb.tags)
return render_template("packages/list.html",
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)
def getReleases(package):
if package.checkPerm(current_user, Permission.MAKE_RELEASE):
return package.releases.limit(5)
else:
return package.releases.filter_by(approved=True).limit(5)
@bp.route("/packages/<author>/<name>/")
@is_package_page
def view(package):
show_similar = not package.approved and (
current_user in package.maintainers 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()
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
if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
review_thread = None
topic_error = None
topic_error_lvl = "warning"
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>" + 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>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>")
topic_error_lvl = "danger"
elif package.type != PackageType.TXP:
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created."))
topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated:
threads = threads.filter_by(private=False)
elif not current_user.rank.atLeast(UserRank.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, 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)
@bp.route("/packages/<author>/<name>/shields/<type>/")
@is_package_page
def shield(package, type):
if type == "title":
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
.format(urlescape(package.title), urlescape("#375a7f"))
elif type == "downloads":
#api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
api_url = "https://content.minetest.net" + url_for("api.package", author=package.author.username, name=package.name)
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
.format(urlescape("#375a7f"), urlescape(api_url))
else:
abort(404)
return redirect(url)
@bp.route("/packages/<author>/<name>/download/")
@is_package_page
def download(package):
release = package.getDownloadRelease()
if release is None:
if "application/zip" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes:
return "", 204
else:
flash(gettext("No download available."), "danger")
return redirect(package.getURL("packages.view"))
else:
return redirect(release.getDownloadURL())
def makeLabel(obj):
if obj.description:
return "{}: {}".format(obj.title, obj.description)
else:
return obj.title
class PackageForm(FlaskForm):
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)])
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce)
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=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)
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)])
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
@login_required
def create_edit(author=None, name=None):
package = None
if author is None:
form = PackageForm(formdata=request.form)
author = request.args.get("author")
if author is None or author == current_user.username:
author = current_user
else:
author = User.query.filter_by(username=author).first()
if author is None:
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(gettext("Permission denied"), "danger")
return redirect(url_for("packages.create_edit"))
else:
package = getPackageByInfo(author, name)
if package is None:
abort(404)
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.getURL("packages.view"))
author = package.author
form = PackageForm(formdata=request.form, obj=package)
# Initial form class from post data and default data
if request.method == "GET":
if package is None:
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums")
form.license.data = None
form.media_license.data = None
else:
form.tags.data = package.tags
form.content_warnings.data = package.content_warnings
if request.method == "POST" and form.type.data == PackageType.TXP:
form.license.data = form.media_license.data
if form.validate_on_submit():
wasNew = False
if not package:
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.READY_FOR_REVIEW:
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else:
flash(gettext("Package already exists!"), "danger")
return redirect(url_for("packages.create_edit"))
package = Package()
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
"video_url": form.video_url.data,
})
if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id)
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.getURL("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
package_query = Package.query.filter_by(state=PackageState.APPROVED)
if package is not None:
package_query = package_query.filter(Package.id != package.id)
enableWizard = name is None and request.method != "POST"
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(),
tabs=get_package_tabs(current_user, package), current_tab="edit")
@bp.route("/packages/<author>/<name>/state/", methods=["POST"])
@login_required
@is_package_page
def move_to_state(package):
state = PackageState.get(request.args.get("state"))
if state is None:
abort(400)
if not package.canMoveToState(current_user, state):
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()
for s in screenshots:
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.getURL("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
db.session.commit()
if package.state == PackageState.CHANGES_NEEDED:
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.getURL("packages.view"))
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@login_required
@is_package_page
def remove(package):
if request.method == "GET":
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(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 {}, 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(gettext("Deleted package"), "success")
return redirect(url)
elif "unapprove" in request.form:
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.getURL("packages.view"))
package.state = PackageState.WIP
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(gettext("Unapproved package"), "success")
return redirect(package.getURL("packages.view"))
else:
abort(400)
class PackageMaintainersForm(FlaskForm):
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_maintainers(package):
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
flash(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":
form.maintainers_str.data = ", ".join([ x.username for x in package.maintainers if x != package.author ])
if form.validate_on_submit():
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.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.getURL("packages.view"), package)
package.maintainers.clear()
package.maintainers.extend(users)
if package.author not in package.maintainers:
package.maintainers.append(package.author)
msg = "Edited {} maintainers".format(package.title)
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.getURL("packages.view"), package)
db.session.commit()
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, tabs=get_package_tabs(current_user, package), current_tab="maintainers")
@bp.route("/packages/<author>/<name>/remove-self-maintainer/", methods=["POST"])
@login_required
@is_package_page
def remove_self_maintainers(package):
if not current_user in package.maintainers:
flash(gettext("You are not a maintainer"), "danger")
elif current_user == package.author:
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.getURL("packages.view"), package)
db.session.commit()
return redirect(package.getURL("packages.view"))
@bp.route("/packages/<author>/<name>/audit/")
@login_required
@is_package_page
def audit(package):
if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
package.checkPerm(current_user, Permission.APPROVE_NEW)):
abort(403)
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
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")
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):
return render_template("packages/share.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="share")
@bp.route("/packages/<author>/<name>/similar/")
@is_package_page
def similar(package):
packages_modnames = {}
for 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

@ -0,0 +1,358 @@
# 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/>.
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
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, get_package_tabs
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
@is_package_page
def list_releases(package):
return render_template("packages/releases_list.html",
package=package,
tabs=get_package_tabs(current_user, package), current_tab="releases")
def get_mt_releases(is_max):
query = MinetestRelease.query.order_by(db.asc(MinetestRelease.id))
if is_max:
query = query.limit(query.count() - 1)
else:
query = query.filter(MinetestRelease.name != "0.4.17")
return query
class CreatePackageReleaseForm(FlaskForm):
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(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(lazy_gettext("Save"))
class EditPackageReleaseForm(FlaskForm):
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(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(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.getURL("packages.view"))
# Initial form class from post data and default data
form = CreatePackageReleaseForm()
if package.repo is not None:
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")
if request.method == "GET":
form.title.data = request.args.get("title")
if form.validate_on_submit():
try:
if form["uploadOpt"].data == "vcs":
rel = do_create_vcs_release(current_user, package, form.title.data,
form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
else:
rel = do_create_zip_release(current_user, package, form.title.data,
form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
except LogicError as e:
flash(e.message, "danger")
return render_template("packages/release_new.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<id>/download/")
@is_package_page
def download_release(package, id):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
ip = request.headers.get("X-Forwarded-For") or request.remote_addr
if ip is not None and not is_user_bot():
key = make_download_key(ip, release.package)
if not has_key(key):
set_key(key, "true")
bonus = 1
PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1
})
Package.query.filter_by(id=package.id).update({
"downloads": Package.downloads + 1,
"score_downloads": Package.score_downloads + bonus,
"score": Package.score + bonus
})
db.session.commit()
return redirect(release.url)
@bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_release(package, id):
release : PackageRelease = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
if not (canEdit or canApprove):
return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data
form = EditPackageReleaseForm(formdata=request.form, obj=release)
if request.method == "GET":
# HACK: fix bug in wtforms
form.approved.data = release.approved
if form.validate_on_submit():
if canEdit:
release.title = form["title"].data
release.min_rel = form["min_rel"].data.getActual()
release.max_rel = form["max_rel"].data.getActual()
if package.checkPerm(current_user, Permission.CHANGE_RELEASE_URL):
release.url = form["url"].data
release.task_id = form["task_id"].data
if release.task_id is not None:
release.task_id = None
if form.approved.data:
release.approve(current_user)
elif canApprove:
release.approved = False
db.session.commit()
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(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(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(lazy_gettext("Only change values previously set as none"))
submit = SubmitField(lazy_gettext("Update"))
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@login_required
@is_package_page
def bulk_change_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data
form = BulkReleaseForm()
if request.method == "GET":
form.only_change_none.data = True
elif form.validate_on_submit():
only_change_none = form.only_change_none.data
for release in package.releases.all():
if form["set_min"].data and (not only_change_none or release.min_rel is None):
release.min_rel = form["min_rel"].data.getActual()
if form["set_max"].data and (not only_change_none or release.max_rel is None):
release.max_rel = form["max_rel"].data.getActual()
db.session.commit()
return redirect(package.getURL("packages.list_releases"))
return render_template("packages/release_bulk_change.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<id>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_release(package, id):
release = PackageRelease.query.get(id)
if release is None or release.package != package:
abort(404)
if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
return redirect(package.getURL("packages.list_releases"))
db.session.delete(release)
db.session.commit()
return redirect(package.getURL("packages.view"))
class PackageUpdateConfigFrom(FlaskForm):
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):
if package.update_config is None:
package.update_config = PackageUpdateConfig()
db.session.add(package.update_config)
form.populate_obj(package.update_config)
package.update_config.ref = nonEmptyOrNone(form.ref.data)
package.update_config.make_release = form.action.data == "make_release"
if package.update_config.trigger == PackageUpdateTrigger.COMMIT:
if package.update_config.last_commit is None:
last_release = package.releases.first()
if last_release and last_release.commit_hash:
package.update_config.last_commit = last_release.commit_hash
elif package.update_config.trigger == PackageUpdateTrigger.TAG:
# Only create releases for tags created after this
package.update_config.last_commit = None
package.update_config.last_tag = None
package.update_config.outdated_at = None
package.update_config.auto_created = False
db.session.commit()
if package.update_config.last_commit is None:
check_update_config.delay(package.id)
@bp.route("/packages/<author>/<name>/update-config/", methods=["GET", "POST"])
@login_required
@is_package_page
def update_config(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
abort(403)
if not package.repo:
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":
if package.update_config:
form.action.data = "make_release" if package.update_config.make_release else "notification"
elif request.args.get("action") == "notification":
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(gettext("Deleted update configuration"), "success")
if package.update_config:
db.session.delete(package.update_config)
db.session.commit()
else:
set_update_config(package, form)
if not form.disable.data and package.releases.count() == 0:
flash(gettext("Now, please create an initial release"), "success")
return redirect(package.getURL("packages.create_release"))
return redirect(package.getURL("packages.list_releases"))
return render_template("packages/update_config.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/setup-releases/")
@login_required
@is_package_page
def setup_releases(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
abort(403)
if package.update_config:
return redirect(package.getURL("packages.update_config"))
return render_template("packages/release_wizard.html", package=package)
@bp.route("/user/update-configs/")
@bp.route("/users/<username>/update-configs/", methods=["GET", "POST"])
@login_required
def bulk_update_config(username=None):
if username is None:
return redirect(url_for("packages.bulk_update_config", username=current_user.username))
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
abort(403)
form = PackageUpdateConfigFrom()
if form.validate_on_submit():
for package in user.packages.filter(Package.state != PackageState.DELETED, Package.repo.isnot(None)).all():
set_update_config(package, form)
return redirect(url_for("packages.bulk_update_config", username=username))
confs = user.packages \
.filter(Package.state != PackageState.DELETED,
Package.update_config.has()) \
.order_by(db.asc(Package.title)).all()
return render_template("packages/bulk_update_conf.html", user=user, confs=confs, form=form)

View File

@ -0,0 +1,240 @@
# ContentDB
# Copyright (C) 2020 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import namedtuple
from flask_babel import gettext, lazy_gettext
from . import bp
from flask import *
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, PackageReviewVote, Package, UserRank, \
Permission, AuditSeverity
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
from app.tasks.webhooktasks import post_discord_webhook
@bp.route("/reviews/")
def list_reviews():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page, num, True)
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
class ReviewForm(FlaskForm):
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(gettext("You can't review your own package!"), "danger")
return redirect(package.getURL("packages.view"))
review = PackageReview.query.filter_by(package=package, author=current_user).first()
form = ReviewForm(formdata=request.form, obj=review)
# Set default values
if request.method == "GET" and review:
form.title.data = review.thread.title
form.recommends.data = "yes" if review.recommends else "no"
form.comment.data = review.thread.replies[0].comment
# Validate and submit
elif form.validate_on_submit():
was_new = False
if not review:
was_new = True
review = PackageReview()
review.package = package
review.author = current_user
db.session.add(review)
review.recommends = form.recommends.data == "yes"
thread = review.thread
if not thread:
thread = Thread()
thread.author = current_user
thread.private = False
thread.package = package
thread.review = review
db.session.add(thread)
thread.watchers.append(current_user)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
thread.replies.append(reply)
else:
reply = thread.replies[0]
reply.comment = form.comment.data
thread.title = form.title.data
db.session.commit()
package.recalcScore()
if was_new:
notif_msg = "New review '{}'".format(form.title.data)
type = NotificationType.NEW_REVIEW
else:
notif_msg = "Updated review '{}'".format(form.title.data)
type = NotificationType.OTHER
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.getURL("packages.view"))
return render_template("packages/review_create_edit.html",
form=form, package=package, review=review)
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_review(package, reviewer):
review = PackageReview.query \
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
.first()
if review is None or review.package != package:
abort(404)
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
abort(403)
thread = review.thread
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = "_converted review into a thread_"
db.session.add(reply)
thread.review = None
msg = "Converted review by {} to thread".format(review.author.display_name)
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
current_user, msg, thread.getViewURL(), thread.package)
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
db.session.delete(review)
package.recalcScore()
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

@ -0,0 +1,149 @@
# 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/>.
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_wtf import FlaskForm
from flask_login import login_required
from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.utils import *
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(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(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(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
@login_required
@is_package_page
def screenshots(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getURL("packages.view"))
if package.screenshots.count() == 0:
return redirect(package.getURL("packages.create_screenshot"))
form = EditPackageScreenshotsForm(obj=package)
form.cover_image.query = package.screenshots
if request.method == "POST":
order = request.form.get("order")
if order:
try:
do_order_screenshots(current_user, package, order.split(","))
return redirect(package.getURL("packages.view"))
except LogicError as e:
flash(e.message, "danger")
if form.validate_on_submit():
form.populate_obj(package)
db.session.commit()
return render_template("packages/screenshots.html", package=package, form=form,
tabs=get_package_tabs(current_user, package), current_tab="screenshots")
@bp.route("/packages/<author>/<name>/screenshots/new/", methods=["GET", "POST"])
@login_required
@is_package_page
def create_screenshot(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.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, False)
return redirect(package.getURL("packages.screenshots"))
except LogicError as e:
flash(e.message, "danger")
return render_template("packages/screenshot_new.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
@login_required
@is_package_page
def edit_screenshot(package, id):
screenshot = PackageScreenshot.query.get(id)
if screenshot is None or screenshot.package != package:
abort(404)
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
if not (canEdit or canApprove):
return redirect(package.getURL("packages.screenshots"))
# Initial form class from post data and default data
form = EditScreenshotForm(obj=screenshot)
if form.validate_on_submit():
wasApproved = screenshot.approved
if canEdit:
screenshot.title = form["title"].data or "Untitled"
if canApprove:
screenshot.approved = form["approved"].data
else:
screenshot.approved = wasApproved
db.session.commit()
return redirect(package.getURL("packages.screenshots"))
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
@bp.route("/packages/<author>/<name>/screenshots/<id>/delete/", methods=["POST"])
@login_required
@is_package_page
def delete_screenshot(package, id):
screenshot = PackageScreenshot.query.get(id)
if screenshot is None or screenshot.package != package:
abort(404)
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
flash(gettext("Permission denied"), "danger")
return redirect(url_for("homepage.home"))
if package.cover_image == screenshot:
package.cover_image = None
db.session.merge(package)
db.session.delete(screenshot)
db.session.commit()
return redirect(package.getURL("packages.screenshots"))

View File

@ -0,0 +1,64 @@
# 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, admin.locale or "en",
f"User report from {user_info}", text)
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
return render_template("report/index.html", form=form, url=url, is_anon=is_anon)

View File

@ -1,51 +1,49 @@
# Content DB
# Copyright (C) 2018 rubenwardy
# 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# 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_user import *
from flask.ext import menu
from app import app, csrf
from app.models import *
from app.tasks import celery, TaskError
from app.tasks.importtasks import getMeta
from app.utils import shouldReturnJson
# from celery.result import AsyncResult
from flask_login import login_required
from app import csrf
from app.tasks import celery
from app.tasks.importtasks import getMeta
from app.utils import *
bp = Blueprint("tasks", __name__)
@csrf.exempt
@app.route("/tasks/getmeta/new/", methods=["POST"])
@bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required
def new_getmeta_page():
def start_getmeta():
author = request.args.get("author")
author = current_user.forums_username if author is None else author
aresult = getMeta.delay(request.args.get("url"), author)
return jsonify({
"poll_url": url_for("check_task", id=aresult.id),
"poll_url": url_for("tasks.check", id=aresult.id),
})
@app.route("/tasks/<id>/")
def check_task(id):
@bp.route("/tasks/<id>/")
def check(id):
result = celery.AsyncResult(id)
status = result.status
traceback = result.traceback
result = result.result
info = None
None
if isinstance(result, Exception):
info = {
'id': id,

View File

@ -0,0 +1,383 @@
# 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/>.
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.models import *
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
@bp.route("/threads/")
def list_all():
query = Thread.query
if not Permission.SEE_THREAD.check(current_user):
query = query.filter_by(private=False)
pid = request.args.get("pid")
if pid:
pid = get_int_or_abort(pid)
query = query.filter_by(package_id=pid)
query = query.filter_by(review_id=None)
query = query.order_by(db.desc(Thread.created_at))
page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100))
pagination = query.paginate(page, num, True)
return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@login_required
def subscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user in thread.watchers:
flash(gettext("Already subscribed!"), "success")
else:
flash(gettext("Subscribed to thread"), "success")
thread.watchers.append(current_user)
db.session.commit()
return redirect(thread.getViewURL())
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@login_required
def unsubscribe(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user in thread.watchers:
flash(gettext("Unsubscribed!"), "success")
thread.watchers.remove(current_user)
db.session.commit()
else:
flash(gettext("Already not subscribed!"), "success")
return redirect(thread.getViewURL())
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
@login_required
def set_lock(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.LOCK_THREAD):
abort(404)
thread.locked = isYes(request.args.get("lock"))
if thread.locked is None:
abort(400)
msg = None
if thread.locked:
msg = "Locked thread '{}'".format(thread.title)
flash(gettext("Locked thread"), "success")
else:
msg = "Unlocked thread '{}'".format(thread.title)
flash(gettext("Unlocked thread"), "success")
addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
db.session.commit()
return redirect(thread.getViewURL())
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
@login_required
def delete_thread(id):
thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.DELETE_THREAD):
abort(404)
if request.method == "GET":
return render_template("threads/delete_thread.html", thread=thread)
summary = "\n\n".join([("<{}> {}".format(reply.author.display_name, reply.comment)) for reply in thread.replies])
msg = "Deleted thread {} by {}".format(thread.title, thread.author.display_name)
db.session.delete(thread)
addAuditLog(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
db.session.commit()
return redirect(url_for("homepage.home"))
@bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"])
@login_required
def delete_reply(id):
thread = Thread.query.get(id)
if thread is None:
abort(404)
reply_id = request.args.get("reply")
if reply_id is None:
abort(404)
reply = ThreadReply.query.get(reply_id)
if reply is None or reply.thread != thread:
abort(404)
if thread.replies[0] == reply:
flash(gettext("Cannot delete thread opening post!"), "danger")
return redirect(thread.getViewURL())
if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
abort(403)
if request.method == "GET":
return render_template("threads/delete_reply.html", thread=thread, reply=reply)
msg = "Deleted reply by {}".format(reply.author.display_name)
addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
db.session.delete(reply)
db.session.commit()
return redirect(thread.getViewURL())
class CommentForm(FlaskForm):
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
submit = SubmitField(lazy_gettext("Comment"))
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@login_required
def edit_reply(id):
thread = Thread.query.get(id)
if thread is None:
abort(404)
reply_id = request.args.get("reply")
if reply_id is None:
abort(404)
reply = ThreadReply.query.get(reply_id)
if reply is None or reply.thread != thread:
abort(404)
if not reply.checkPerm(current_user, Permission.EDIT_REPLY):
abort(403)
form = CommentForm(formdata=request.form, obj=reply)
if form.validate_on_submit():
comment = form.comment.data
msg = "Edited reply by {}".format(reply.author.display_name)
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
addNotification(reply.author, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
reply.comment = comment
db.session.commit()
return redirect(thread.getViewURL())
return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form)
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id):
thread: Thread = Thread.query.get(id)
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
if current_user.is_authenticated and request.method == "POST":
comment = request.form["comment"]
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
flash(gettext("You cannot comment on this thread"), "danger")
return redirect(thread.getViewURL())
if not current_user.canCommentRL():
flash(gettext("Please wait before commenting again"), "danger")
return redirect(thread.getViewURL())
if 2000 >= len(comment) > 3:
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
thread.replies.append(reply)
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(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
return render_template("threads/view.html", thread=thread)
class ThreadForm(FlaskForm):
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"])
@login_required
def new():
form = ThreadForm(formdata=request.form)
package = None
if "pid" in request.args:
package = Package.query.get(int(request.args.get("pid")))
if package is None:
flash(gettext("Unable to find that package!"), "danger")
# Don't allow making orphan threads on approved packages for now
if package is None:
abort(403)
def_is_private = request.args.get("private") or False
if package is None:
def_is_private = True
allow_change = package and package.approved
is_review_thread = package and not package.approved
# Check that user can make the thread
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
flash(gettext("Unable to create thread!"), "danger")
return redirect(url_for("homepage.home"))
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
flash(gettext("An approval thread already exists!"), "danger")
return redirect(package.review_thread.getViewURL())
elif not current_user.canOpenThreadRL():
flash(gettext("Please wait before opening another thread"), "danger")
if package:
return redirect(package.getURL("packages.view"))
else:
return redirect(url_for("homepage.home"))
# Set default values
elif request.method == "GET":
form.private.data = def_is_private
form.title.data = request.args.get("title") or ""
# Validate and submit
elif form.validate_on_submit():
thread = Thread()
thread.author = current_user
thread.title = form.title.data
thread.private = form.private.data if allow_change else def_is_private
thread.package = package
db.session.add(thread)
thread.watchers.append(current_user)
if package is not None and package.author != current_user:
thread.watchers.append(package.author)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
thread.replies.append(reply)
db.session.commit()
if is_review_thread:
package.review_thread = thread
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)
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()
return redirect(thread.getViewURL())
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

@ -0,0 +1,85 @@
# 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/>.
from flask import abort, send_file, Blueprint, current_app
bp = Blueprint("thumbnails", __name__)
import os
from PIL import Image
ALLOWED_RESOLUTIONS=[(100,67), (270,180), (350,233)]
def mkdir(path):
assert path != "" and path is not None
try:
if not os.path.isdir(path):
os.mkdir(path)
except FileExistsError:
pass
def resize_and_crop(img_path, modified_path, size):
try:
img = Image.open(img_path)
except FileNotFoundError:
abort(404)
# Get current and desired ratio for the images
img_ratio = img.size[0] / float(img.size[1])
ratio = size[0] / float(size[1])
# Is more portrait than target, scale and crop
if ratio > img_ratio:
img = img.resize((int(size[0]), int(size[0] * img.size[1] / img.size[0])),
Image.BICUBIC)
box = (0, (img.size[1] - size[1]) / 2, img.size[0], (img.size[1] + size[1]) / 2)
img = img.crop(box)
# Is more landscape than target, scale and crop
elif ratio < img_ratio:
img = img.resize((int(size[1] * img.size[0] / img.size[1]), int(size[1])),
Image.BICUBIC)
box = ((img.size[0] - size[0]) / 2, 0, (img.size[0] + size[0]) / 2, img.size[1])
img = img.crop(box)
# Is exactly the same ratio as target
else:
img = img.resize(size, Image.BICUBIC)
img.save(modified_path)
@bp.route("/thumbnails/<int:level>/<img>")
def make_thumbnail(img, level):
if level > len(ALLOWED_RESOLUTIONS) or level <= 0:
abort(403)
w, h = ALLOWED_RESOLUTIONS[level - 1]
upload_dir = current_app.config["UPLOAD_DIR"]
thumbnail_dir = current_app.config["THUMBNAIL_DIR"]
mkdir(thumbnail_dir)
output_dir = os.path.join(thumbnail_dir, str(level))
mkdir(output_dir)
cache_filepath = os.path.join(output_dir, img)
source_filepath = os.path.join(upload_dir, img)
resize_and_crop(source_filepath, cache_filepath, (w, h))
return send_file(cache_filepath)

View File

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

View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint("users", __name__)
from . import profile, claim, account, settings

View File

@ -0,0 +1,427 @@
# ContentDB
# Copyright (C) 2020 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 *
from flask_babel import gettext, lazy_gettext, get_locale
from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
from wtforms import *
from wtforms.validators import *
from 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, post_login, is_username_valid
from passlib.pwd import genphrase
from . import bp
class LoginForm(FlaskForm):
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(gettext("Incorrect email or password"), "danger")
else:
flash(err, "danger")
username = form.username.data.strip()
user = User.query.filter(or_(User.username == username, User.email == username)).first()
if user is None:
return show_safe_err(gettext(u"User %(username)s does not exist", username=username))
if not check_password_hash(user.password, form.password.data):
return show_safe_err(gettext(u"Incorrect password. Did you set one?"))
if not user.is_active:
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()
if not login_user(user, remember=form.remember_me.data):
flash(gettext("Login failed"), "danger")
return
return post_login(user, request.args.get("next"))
@bp.route("/user/login/", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return redirect(next or url_for("homepage.home"))
form = LoginForm(request.form)
if form.validate_on_submit():
ret = handle_login(form)
if ret:
return ret
if request.method == "GET":
form.remember_me.data = True
return render_template("users/login.html", form=form)
@bp.route("/user/logout/", methods=["GET", "POST"])
def logout():
logout_user()
return redirect(url_for("homepage.home"))
class RegisterForm(FlaskForm):
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(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(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, get_locale().language, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent"))
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(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, display name=" + user.display_name,
url_for("users.profile", username=user.username))
token = randomString(32)
ver = UserEmailVerification()
ver.user = user
ver.token = token
ver.email = form.email.data
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language)
return redirect(url_for("users.email_sent"))
@bp.route("/user/register/", methods=["GET", "POST"])
def register():
form = RegisterForm(request.form)
if form.validate_on_submit():
ret = handle_register(form)
if ret:
return ret
return render_template("users/register.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
class ForgotPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password"))
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password():
form = ForgotPasswordForm(request.form)
if form.validate_on_submit():
email = form.email.data
user = User.query.filter_by(email=email).first()
if user:
token = randomString(32)
addAuditLog(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
url_for("users.profile", username=user.username), None)
ver = UserEmailVerification()
ver.user = user
ver.token = token
ver.email = email
ver.is_password_reset = True
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language)
else:
html = render_template("emails/unable_to_find_account.html")
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
html, html)
return redirect(url_for("users.email_sent"))
return render_template("users/forgot_password.html", form=form)
class SetPasswordForm(FlaskForm):
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(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(gettext("Passwords do not match"), "danger")
return
addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
current_user.password = make_flask_login_password(form.password.data)
if hasattr(form, "email"):
newEmail = nonEmptyOrNone(form.email.data)
if newEmail and newEmail != current_user.email:
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "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, get_locale().language, gettext("Email already in use"),
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
else:
token = randomString(32)
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, get_locale().language)
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("users.email_sent"))
db.session.commit()
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("homepage.home"))
@bp.route("/user/change-password/", methods=["GET", "POST"])
@login_required
def change_password():
form = ChangePasswordForm(request.form)
if form.validate_on_submit():
if check_password_hash(current_user.password, form.old_password.data):
ret = handle_set_password(form)
if ret:
return ret
else:
flash(gettext("Old password is incorrect"), "danger")
return render_template("users/change_set_password.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
@bp.route("/user/set-password/", methods=["GET", "POST"])
@login_required
def set_password():
if current_user.password:
return redirect(url_for("users.change_password"))
form = SetPasswordForm(request.form)
if current_user.email is None:
form.email.validators = [InputRequired(), Email()]
if form.validate_on_submit():
ret = handle_set_password(form)
if ret:
return ret
return render_template("users/change_set_password.html", form=form, optional=request.args.get("optional"),
suggested_password=genphrase(entropy=52, wordset="bip39"))
@bp.route("/user/verify/")
def verify_email():
token = request.args.get("token")
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
if ver is None:
flash(gettext("Unknown verification token!"), "danger")
return redirect(url_for("homepage.home"))
delta = (datetime.datetime.now() - ver.created_at)
delta: datetime.timedelta
if delta.total_seconds() > 12*60*60:
flash(gettext("Token has expired"), "danger")
db.session.delete(ver)
db.session.commit()
return redirect(url_for("homepage.home"))
user = ver.user
addAuditLog(AuditSeverity.USER, user, "Confirmed their email",
url_for("users.profile", username=user.username))
was_activating = not user.is_active
if ver.email and user.email != ver.email:
if User.query.filter_by(email=ver.email).count() > 0:
flash(gettext("Another user is already using that email"), "danger")
return redirect(url_for("homepage.home"))
flash(gettext("Confirmed email change"), "success")
if user.email:
send_user_email.delay(user.email,
user.locale or "en",
gettext("Email address changed"),
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
user.is_active = True
user.email = ver.email
db.session.delete(ver)
db.session.commit()
if ver.is_password_reset:
login_user(user)
user.password = None
db.session.commit()
return redirect(url_for("users.set_password"))
if current_user.is_authenticated:
return redirect(url_for("users.profile", username=current_user.username))
elif was_activating:
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(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Send"))
def unsubscribe_verify():
form = UnsubscribeForm(request.form)
if form.validate_on_submit():
email = form.email.data
sub = EmailSubscription.query.filter_by(email=email).first()
if not sub:
sub = EmailSubscription(email)
db.session.add(sub)
sub.token = randomString(32)
db.session.commit()
send_unsubscribe_verify.delay(form.email.data, get_locale().language)
return redirect(url_for("users.email_sent"))
return render_template("users/unsubscribe.html", form=form)
def unsubscribe_manage(sub: EmailSubscription):
user = User.query.filter_by(email=sub.email).first()
if request.method == "POST":
if user:
user.email = None
sub.blacklisted = True
db.session.commit()
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)
@bp.route("/unsubscribe/", methods=["GET", "POST"])
def unsubscribe():
token = request.args.get("token")
if token:
sub = EmailSubscription.query.filter_by(token=token).first()
if sub:
return unsubscribe_manage(sub)
return unsubscribe_verify()
@bp.route("/email_sent/")
def email_sent():
return render_template("users/email_sent.html")

View File

@ -0,0 +1,116 @@
# 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/>.
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, is_username_valid
from app.tasks.forumtasks import checkForumAccount
from app.utils.phpbbparser import getProfile
@bp.route("/user/claim/", methods=["GET", "POST"])
def claim():
return render_template("users/claim.html")
@bp.route("/user/claim-forums/", methods=["GET", "POST"])
def claim_forums():
username = request.args.get("username")
if username is None:
username = ""
else:
method = request.args.get("method")
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(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(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"))
if "forum_token" in session:
token = session["forum_token"]
else:
token = randomString(12)
session["forum_token"] = token
if request.method == "POST":
ctype = request.form.get("claim_type")
username = request.form.get("username")
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(gettext("That user has already been claimed!"), "danger")
return redirect(url_for("users.claim_forums"))
# Get signature
sig = None
try:
profile = getProfile("https://forum.minetest.net", username)
sig = profile.signature if profile else None
except IOError as e:
if hasattr(e, 'message'):
message = e.message
else:
message = str(e)
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(gettext("Unable to get forum signature - does the user exist?"), "danger")
return redirect(url_for("users.claim_forums", username=username))
# Look for key
if sig and token in sig:
# Try getting again to fix crash
user = User.query.filter_by(forums_username=username).first()
if user is None:
user = User(username)
user.forums_username = username
db.session.add(user)
db.session.commit()
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(gettext("Could not find the key in your signature!"), "danger")
return redirect(url_for("users.claim_forums", username=username))
else:
flash(gettext("Unknown claim type"), "danger")
return render_template("users/claim_forums.html", username=username, key="cdb_" + token)

View File

@ -0,0 +1,251 @@
# 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 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
from app.models import *
from app.tasks.forumtasks import checkForumAccount
from . import bp
@bp.route("/users/", methods=["GET"])
def list_all():
users = db.session.query(User, func.count(Package.id)) \
.select_from(User).outerjoin(Package) \
.order_by(db.desc(User.rank), db.asc(User.display_name)) \
.group_by(User.id).all()
return render_template("users/list.html", users=users)
@bp.route("/user/forum/<username>/")
def by_forums_username(username):
user = User.query.filter_by(forums_username=username).first()
if user:
return redirect(url_for("users.profile", username=user.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)
if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
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, maintained_packages=maintained_packages,
medals_unlocked=unlocked, medals_locked=locked)
@bp.route("/users/<username>/check/", methods=["POST"])
@login_required
def user_check(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.MODERATOR):
abort(403)
if user.forums_username is None:
abort(404)
task = checkForumAccount.delay(user.forums_username)
next_url = url_for("users.profile", username=username)
return redirect(url_for("tasks.check", id=task.id, r=next_url))

View File

@ -0,0 +1,368 @@
from flask import *
from flask_babel import gettext, lazy_gettext, get_locale
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 *
from app.models import *
from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required
from app.tasks.emails import send_verify_email
from . import bp
def get_setting_tabs(user):
ret = [
{
"id": "edit_profile",
"title": gettext("Edit Profile"),
"url": url_for("users.profile_edit", username=user.username)
},
{
"id": "account",
"title": gettext("Account and Security"),
"url": url_for("users.account", username=user.username)
},
{
"id": "notifications",
"title": gettext("Email and Notifications"),
"url": url_for("users.email_notifications", username=user.username)
},
{
"id": "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):
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"])
@login_required
def profile_edit(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(gettext("Permission denied"), "danger")
return redirect(url_for("users.profile", username=username))
form = UserProfileForm(obj=user)
if form.validate_on_submit():
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")
def make_settings_form():
attrs = {
"email": StringField(lazy_gettext("Email"), [Optional(), Email()]),
"submit": SubmitField(lazy_gettext("Save"))
}
for notificationType in NotificationType:
key = "pref_" + notificationType.toName()
attrs[key] = BooleanField("")
attrs[key + "_digest"] = BooleanField("")
return type("SettingsForm", (FlaskForm,), attrs)
SettingsForm = make_settings_form()
def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form):
for notificationType in NotificationType:
field_email = getattr(form, "pref_" + notificationType.toName()).data
field_digest = getattr(form, "pref_" + notificationType.toName() + "_digest").data or field_email
prefs.set_can_email(notificationType, field_email)
prefs.set_can_digest(notificationType, field_digest)
if is_new:
db.session.add(prefs)
if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
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(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return
token = randomString(32)
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
msg = "Changed email of {}".format(user.display_name)
addAuditLog(severity, current_user, msg, url_for("users.profile", username=user.username))
ver = UserEmailVerification()
ver.user = user
ver.token = token
ver.email = newEmail
db.session.add(ver)
db.session.commit()
send_verify_email.delay(newEmail, token, get_locale().language)
return redirect(url_for("users.email_sent"))
db.session.commit()
return redirect(url_for("users.email_notifications", username=user.username))
@bp.route("/user/settings/email/")
@bp.route("/users/<username>/settings/email/", methods=["GET", "POST"])
@login_required
def email_notifications(username=None):
if username is None:
return redirect(url_for("users.email_notifications", username=current_user.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)
is_new = False
prefs = user.notification_preferences
if prefs is None:
is_new = True
prefs = UserNotificationPreferences(user)
data = {}
types = []
for notificationType in NotificationType:
types.append(notificationType)
data["pref_" + notificationType.toName()] = prefs.get_can_email(notificationType)
data["pref_" + notificationType.toName() + "_digest"] = prefs.get_can_digest(notificationType)
data["email"] = user.email
form = SettingsForm(data=data)
if form.validate_on_submit():
ret = handle_email_notifications(user, prefs, is_new, form)
if ret:
return ret
return render_template("users/settings_email.html",
form=form, user=user, types=types, is_new=is_new,
tabs=get_setting_tabs(user), current_tab="notifications")
@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)
return render_template("users/account.html", user=user, tabs=get_setting_tabs(user), current_tab="account")
@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 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)
if user.checkPerm(current_user, Permission.CHANGE_RANK):
newRank = form["rank"].data
if current_user.rank.atLeast(newRank):
if newRank != user.rank:
user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
addAuditLog(AuditSeverity.MODERATION, current_user, msg,
url_for("users.profile", username=username))
else:
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
db.session.commit()
return redirect(url_for("users.modtools", username=username))
return render_template("users/modtools.html", user=user, form=form, tabs=get_setting_tabs(user), current_tab="modtools")
@bp.route("/users/<username>/modtools/set-email/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_set_email(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
abort(403)
user.email = request.form["email"]
user.is_active = False
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()
send_verify_email.delay(user.email, token, user.locale or "en")
flash(f"Set email and sent a password reset on {user.username}", "success")
return redirect(url_for("users.modtools", username=username))
@bp.route("/users/<username>/modtools/ban/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_ban(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.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

@ -1,83 +1,115 @@
# Content DB
# Copyright (C) 2018 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from .models import *
from .utils import make_flask_login_password
import os, sys, datetime
def populate(session):
admin_user = User("rubenwardy")
admin_user.is_active = True
admin_user.password = make_flask_login_password("tuckfrump")
admin_user.github_username = "rubenwardy"
admin_user.forums_username = "rubenwardy"
admin_user.rank = UserRank.ADMIN
session.add(admin_user)
if not "FLASK_CONFIG" in os.environ:
os.environ["FLASK_CONFIG"] = "../config.cfg"
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)
test_data = len(sys.argv) >= 2 and sys.argv[1].strip() == "-t"
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))
from app.models import *
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", "Featured"]:
row = Tag(tag)
tags[row.name] = row
session.add(row)
licenses = {}
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
"CC-BY", "MIT", "ZLib", "Other (Free)"]:
row = License(license)
licenses[row.name] = row
session.add(row)
for license in ["CC-BY-NC-SA", "Other (Non-free)"]:
row = License(license, False)
licenses[row.name] = row
session.add(row)
def populate_test_data(session):
licenses = { x.name : x for x in License.query.all() }
tags = { x.name : x for x in Tag.query.all() }
admin_user = User.query.filter_by(rank=UserRank.ADMIN).first()
v4 = MinetestRelease.query.filter_by(protocol=32).first()
v50 = MinetestRelease.query.filter_by(protocol=37).first()
v51 = MinetestRelease.query.filter_by(protocol=38).first()
def defineDummyData(licenses, tags, ruben):
ez = User("Shara")
ez.github_username = "Ezhh"
ez.forums_username = "Shara"
ez.rank = UserRank.EDITOR
db.session.add(ez)
session.add(ez)
not1 = Notification(ruben, ez, "Awards approved", "/packages/rubenwardy/awards/")
db.session.add(not1)
not1 = Notification(admin_user, ez, NotificationType.PACKAGE_APPROVAL, "Awards approved", "/packages/rubenwardy/awards/")
session.add(not1)
jeija = User("Jeija")
jeija.github_username = "Jeija"
db.session.add(jeija)
jeija.forums_username = "Jeija"
session.add(jeija)
mod = Package()
mod.approved = True
mod.state = PackageState.APPROVED
mod.name = "alpha"
mod.title = "Alpha Test"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ruben
mod.author = admin_user
mod.tags.append(tags["mapgen"])
mod.tags.append(tags["environment"])
mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015
mod.shortDesc = "The content library should not be used yet as it is still in alpha"
mod.short_desc = "The content library should not be used yet as it is still in alpha"
mod.desc = "This is the long desc"
db.session.add(mod)
session.add(mod)
rel = PackageRelease()
rel.package = mod
rel.title = "v1.0.0"
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
rel.approved = True
db.session.add(rel)
session.add(rel)
mod1 = Package()
mod1.approved = True
mod1.state = PackageState.APPROVED
mod1.name = "awards"
mod1.title = "Awards"
mod1.license = licenses["LGPLv2.1"]
mod1.media_license = licenses["MIT"]
mod1.type = PackageType.MOD
mod1.author = ruben
mod1.author = admin_user
mod1.tags.append(tags["player_effects"])
mod1.repo = "https://github.com/rubenwardy/awards"
mod1.issueTracker = "https://github.com/rubenwardy/awards/issues"
mod1.forums = 4870
mod1.shortDesc = "Adds achievements and an API to register new ones."
mod1.short_desc = "Adds achievements and an API to register new ones."
mod1.desc = """
Majority of awards are back ported from Calinou's old fork in Carbone, under same license.
```
```lua
awards.register_achievement("award_mesefind",{
title = "First Mese Find",
description = "Found some Mese!",
@ -92,34 +124,26 @@ awards.register_achievement("award_mesefind",{
rel = PackageRelease()
rel.package = mod1
rel.min_rel = v51
rel.title = "v1.0.0"
rel.url = "https://github.com/rubenwardy/awards/archive/master.zip"
rel.approved = True
db.session.add(rel)
session.add(rel)
mod2 = Package()
mod2.approved = True
mod2.state = PackageState.APPROVED
mod2.name = "mesecons"
mod2.title = "Mesecons"
mod2.tags.append(tags["tools"])
mod2.type = PackageType.MOD
mod2.license = licenses["LGPLv3"]
mod2.media_license = licenses["MIT"]
mod2.author = jeija
mod2.repo = "https://github.com/minetest-mods/mesecons/"
mod2.issueTracker = "https://github.com/minetest-mods/mesecons/issues"
mod2.forums = 628
mod2.shortDesc = "Mesecons adds everything digital, from all kinds of sensors, switches, solar panels, detectors, pistons, lamps, sound blocks to advanced digital circuitry like logic gates and programmable blocks."
mod2.short_desc = "Mesecons adds everything digital, from all kinds of sensors, switches, solar panels, detectors, pistons, lamps, sound blocks to advanced digital circuitry like logic gates and programmable blocks."
mod2.desc = """
########################################################################
## __ __ _____ _____ _____ _____ _____ _ _ _____ ##
## | \ / | | ___| | ___| | ___| | ___| | _ | | \ | | | ___| ##
## | \/ | | |___ | |___ | |___ | | | | | | | \| | | |___ ##
## | |\__/| | | ___| |___ | | ___| | | | | | | | | |___ | ##
## | | | | | |___ ___| | | |___ | |___ | |_| | | |\ | ___| | ##
## |_| |_| |_____| |_____| |_____| |_____| |_____| |_| \_| |_____| ##
## ##
########################################################################
MESECONS by Jeija and contributors
Mezzee-what?
@ -192,36 +216,39 @@ No warranty is provided, express or implied, for any part of the project.
"""
db.session.add(mod1)
db.session.add(mod2)
session.add(mod1)
session.add(mod2)
mod = Package()
mod.approved = True
mod.state = PackageState.APPROVED
mod.name = "handholds"
mod.title = "Handholds"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ez
mod.tags.append(tags["player_effects"])
mod.repo = "https://github.com/ezhh/handholds"
mod.issueTracker = "https://github.com/ezhh/handholds/issues"
mod.forums = 17069
mod.shortDesc = "Adds hand holds and climbing thingies"
mod.short_desc = "Adds hand holds and climbing thingies"
mod.desc = "This is the long desc"
db.session.add(mod)
session.add(mod)
rel = PackageRelease()
rel.package = mod
rel.title = "v1.0.0"
rel.max_rel = v4
rel.url = "https://github.com/ezhh/handholds/archive/master.zip"
rel.approved = True
db.session.add(rel)
session.add(rel)
mod = Package()
mod.approved = True
mod.state = PackageState.APPROVED
mod.name = "other_worlds"
mod.title = "Other Worlds"
mod.license = licenses["MIT"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ez
mod.tags.append(tags["mapgen"])
@ -229,124 +256,138 @@ No warranty is provided, express or implied, for any part of the project.
mod.repo = "https://github.com/ezhh/other_worlds"
mod.issueTracker = "https://github.com/ezhh/other_worlds/issues"
mod.forums = 16015
mod.shortDesc = "Adds space with asteroids and comets"
mod.short_desc = "Adds space with asteroids and comets"
mod.desc = "This is the long desc"
db.session.add(mod)
session.add(mod)
mod = Package()
mod.approved = True
mod.state = PackageState.APPROVED
mod.name = "food"
mod.title = "Food"
mod.license = licenses["LGPLv2.1"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.author = ruben
mod.author = admin_user
mod.tags.append(tags["player_effects"])
mod.repo = "https://github.com/rubenwardy/food/"
mod.issueTracker = "https://github.com/rubenwardy/food/issues/"
mod.forums = 2960
mod.shortDesc = "Adds lots of food and an API to manage ingredients"
mod.short_desc = "Adds lots of food and an API to manage ingredients"
mod.desc = "This is the long desc"
food = mod
db.session.add(mod)
session.add(mod)
mod = Package()
mod.approved = True
mod.state = PackageState.APPROVED
mod.name = "food_sweet"
mod.title = "Sweet Foods"
mod.license = licenses["CC0"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.MOD
mod.harddeps.append(food)
mod.author = ruben
mod.author = admin_user
mod.tags.append(tags["player_effects"])
mod.repo = "https://github.com/rubenwardy/food_sweet/"
mod.issueTracker = "https://github.com/rubenwardy/food_sweet/issues/"
mod.forums = 9039
mod.shortDesc = "Adds sweet food"
mod.short_desc = "Adds sweet food"
mod.desc = "This is the long desc"
db.session.add(mod)
food_sweet = mod
session.add(mod)
game1 = Package()
game1.approved = True
game1.state = PackageState.APPROVED
game1.name = "capturetheflag"
game1.title = "Capture The Flag"
game1.type = PackageType.GAME
game1.license = licenses["LGPLv2.1"]
game1.author = ruben
game1.media_license = licenses["MIT"]
game1.author = admin_user
game1.tags.append(tags["pvp"])
game1.tags.append(tags["survival"])
game1.tags.append(tags["multiplayer"])
game1.repo = "https://github.com/rubenwardy/capturetheflag"
game1.issueTracker = "https://github.com/rubenwardy/capturetheflag/issues"
game1.forums = 12835
game1.shortDesc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
game1.short_desc = "Two teams battle to snatch and return the enemy's flag, before the enemy takes their own!"
game1.desc = """
As seen on the Capture the Flag server (minetest.rubenwardy.com:30000)
` `[`javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/"/+/onmouseover=1/+/`](javascript:/*--%3E%3C/title%3E%3C/style%3E%3C/textarea%3E%3C/script%3E%3C/xmp%3E%3Csvg/onload='+/%22/+/onmouseover=1/+/)`[*/[]/+alert(1)//'>`
<IMG SRC="javascript:alert('XSS');">
<IMG SRC=javascript:alert(&amp;quot;XSS&amp;quot;)>
``<IMG SRC=`javascript:alert("RSnake says, 'XSS'")`>``
\<a onmouseover="alert(document.cookie)"\>xxs link\</a\>
\<a onmouseover=alert(document.cookie)\>xxs link\</a\>
<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>
<script>alert("hello");</script>
<SCRIPT SRC=`[`http://xss.rocks/xss.js></SCRIPT>`](http://xss.rocks/xss.js%3E%3C/SCRIPT%3E)`;`
`<IMG \"\"\">`
<SCRIPT>
alert("XSS")
</SCRIPT>
<IMG SRC= onmouseover="alert('xxs')">
<img src=x onerror="&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041">
"\>
Uses the CTF PvP Engine.
"""
db.session.add(game1)
session.add(game1)
rel = PackageRelease()
rel.package = game1
rel.title = "v1.0.0"
rel.url = "https://github.com/rubenwardy/capturetheflag/archive/master.zip"
rel.approved = True
db.session.add(rel)
session.add(rel)
mod = Package()
mod.approved = True
mod.state = PackageState.APPROVED
mod.name = "pixelbox"
mod.title = "PixelBOX Reloaded"
mod.license = licenses["CC0"]
mod.media_license = licenses["MIT"]
mod.type = PackageType.TXP
mod.author = ruben
mod.author = admin_user
mod.forums = 14132
mod.shortDesc = "This is an update of the original PixelBOX texture pack by the brillant artist Gambit"
mod.short_desc = "This is an update of the original PixelBOX texture pack by the brillant artist Gambit"
mod.desc = "This is the long desc"
db.session.add(mod)
session.add(mod)
rel = PackageRelease()
rel.package = mod
rel.title = "v1.0.0"
rel.url = "http://mamadou3.free.fr/Minetest/PixelBOX.zip"
rel.approved = True
db.session.add(rel)
session.add(rel)
session.commit()
delete_db = len(sys.argv) >= 2 and sys.argv[1].strip() == "-d"
if delete_db and os.path.isfile("db.sqlite"):
os.remove("db.sqlite")
metas = {}
for package in Package.query.filter_by(type=PackageType.MOD).all():
meta = None
try:
meta = metas[package.name]
except KeyError:
meta = MetaPackage(package.name)
session.add(meta)
metas[package.name] = meta
package.provides.append(meta)
print("Creating database tables...")
db.create_all()
print("Filling database...")
ruben = User("rubenwardy")
ruben.github_username = "rubenwardy"
ruben.forums_username = "rubenwardy"
ruben.rank = UserRank.ADMIN
db.session.add(ruben)
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"]:
row = Tag(tag)
tags[row.name] = row
db.session.add(row)
licenses = {}
for license in ["GPLv2.1", "GPLv3", "LGPLv2.1", "LGPLv3", "AGPLv2.1", "AGPLv3",
"Apache", "BSD 3-Clause", "BSD 2-Clause", "CC0", "CC-BY-SA",
"CC-BY", "CC-BY-NC-SA", "MIT", "ZLib"]:
row = License(license)
licenses[row.name] = row
db.session.add(row)
if test_data:
defineDummyData(licenses, tags, ruben)
db.session.commit()
dep = Dependency(food_sweet, meta=metas["food"])
session.add(dep)

View File

@ -1,4 +1,30 @@
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)
* [Package Tags](package_tags)
* [Contact Us](contact_us)
* [Top Packages Algorithm](top_packages)
* [Featured Packages](featured)
## Help for Package Authors
* [Package Inclusion Policy and Guidance](/policy_and_guidance/)
* [Git Update Detection](update_config)
* [Creating Releases using Webhooks](release_webhooks)
* [Package Configuration and Releases Guide](package_config)
## Help for Specific User Ranks
* [Editors](editors)
## APIs
* [API](api)
* [Prometheus Metrics](metrics)

398
app/flatpages/help/api.md Normal file
View File

@ -0,0 +1,398 @@
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:
```json
{
"success": false,
"error": "The error message"
}
```
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. 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
Not all endpoints require authentication, but it is done using Bearer tokens:
```bash
curl https://content.minetest.net/api/whoami/ \
-H "Authorization: Bearer YOURTOKEN"
```
Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* GET `/api/whoami/`: JSON dictionary with the following keys:
* `is_authenticated`: True on successful API authentication
* `username`: Username of the user authenticated as, null otherwise.
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
## Packages
* GET `/api/packages/` (List)
* See [Package Queries](#package-queries)
* GET `/api/packages/<username>/<name>/` (Read)
* PUT `/api/packages/<author>/<name>/` (Update)
* Requires authentication.
* JSON dictionary with any of these keys (all are optional, null to delete Nullables):
* `type`: One of `GAME`, `MOD`, `TXP`.
* `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](#tags) names.
* `content_warnings`: List of [content warning](#content-warnings) names.
* `license`: A [license](#licenses) name.
* `media_license`: A [license](#licenses) name.
* `long_description`: Long markdown description.
* `repo`: Git repo URL.
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* `video_url`: URL to a video.
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
* 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 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 https://content.minetest.net/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "website": null }'
```
### Package Queries
Example:
/api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore
Supported query parameters:
* `type`: Package types (`mod`, `game`, `txp`).
* `q`: Query string.
* `author`: Filter by author.
* `tag`: Filter by tags.
* `random`: When present, enable random ordering and ignore `sort`.
* `limit`: Return at most `limit` packages.
* `hide`: Hide content based on [Content Flags](/help/content_flags/).
* `sort`: Sort by (`name`, `title`, `score`, `reviews`, `downloads`, `created_at`, `approved_at`, `last_release`).
* `order`: Sort ascending (`asc`) or descending (`desc`).
* `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* `fmt`: How the response is formated.
* `keys`: author/name only.
* `short`: stuff needed for the Minetest client.
## Releases
* 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
* `release_date`: Date released
* `url`: download URL
* `commit`: commit hash or null
* `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.
* Body can be JSON or multipart form data. Zip uploads must be multipart form data.
* `title`: human-readable name of the release.
* For Git release creation:
* `method`: must be `git`.
* `ref`: (Optional) git reference, eg: `master`.
* For zip upload release creation:
* `file`: multipart file to upload, like `<input type="file" name="file">`.
* `commit`: (Optional) Source Git commit hash, for informational purposes.
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).
* DELETE `/api/packages/<username>/<name>/releases/<id>/` (Delete)
* Requires authentication.
* Deletes release.
Examples:
```bash
# Create release from Git
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "method": "git", "title": "My Release", "ref": "master" }'
# Create release from zip upload
curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \
-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"
```
## Screenshots
* GET `/api/packages/<username>/<name>/screenshots/` (List)
* Returns array of screenshot dictionaries with keys:
* `id`: screenshot ID
* `approved`: true if approved and visible.
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
* `url`: absolute URL to screenshot.
* `created_at`: ISO time.
* `order`: Number used in ordering.
* `is_cover_image`: true for cover image.
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
* Returns screenshot dictionary like above.
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
* Requires authentication.
* Body is multipart form data.
* `title`: human-readable name for the screenshot, shown as a caption and alt text.
* `file`: multipart file to upload, like `<input type=file>`.
* `is_cover_image`: set cover image to this.
* DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
* Requires authentication.
* Deletes screenshot.
* POST `/api/packages/<username>/<name>/screenshots/order/`
* Requires authentication.
* Body is a JSON array containing the screenshot IDs in their order.
* POST `/api/packages/<username>/<name>/screenshots/cover-image/`
* Requires authentication.
* Body is a JSON dictionary with "cover_image" containing the screenshot ID.
Currently, to get a different size of thumbnail you can replace the number in `/thumbnails/1/` with any number from 1-3.
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
Examples:
```bash
# Create screenshot
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png
# Create screenshot and set it as the cover image
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
# Delete screenshot
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
-H "Authorization: Bearer YOURTOKEN"
# Reorder screenshots
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "[13, 2, 5, 7]"
# Set cover image
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/cover-image/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "{ 'cover_image': 123 }"
```
## Reviews
* GET `/api/packages/<username>/<name>/reviews/` (List)
* Returns array of review dictionaries with keys:
* `user`: dictionary with `display_name` and `username`.
* `title`: review title
* `comment`: the text
* `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/` ([View](/api/topics/))
* See [Topic Queries](#topic-queries)
### Topic Queries
Example:
/api/topics/?q=mobs&type=mod&type=game
Supported query parameters:
* `q`: Query string.
* `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
### Tags
* GET `/api/tags/` ([View](/api/tags/)): List of:
* `name`: technical name.
* `title`: human-readable title.
* `description`: tag description or null.
* `is_protected`: boolean, whether the tag is protected (can only be set by Editors in the web interface).
* `views`: number of views of this tag.
### Content Warnings
* GET `/api/content_warnings/` ([View](/api/content_warnings/)): List of:
* `name`: technical name
* `title`: human-readable title
* `description`: tag description or null
### Licenses
* GET `/api/licenses/` ([View](/api/licenses/)): List of:
* `name`
* `is_foss`: whether the license is foss
### 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
* `updated`: recently updated packages
* `pop_mod`: popular mods
* `pop_txp`: popular textures
* `pop_game`: popular games
* `high_reviewed`: highest reviewed
* GET `/api/welcome/v1/` ([View](/api/welcome/v1/)) - in-menu welcome dialog. Experimental (may change without warning)
* `featured`: featured games

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

@ -0,0 +1,41 @@
title: Content Flags
Content flags allow you to hide content based on your preferences.
The filtering is done server-side, which means that you don't need to update
your client to use new flags.
## Flags
Minetest allows you to specify a comma-separated list of flags to hide in the
client:
```
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.
* `wip`: packages marked as Work in Progress
* `deprecated`: packages marked as Deprecated
* A content warning, given below.
* `*`: hides all content warnings.
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list
without making a release.
* `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages
## Content Warnings
Packages with mature content will be tagged with a content warning based
on the content type.
* `bad_language`: swearing.
* `drugs`: drugs or alcohol.
* `gambling`
* `gore`: blood, etc.
* `horror`: shocking and scary content.
* `violence`: non-cartoon violence.

View File

@ -0,0 +1,34 @@
title: Editors
## What should editors do?
Editors are users of rank Editor or above.
They are responsible for ensuring that the package listings of ContentDB are useful.
For this purpose, they can/will:
* Review and approve packages.
* Edit any package - including tags, releases, screenshots, and maintainers.
* Create packages on behalf of authors who aren't present.
Editors should make sure they are familiar with the
[Package Inclusion Policy and Guidance](/policy_and_guidance/).
## ContentDB is not a curated platform
It's important to note that ContentDB isn't a curated platform, but it also does have some
requirements on minimum usefulness. See 2.2 in the [Policy and Guidance](/policy_and_guidance/).
## Editor Work Queue
The [Editor Work Queue](/todo/) and related pages contain useful information for editors, such as:
* The package, release, and screenshot approval queues.
* Packages which are outdated or are missing tags.
* A list of forum topics without packages.
Editors can create the packages or "discard" them if they don't think it's worth adding them.
## Editor Notifications
Editors currently receive notifications for any new thread opened on a package, so that they
know when a user is asking for help. These notifications are shown separately in the notifications
interface, and can be configured separately in Emails and Notifications.

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 1280x720 pixels).
It may be shown cropped to 16:9 aspect ratio, or shorter.
* MUST: mod.conf/game.conf/texture_pack.conf present with:
* name (if mod or game)
* description
* dependencies (if relevant)
* `min_minetest_version` and `max_minetest_version` (if relevant)
* MUST: Contain a README file and a LICENSE file. These may be `.md` or `.txt`.
* README files typically contain helpful links (download, manual, bugtracker, etc), and other
information that players or (potential) contributors may need.
* SHOULD: All important settings are in settingtypes.txt with description.
## Game-specific Requirements
### Meta and packaging
* MUST: Have a main menu icon and header image.
### Stability
* MUST: If any major setting (like `enable_damage`) is unsupported, the game must disable it
using `disabled_settings` in the `game.conf`, and deal with it appropriately in the code
(e.g. force-disable the setting, as the user may still set the setting in `minetest.conf`)
### Usability
* MUST: Unsupported mapgens are disabled in game.conf.
* SHOULD: Passes the Beginner Test: A newbie to the game (but not Minetest) wouldn't get completely
stuck within the first 5 minutes of playing.
* SHOULD: Have good documentation. This may include one or more of:
* A craftguide, or other in-game learning system
* A manual
* A wiki
* Something else
### Gameplay
* CAN: Passes the Six Hour Test (only applies to sandbox games): The game doesn't run out of new
content before the first 6 hours of playing.
* CAN: Players don't feel that something in the game is "lacking".
### Audiovisuals
* MUST: Audiovisual design should be of good quality.
* MUST: No obvious GUI/HUD breakages.
* MUST: Sounds have no obvious artifacts like clicks or unintentional noise.
* SHOULD: Graphical design is mostly consistent.
* SHOULD: Sounds are used.
* SHOULD: Sounds are normalized (more or less).
### Quality Assurance
* MUST: No flooding the console/log file with warnings.
* MUST: No duplicate crafting recipes.
* MUST: Highly experimental game features are disabled by default.
* MUST: Experimental game features are clearly marked as such.
* SHOULD: No unknown nodes/items/objects appear.
* SHOULD: No dependency on legacy API calls.
* SHOULD: No console warnings.
### Writing
* MUST: All items that can be obtained in normal gameplay have `description` set (whether in the definition or meta).
* MUST: Game is not littered with typos or bad grammar (a few typos are OK but should be fixed, when found).
* SHOULD: All items have unique names (items which disguise themselves as another item are exempt).
* SHOULD: The writing style of all item names is grammatical and consistent.
* SHOULD: Descriptions of things convey useful and meaningful information (if applicable).
* CAN: Text is written in clear and (if possible) simple language.

View File

@ -0,0 +1,23 @@
title: Prometheus Metrics
## What is Prometheus?
[Prometheus](https://prometheus.io) is an "open-source monitoring system with a
dimensional data model, flexible query language, efficient time series database
and modern alerting approach".
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
* `contentdb_packages` - Total packages (counter).
* `contentdb_users` - Number of registered users (counter).
* `contentdb_downloads` - Total downloads (counter).
* `contentdb_score` - Total package score (gauge).

View File

@ -0,0 +1,73 @@
title: Non-free Licenses
## What are Non-Free, Free, and Open Source licenses?
A non-free license is one that does not meet the
[Free Software Definition](https://www.gnu.org/philosophy/free-sw.en.html)
or the [Open Source Definition](https://opensource.org/osd).
ContentDB will clearly label any packages with non-free licenses,
and they will be subject to limited promotion.
## How does ContentDB deal with Non-Free Licenses?
**ContentDB does not allow certain non-free licenses, and will limit the promotion
of packages with non-free licenses.**
Minetest is free and open source software, and is only as big as it is now
because of this. It's pretty amazing you can take nearly any published mod and modify it
to how you like - add some features, maybe fix some bugs - and then share those
modifications without the worry of legal issues. The project, itself, relies on open
source contributions to survive - if it were non-free, then it would have died
when celeron55 lost interest.
If you have played nearly any game with a large modding scene, you will find
that most mods are legally ambiguous. A lot of them don't even provide the
source code to allow you to bug fix or extend as you need.
Limiting the promotion of problematic licenses helps Minetest avoid ending up in
such a state. Licenses that prohibit redistribution or modification are
completely banned from ContentDB and the Minetest forums. Other non-free licenses
will be subject to limited promotion - they won't be shown by default in
the client.
Not providing full promotion on ContentDB, or not allowing your package at all,
doesn't mean you can't make such content - it just means we're not going to help
you spread it.
## What's so bad about licenses that forbid commercial use?
Please read [reasons not to use a Creative Commons -NC license](https://freedomdefined.org/Licenses/NC).
Here's a quick summary related to Minetest content:
1. They make your work incompatible with a growing body of free content, even if
you do want to allow derivative works or combinations.
This means that it can cause problems when another modder wishes to include your
work in a modpack or game.
2. They may rule out other basic and beneficial uses that you want to allow.
For example, CC -NC will forbid showing your content in a monetised YouTube
video.
3. They are unlikely to increase the potential profit from your work, and a
share-alike license serves the goal to protect your work from unethical
exploitation equally well.
## How can I show non-free packages in the client?
Non-free packages are hidden in the client by default, partly in order to comply
with the rules of various Linux distributions.
Users can opt-in to showing non-free software, if they wish:
1. In the main menu, go to Settings > All settings
2. Search for "ContentDB Flag Blacklist".
3. Edit that setting to remove `nonfree, `.
<figure class="figure my-4">
<img class="figure-img img-fluid rounded" src="/static/contentdb_flag_blacklist.png" alt="Screenshot of the ContentDB Flag Blacklist setting">
<figcaption class="figure-caption">Screenshot of the ContentDB Flag Blacklist setting</figcaption>
</figure>
In the future, [the `platform_default` flag](/help/content_flags/) will be used to control what content
each platforms shows - Android is significantly stricter about mature content.
You may wish to remove all text from that setting completely, leaving it blank,
if you wish to view all content when this happens. Currently, [mature content is
not permitted on ContentDB](/policy_and_guidance/).

View File

@ -0,0 +1,128 @@
title: Package Configuration and Releases Guide
## Introduction
ContentDB will read configuration files in your package when doing several
tasks, including package and release creation. This page details how you can use
this to your advantage.
## .conf files
### What is a content .conf file?
Every type of content can have a `.conf` file that contains the metadata.
The filename of the `.conf` file depends on the content type:
* `mod.conf` for mods.
* `modpack.conf` for mod packs.
* `game.conf` for games.
* `texture_pack.conf` for texture packs.
The `.conf` uses a key-value format, separated using equals. Here's a simple example:
name = mymod
description = A short description to show in the client.
### Understood values
ContentDB understands the following information:
* `description` - A short description to show in the client.
* `depends` - Comma-separated hard dependencies.
* `optional_depends` - Comma-separated soft dependencies.
* `min_minetest_version` - The minimum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
* `max_minetest_version` - The maximum Minetest version this runs on, see [Min and Max Minetest Versions](#min_max_versions).
and for mods only:
* `name` - the mod technical name.
## .cdb.json
You can include a `.cdb.json` file in the root of your content directory (ie: next to a .conf)
to update the package meta.
It should be a JSON dictionary with one or more of the following optional keys:
* `type`: One of `GAME`, `MOD`, `TXP`.
* `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/).
* `media_license`: A license name.
* `long_description`: Long markdown description.
* `repo`: Git repo URL.
* `website`: Website URL.
* `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID.
* `video_url`: URL to a video.
Use `null` to unset fields where relevant.
Example:
```json
{
"title": "Foo bar",
"tags": ["pvp", "survival"],
"license": "MIT",
"website": null
}
```
## Controlling Release Creation
### Git-based Releases and Submodules
ContentDB can create releases from a Git repository.
It will include submodules in the resulting archive.
Simply set VCS Repository in the package's meta to a Git repository, and then
choose Git as the method when creating a release.
### Automatic Release Creation
See [Git Update Detection](/help/update_config/).
You can also use [GitLab/GitHub webhooks](/help/release_webhooks/) or the [API](/help/api/)
to create releases.
### Min and Max Minetest Versions
<a name="min_max_versions" />
When creating a release, the `.conf` file will be read to determine what Minetest
versions the release supports. If the `.conf` doesn't specify, then it is assumed
that it supports all versions.
This happens when you create a release via the ContentDB web interface, the
[API](/help/api/), or using a [GitLab/GitHub webhook](/help/release_webhooks/).
Here's an example config:
name = mymod
min_minetest_version = 5.0
max_minetest_version = 5.3
Leaving out min or max to have them set as "None".
### Excluding files
When using Git to create releases,
you can exclude files from a release by using [gitattributes](https://git-scm.com/docs/gitattributes):
.* export-ignore
sources export-ignore
*.zip export-ignore
This will prevent any files from being included if they:
* Beginning with `.`
* or are named `sources` or are inside any directory named `sources`.
* or have an extension of "zip".

View File

@ -1,27 +0,0 @@
title: Package Tags
## Overview
Tags should be added to packages to enable easy identification of different types of mods and games.
They are only beneficial when applied correctly, so please use the following guidelines.
## Tag Usage
* **Inventory** - For mods that add new inventory systems or new inventory pages.
* **Mapgen** - For mods that add new biomes, new mapgen decorations, or any other mapgen elements.
* **Building** - For mods that focus on adding new materials or nodes to build with.
* **Mobs and NPCs** - For mods that add mobs or NPCs, or provide tools that assist with mob and NPC creation or manipulation.
* **Tools** - For mods that add new tools or new features for existing tools.
* **Player effects** - For mods that change player effects, for example speed, jump height or gravity.
* **Environment** - For mods that add environmental effects, including ambient sound and weather effects.
* **Transport** - For mods that add transportation methods. This includes teleportation, vehicles and ridable mobs.
* **Maintenance** - For mods that assist with world or player maintenance. This includes large-scale map manipulation, area protection and other administrative tools.
* **Plants and farming** - For mods that add new plants or other farmable resources.
* **Survival** - For mods written specifically for survival games. For example, these mods might focus on game-balance or increase the difficulty level. This tag should also be used for games with a heavy survival focus.
* **Creative** - For mods written specifically (and often exclusively) for use in creative mode. For example, these mods may add a large amount of decorative content, or content without crafting recipes. This tag should also be used for games with a heavy creative focus.
* **PvP** - For games designed to be played competitively against other players.
* **PvE** - For games designed for one or multiple players which focus on combat against mobs or NPCs.
* **Puzzle** - For games with a focus on puzzle solving instead of combat.
* **Multiplayer** - For games that can be played with other players.
* **Singleplayer** - For games that can be played alone.

View File

@ -3,22 +3,26 @@ title: Ranks and Permissions
## Overview
* **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
* **Members** - Trusted to change the meta data of their own packages', but cannot publish releases.
* **Editors** - Trusted to change the meta data of any package, and also make and publish releases.
* **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.
* **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.
## Breakdown
<table class="fancyTable">
<table class="table table-striped ranks-table">
<thead>
<tr>
<th>Rank</th>
<th colspan=2>New Member</th>
<th colspan=2>Member</th>
<th colspan=2>Editor</th>
<th colspan=2>Moderator</th>
<th colspan=2>Admin</th>
<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>
</tr>
<tr>
<th>Owner of thing</th>
@ -32,169 +36,271 @@ title: Ranks and Permissions
<th>N</th>
<th>Y</th>
<th>N</th>
<th>Y</th>
<th>N</th>
<th>Y</th>
<th>N</th>
</tr>
</thead>
<tbody>
<tr>
<td>Create Package</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Approve Package</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Delete Package</td>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Edit Package</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Edit Maintainers</td>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Add/Delete Screenshot</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Approve Screenshot</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
</tr>
<tr>
<td>Approve EditRequest</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
</tr>
<tr>
<td>Edit EditRequest</td>
<th><sup>1</sup></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Make Release</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Approve Release</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Change Release URL</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<th></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>See Private Thread</td>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Edit Comments</td>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<td></td>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Set Email</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th></th> <!-- moderator -->
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<th><sup>2</sup></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Create Token</td>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<td></td> <!-- moderator -->
<th><sup>2</sup></th>
<td></td> <!-- admin -->
<td></td>
</tr>
<tr>
<td>Set Rank</td>
<th></th> <!-- new -->
<th></th>
<th></th> <!-- member -->
<th></th>
<th></th> <!-- editor -->
<th></th>
<th><sup>3</sup></th> <!-- moderator -->
<th><sup>2</sup><sup>3</sup></th>
<th></th> <!-- admin -->
<th></th>
<td></td> <!-- new -->
<td></td>
<td></td> <!-- member -->
<td></td>
<td></td> <!-- trusted member -->
<td></td>
<td></td> <!-- approver -->
<td></td>
<td></td> <!-- editor -->
<td></td>
<th><sup>2</sup></th> <!-- moderator -->
<th><sup>1</sup><sup>2</sup></th>
<td></td> <!-- admin -->
<td></td>
</tr>
</tbody>
</table>
1. User must be the author of the EditRequest.
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

@ -0,0 +1,63 @@
title: Creating Releases using Webhooks
## What does this mean?
A webhook is a notification from one service to another. Put simply, a webhook
is used to notify ContentDB that the git repository has changed.
ContentDB offers the ability to automatically create releases using webhooks
from either Github or Gitlab. If you're not using either of those services,
you can also use the [API](../api) to create releases.
ContentDB also offers the ability to poll a Git repo and check for updates
without any web hooks, this is limited to once a day.
See [Git Update Detection](/help/update_config/).
The process is as follows:
1. The user creates an API Token and a webhook to use it.
2. The user pushes a commit to the git host (Gitlab or Github).
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
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
2. Copy the access token that was generated.
3. Go to the GitLab repository's settings > Webhooks > Add Webhook.
4. Set the payload URL to `https://content.minetest.net/github/webhook/`
5. Set the content type to JSON.
6. Set the secret to the access token that you copied.
7. Set the events
* If you want a rolling release, choose "just the push event".
* Or if you want a stable release cycle based on tags,
choose "Let me select" > Branch or tag creation.
8. Create.
### GitLab
1. Create a ContentDB API Token at [Profile > API Tokens: Manage](/user/tokens/).
2. Copy the access token that was generated.
3. Go to the GitLab repository's settings > Webhooks.
4. Set the URL to `https://content.minetest.net/gitlab/webhook/`
6. Set the secret token to the ContentDB access token that you copied.
7. Set the events
* If you want a rolling release, choose "Push events".
* Or if you want a stable release cycle based on tags,
choose "Tag push events".
8. Add webhook.
## Configuring Release Creation
See the [Package Configuration and Releases Guide](/help/package_config/) for
documentation on configuring the release creation.
From the Git repository, you can set the min/max Minetest versions, which files are included,
and update the package meta.

View File

@ -0,0 +1,36 @@
title: Top Packages Algorithm
## Package Score
Each package is given a `score`, which is used when ordering them in the
"Top Games/Mods/Texture Packs" lists. The intention of this feature is
to make it easier for new users to find good packages.
A package's score is equal to a rolling average of recent downloads,
plus the sum of the score given by reviews.
A review score is 100 if positive, -100 if negative.
```c
reviews_sum = sum(100 * (positive ? 1 : -1));
score = avg_downloads + reviews_sum;
```
## Pseudo rolling average of downloads
Each package adds 1 to `avg_downloads` for each unique download,
and then loses 5% (=1/20) of the value every day.
This is called a [Frecency](https://en.wikipedia.org/wiki/Frecency) heuristic,
a measure which combines both frequency and recency.
"Unique download" is counted per IP per package.
Downloading an update won't increase the download count if it's already been
downloaded from that IP.
## Transparency and Feedback
You can see all scores using the [scores REST API](/api/scores/), or by
using the [Prometheus metrics](/help/metrics/) endpoint.
Consider [suggesting improvements](https://github.com/minetest/contentdb/issues/new?assignees=&labels=Policy&template=policy.md&title=).

View File

@ -0,0 +1,43 @@
title: Git Update Detection
## Introduction
When you push a change to your Git repository, ContentDB can create a new release automatically or
send you a reminder. ContentDB will check your Git repository one per day, but you can use
webhooks or the API for faster updates.
Git Update Detection is clever enough to not create a release again if you've already created
it manually or using webhooks/the API.
## Setting up
* Set "VCS Repository URL" in your package.
* Open the "Configure Git Update Detection" page:
* Go to the Create Release page and click "Set up" on the banner.
* If the "How do you want to create releases?" wizard appears, choose "Automatic".
* Choose a trigger:
* **New Commit** - this will trigger for each pushed commit on the default branch, or the branch you specify.
* **New Tag** - this will trigger when a New Tag is created.
* Choose action to occur when the trigger happens:
* **Notification** - All maintainers receive a notification under the Bot category, and the package
will appear under "Outdated Packages" in [your to do list](/user/todo/).
* **Create Release** - A new release is created.
If New Commit, the title will be the iso date (eg: 2021-02-01).
If New Tag, the title will the tag name.
## Marking a package as up-to-date
Git Update Detection shouldn't erroneously mark packages as outdated if it is configured currently,
so the first thing you should do is make sure the Update Settings are set correctly.
There are some situations where the settings are correct, but you want to mark a package as
up-to-date - for example, if you don't want to make a release for a particular tag.
Clicking "Save" on "Update Settings" will mark a package as up-to-date.
## Configuring Release Creation
See the [Package Configuration and Releases Guide](/help/package_config/) for
documentation on configuring the release creation.
From the Git repository, you can set the min/max Minetest versions, which files are included,
and update the package meta.

View File

@ -0,0 +1,40 @@
title: WTFPL is a terrible license
toc: False
<div id="warning" class="alert alert-warning">
<span class="icon_message"></span>
Please reconsider the choice of WTFPL as a license.
<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
var params = new URLSearchParams(location.search);
var r = params.get("r");
if (r)
document.write("<a class='alert_right button' href='" + r + "'>Okay</a>");
else
$("#warning").hide();
</script>
</div>
The use of WTFPL as a license is discouraged for multiple reasons.
* **No Warranty disclaimer:** This could open you up to being sued.<sup>[1]</sup>
* **Swearing:** This prevents settings like schools from using your content.
* **Not OSI Approved:** Same as public domain?
The Open Source Initiative chose not to approve the license as an open-source
license, saying:<sup>[3]</sup>
> It's no different from dedication to the public domain.
> Author has submitted license approval request author is free to make public domain dedication.
> Although he agrees with the recommendation, Mr. Michlmayr notes that public domain doesn't exist in Europe. Recommend: Reject.
## Sources
1. [WTFPL is harmful to software developers](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html)
2. [FSF](https://www.gnu.org/licenses/license-list.en.html)
3. [OSI](https://opensource.org/minutes20090304)

View File

@ -0,0 +1,156 @@
title: Package Inclusion Policy and Guidance
## 0. Overview
ContentDB is for the community, and as such listings should be useful to the
community. To help with this, there are a few rules to improve the quality of
the listings and to combat abuse.
* **No inappropriate content.** <sup>2.1</sup>
* **Content must be playable/useful, but not necessarily finished.** <sup>2.2</sup>
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
* **The ContentDB admin reserves the right to remove packages for any reason**,
including ones not covered by this document, and to ban users who abuse
this service. <sup>1</sup>
## 1. General
The ContentDB admin reserves the right to remove packages for any reason,
including ones not covered by this document, and to ban users who abuse this service.
## 2. Accepted Content
### 2.1. Acceptable Content
Sexually-orientated content is not permitted.
If in doubt at what this means, [contact us by raising a report](/report/).
Mature content is permitted providing that it is labelled correctly.
See [Content Flags](/help/content_flags/).
The submission of malware is strictly prohibited. This includes software that
does not do as it advertises, for example, if it posts telemetry without stating
clearly that it does in the package meta.
### 2.2. State of Completion
ContentDB should only currently contain playable content - content which is
sufficiently complete to be useful to end-users. It's fine to add stuff which
is still a Work in Progress (WIP) as long as it adds sufficient value;
MineClone 2 is a good example of a WIP package which may break between releases
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.
## 3. Technical Names
### 3.1 Right to a name
A package uses a name when it has that name or contains a mod that uses that name.
The first package to use a name based on the creation of its forum topic or
ContentDB submission has the right to the technical name. The use of a package
on a server or in private doesn't reserve its name. No other packages of the same
type may use the same name, except for the exception given by 3.2.
If it turns out that we made a mistake by approving a package and that the
name should have been given to another package, then we *may* unapprove the
package and give the name to the correct one.
If you submit a package where you don't have the right to the name you will be asked
to change the name of the package, or your package won't be accepted.
We reserve the right to issue exceptions for this where we feel necessary.
### 3.2. Mod Forks and Reimplementations
An exception to the above is that mods are allowed to have the same name as a
mod if it's a fork of that mod (or a close reimplementation). In real terms, it
should be possible to use the new mod as a drop-in replacement.
We reserve the right to decide whether a mod counts as a fork or
reimplementation of the mod that owns the name.
## 4. Licenses
### 4.1. Allowed Licenses
Please ensure that you correctly credit any resources (code, assets, or otherwise)
that you have used in your package.
**The use of licenses that do not allow derivatives or redistribution is not
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
The use of licenses that discriminate between groups of people or forbid the use
of the content on servers or singleplayer is also not permitted.**
However, closed sourced licenses are allowed if they allow the above.
If the license you use is not on the list then please select "Other", and we'll
get around to adding it.
Please note that the definitions of "free" and "non-free" is the same as that
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
### 4.2. Recommended Licenses
It is highly recommended that you use a Free and Open Source software (FOSS)
license. FOSS licenses result in a sharing community and will increase the
number of potential users your package has. Using a closed source license will
result in your package being massively penalised in the search results and
package lists. See the help page on [non-free licenses](/help/non_free/) for more
information.
It is recommended that you use a proper license for code with a warranty
disclaimer, such as the (L)GPL or MIT. You should also use a proper media license
for media, such as a Creative Commons license.
The use of WTFPL is discouraged as it doesn't contain a
[valid warranty disclaimer](https://cubicspot.blogspot.com/2017/04/wtfpl-is-harmful-to-software-developers.html),
and also includes swearing which prevents settings like schools from using your content.
[Read more](/help/wtfpl/).
Public domain is not a valid license in many countries, please use CC0 or MIT instead.
## 5. Promotions and Advertisements (inc. asking for donations)
You may not place any promotions or advertisements in any meta data including
screenshots. This includes asking for donations, promoting online shops,
or linking to personal websites and social media. Please instead use the
fields provided on your user profile page to place links to websites and
donation pages.
ContentDB is for the community. We may remove any promotions if we feel that
they're inappropriate.
## 6. Reviews and Package Score
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

@ -0,0 +1,100 @@
title: Privacy Policy
Last Updated: 2022-01-23
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
## What Information is Collected
**All users:**
* HTTP requests are logged, with the following information:
* Time
* IP address
* Page URL
* Response status code
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
**With an account:**
* Email address
* Passwords (hashed and salted using BCrypt)
* Profile information, such as website URLs and donation URLs
* Comments, threads, and reviews
* Audit log actions (such as edits and logins) and their time stamps
ContentDB collects usernames of content creators from the forums,
as this is required to index forum topics.
Packages, including releases, screenshots, and any meta information,
are not considered personal information.
Please avoid giving other personal information as we do not want it.
## How this information is used
* Logged HTTP requests may be used for debugging ContentDB.
* Email addresses are used to:
* Provide essential system messages, such as password resets and privacy policy updates.
* Send notifications - the user may configure this to their needs, including opting out.
* The admin may use ContentDB to send emails when they need to contact a user.
* Passwords are used to authenticate the user.
* The audit log is used to record actions that may be harmful.
* Preferred language/locale is used to translate emails and the ContentDB interface.
* Other information is displayed as part of ContentDB's service.
## Who has access
* Only the admin has access to the HTTP requests.
The logs may be shared with others to aid in debugging, but care will be taken to remove any personal information.
* Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
The keys and the backups themselves are given to different people,
requiring at least two staff members to read a backup.
* Email addresses are visible to moderators and the admin.
They have access to assist users, and they are not permitted to share email addresses.
* Hashing protects passwords from being read whilst stored in the database or in backups.
* Profile information is public, including URLs and linked accounts.
* The visibility of comments depends on the visibility of threads.
They are either public, or visible only to the package author and editors.
* The complete audit log is visible to moderators.
Users may see their own audit log actions on their account settings page.
Owners, maintainers, and editors may be able to see the actions on a package in the future.
* Preferred language can only be viewed by this with access to the database or a backup.
* We may be required to share information with law enforcement.
## Location
The ContentDB production server is currently located in Germany.
Backups are stored in the UK.
Encrypted backups may be stored in other countries, such as the US or EU.
By using this service, you give permission for the data to be moved as needed.
## Period of Retention
The server uses log rotation, meaning that any logged HTTP requests will be
forgotten within a few weeks.
Usernames may be kept indefinitely, but other user information will be deleted if
requested. See below.
## Removal Requests
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,
for use in indexing mod/game topics. ContentDB also requires the use of a username
to uniquely identify a package. Therefore, an author cannot be removed completely
from ContentDB if they have any packages or mod/game topics on the forum.
If we are unable to remove your account for one of the above reasons, your user
account will instead be wiped and deactivated, ending up exactly like an author
who has not yet joined ContentDB. All personal information will be removed from the profile,
and any comments or threads will be deleted.
## Future Changes to Privacy Policy
We will alert any future changes to the privacy policy via email and
via notices on the ContentDB website.
By continuing to use this service, you agree to the privacy policy.

24
app/logic/LogicError.py Normal file
View File

@ -0,0 +1,24 @@
# ContentDB
# Copyright (C) 2021 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/>.
class LogicError(Exception):
def __init__(self, code, message):
self.code = code
self.message = message
def __str__(self):
return repr("LogicError {}: {}".format(self.code, self.message))

0
app/logic/__init__.py Normal file
View File

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

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

196
app/logic/packages.py Normal file
View File

@ -0,0 +1,196 @@
# ContentDB
# Copyright (C) 2021 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import re
import 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, UserRank, PackageDevState
from app.utils import addAuditLog
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: str):
if not cond:
raise LogicError(400, msg)
def get_license(name):
if type(name) == License:
return name
license = License.query.filter(License.name.ilike(name)).first()
if license is None:
raise LogicError(400, "Unknown license " + name)
return license
name_re = re.compile("^[a-z0-9_]+$")
AnyType = "?"
ALLOWED_FIELDS = {
"type": AnyType,
"title": str,
"name": str,
"short_description": str,
"short_desc": str,
"dev_state": AnyType,
"tags": list,
"content_warnings": list,
"license": AnyType,
"media_license": AnyType,
"long_description": str,
"desc": str,
"repo": str,
"website": str,
"issue_tracker": str,
"issueTracker": str,
"forums": int,
"video_url": str,
}
ALIASES = {
"short_description": "short_desc",
"issue_tracker": "issueTracker",
"long_description": "desc"
}
def is_int(val):
try:
int(val)
return True
except ValueError:
return False
def validate(data: dict):
for key, value in data.items():
if value is not None:
typ = ALLOWED_FIELDS.get(key)
check(typ is not None, key + " is not a known field")
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)),
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)
if value is not None:
check(value.startswith("http://") or value.startswith("https://"),
key + " must start with http:// or https://")
check(validators.url(value, public=True), key + " must be a valid URL")
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, 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, lazy_gettext("You do not have permission to change the package name"))
for alias, to in ALIASES.items():
if alias in data:
data[to] = data[alias]
validate(data)
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"])
if "video_url" in data and data["video_url"] is not None:
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
if "dQw4w9WgXcQ" in data["video_url"]:
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums", "video_url"]:
if key in data:
setattr(package, key, data[key])
if package.type == PackageType.TXP:
package.license = package.media_license
if was_new and package.type == PackageType.MOD:
m = MetaPackage.GetOrCreate(package.name, {})
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):
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)
if not was_web and tag.is_protected:
continue
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()
for warning_id in data["content_warnings"]:
if is_int(warning_id):
package.content_warnings.append(ContentWarning.query.get(warning_id))
else:
warning = ContentWarning.query.filter_by(name=warning_id).first()
if warning is None:
raise LogicError(400, "Unknown warning: " + warning_id)
package.content_warnings.append(warning)
if not was_new:
if reason is None:
msg = "Edited {}".format(package.title)
else:
msg = "Edited {} ({})".format(package.title, reason)
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
db.session.commit()
return package

98
app/logic/releases.py Normal file
View File

@ -0,0 +1,98 @@
# 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 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
from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease
from app.tasks.importtasks import makeVCSRelease, checkZipRelease
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, 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 >= 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,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None):
check_can_create_release(user, package)
rel = PackageRelease()
rel.package = package
rel.title = title
rel.url = ""
rel.task_id = uuid()
rel.min_rel = min_v
rel.max_rel = max_v
db.session.add(rel)
if reason is None:
msg = "Created release {}".format(rel.title)
else:
msg = "Created release {} ({})".format(rel.title, reason)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
db.session.commit()
makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id)
return rel
def do_create_zip_release(user: User, package: Package, title: str, file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None,
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()
rel.package = package
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)
if reason is None:
msg = "Created release {}".format(rel.title)
else:
msg = "Created release {} ({})".format(rel.title, reason)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
db.session.commit()
checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
return rel

87
app/logic/screenshots.py Normal file
View File

@ -0,0 +1,87 @@
import datetime, json
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
from app.utils import addNotification, addAuditLog
from app.utils.image import get_image_size
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
if count >= 20:
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again"))
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file"))
counter = 1
for screenshot in package.screenshots.all():
screenshot.order = counter
counter += 1
ss = PackageScreenshot()
ss.package = package
ss.title = title or "Untitled"
ss.url = uploaded_url
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
ss.order = counter
ss.width, ss.height = get_image_size(uploaded_path)
if ss.is_too_small():
raise LogicError(429,
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
db.session.add(ss)
if reason is None:
msg = "Created screenshot {}".format(ss.title)
else:
msg = "Created screenshot {} ({})".format(ss.title, reason)
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()
if is_cover_image:
package.cover_image = ss
db.session.commit()
return ss
def do_order_screenshots(_user: User, package: Package, order: [any]):
lookup = {}
for screenshot in package.screenshots.all():
lookup[screenshot.id] = screenshot
counter = 1
for ss_id in order:
try:
lookup[int(ss_id)].order = counter
counter += 1
except KeyError as e:
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()
def do_set_cover_image(_user: User, package: Package, cover_image):
try:
cover_image = int(cover_image)
except (ValueError, TypeError) as e:
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
for screenshot in package.screenshots.all():
if screenshot.id == cover_image:
package.cover_image = screenshot
db.session.commit()
return
raise LogicError(400, "Unable to find screenshot")

63
app/logic/uploads.py Normal file
View File

@ -0,0 +1,63 @@
# 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 imghdr
import os
from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError
from app.models import *
from app.utils import randomString
def get_extension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
ALLOWED_IMAGES = {"jpeg", "png"}
def isAllowedImage(data):
return imghdr.what(None, data) in ALLOWED_IMAGES
def upload_file(file, fileType, fileTypeDesc):
if not file or file is None or file.filename == "":
raise LogicError(400, "Expected file")
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
isImage = False
if fileType == "image":
allowedExtensions = ["jpg", "jpeg", "png"]
isImage = True
elif fileType == "zip":
allowedExtensions = ["zip"]
else:
raise Exception("Invalid fileType")
ext = get_extension(file.filename)
if ext is None or not ext in allowedExtensions:
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
if isImage and not isAllowedImage(file.stream.read()):
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
file.stream.seek(0)
filename = randomString(10) + "." + ext
filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
file.save(filepath)
return "/uploads/" + filename, filepath

96
app/maillogger.py Normal file
View File

@ -0,0 +1,96 @@
import logging
from app.tasks.emails import send_user_email
def _has_newline(line):
"""Used by has_bad_header to check for \\r or \\n"""
if line and ("\r" in line or "\n" in line):
return True
return False
def _is_bad_subject(subject):
"""Copied from: flask_mail.py class Message def has_bad_headers"""
if _has_newline(subject):
for linenum, line in enumerate(subject.split("\r\n")):
if not line:
return True
if linenum > 0 and line[0] not in "\t ":
return True
if _has_newline(line):
return True
if len(line.strip()) == 0:
return True
return False
class FlaskMailSubjectFormatter(logging.Formatter):
def format(self, record):
record.message = record.getMessage()
if self.usesTime():
record.asctime = self.formatTime(record, self.datefmt)
s = self.formatMessage(record)
return s
class FlaskMailTextFormatter(logging.Formatter):
pass
class FlaskMailHTMLFormatter(logging.Formatter):
def formatException(self, exc_info):
formatted_exception = logging.Handler.formatException(self, exc_info)
return "<pre>%s</pre>" % formatted_exception
def formatStack(self, stack_info):
return "<pre>%s</pre>" % stack_info
# see: https://github.com/python/cpython/blob/3.6/Lib/logging/__init__.py (class Handler)
class FlaskMailHandler(logging.Handler):
def __init__(self, send_to, subject_template, level=logging.NOTSET):
logging.Handler.__init__(self, level)
self.send_to = send_to
self.subject_template = subject_template
def setFormatter(self, text_fmt):
"""
Set the formatters for this handler. Provide at least one formatter.
When no text_fmt is provided, no text-part is created for the email body.
"""
assert text_fmt != None, "At least one formatter should be provided"
if type(text_fmt)==str:
text_fmt = FlaskMailTextFormatter(text_fmt)
self.formatter = text_fmt
def getSubject(self, record):
fmt = FlaskMailSubjectFormatter(self.subject_template)
subject = fmt.format(record)
# Since templates can cause header problems, and we rather have a incomplete email then an error, we fix this
if _is_bad_subject(subject):
subject="FlaskMailHandler log-entry from ContentDB [original subject is replaced, because it would result in a bad header]"
return subject
def emit(self, record):
subject = self.getSubject(record)
text = self.format(record) if self.formatter else None
html = "<pre>{}</pre>".format(text)
if "The recipient has exceeded message rate limit. Try again later" in subject:
return
for email in self.send_to:
send_user_email.delay(email, "en", subject, text, html)
def build_handler(app):
subject_template = "ContentDB %(message)s (%(module)s > %(funcName)s)"
text_template = ("Message type: %(levelname)s\n"
"Location: %(pathname)s:%(lineno)d\n"
"Module: %(module)s\n"
"Function: %(funcName)s\n"
"Time: %(asctime)s\n"
"Message: %(message)s\n\n")
mail_handler = FlaskMailHandler(app.config["MAIL_UTILS_ERROR_SEND_TO"], subject_template)
mail_handler.setLevel(logging.ERROR)
mail_handler.setFormatter(text_template)
return mail_handler

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

@ -1,595 +0,0 @@
# Content DB
# Copyright (C) 2018 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from urllib.parse import urlparse
from app import app
from datetime import datetime
from sqlalchemy.orm import validates
from flask_user import login_required, UserManager, UserMixin, SQLAlchemyAdapter
import enum
# Initialise database
db = SQLAlchemy(app)
migrate = Migrate(app, db)
class UserRank(enum.Enum):
NOT_JOINED = 0
NEW_MEMBER = 1
MEMBER = 2
EDITOR = 3
MODERATOR = 4
ADMIN = 5
def atLeast(self, min):
return self.value >= min.value
def getTitle(self):
return self.name.replace("_", " ").title()
def toName(self):
return self.name.lower()
def __str__(self):
return self.name
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == UserRank else UserRank[item]
class Permission(enum.Enum):
EDIT_PACKAGE = "EDIT_PACKAGE"
APPROVE_CHANGES = "APPROVE_CHANGES"
DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR"
MAKE_RELEASE = "MAKE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
APPROVE_RELEASE = "APPROVE_RELEASE"
APPROVE_NEW = "APPROVE_NEW"
CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
CHANGE_DNAME = "CHANGE_DNAME"
CHANGE_RANK = "CHANGE_RANK"
CHANGE_EMAIL = "CHANGE_EMAIL"
EDIT_EDITREQUEST = "EDIT_EDITREQUEST"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
def check(self, user):
if not user.is_authenticated:
return False
if self == Permission.APPROVE_NEW or \
self == Permission.APPROVE_CHANGES or \
self == Permission.APPROVE_RELEASE or \
self == Permission.APPROVE_SCREENSHOT:
return user.rank.atLeast(UserRank.EDITOR)
else:
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
# User authentication information
username = db.Column(db.String(50), nullable=False, unique=True)
password = db.Column(db.String(255), nullable=False, server_default="")
reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
rank = db.Column(db.Enum(UserRank))
# Account linking
github_username = db.Column(db.String(50), nullable=True, unique=True)
forums_username = db.Column(db.String(50), nullable=True, unique=True)
# User email information
email = db.Column(db.String(255), nullable=True, unique=True)
confirmed_at = db.Column(db.DateTime())
# User information
active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
display_name = db.Column(db.String(100), nullable=False, server_default="")
# Content
notifications = db.relationship("Notification", primaryjoin="User.id==Notification.user_id")
# causednotifs = db.relationship("Notification", backref="causer", lazy="dynamic")
packages = db.relationship("Package", backref="author", lazy="dynamic")
requests = db.relationship("EditRequest", backref="author", lazy="dynamic")
def __init__(self, username):
import datetime
self.username = username
self.confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
self.display_name = username
self.rank = UserRank.NOT_JOINED
def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self) or \
Permission.APPROVE_CHANGES.check(self)
def isClaimed(self):
return self.rank.atLeast(UserRank.NEW_MEMBER)
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to User.checkPerm()")
# 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_DNAME:
return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_EMAIL:
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank))
else:
raise Exception("Permission {} is not related to users".format(perm.name))
class UserEmailVerification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
email = db.Column(db.String(100))
token = db.Column(db.String(32))
user = db.relationship("User", foreign_keys=[user_id])
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"))
user = db.relationship("User", foreign_keys=[user_id])
causer = db.relationship("User", foreign_keys=[causer_id])
title = db.Column(db.String(100), nullable=False)
url = db.Column(db.String(200), nullable=True)
def __init__(self, us, cau, titl, ur):
self.user = us
self.causer = cau
self.title = titl
self.url = ur
class License(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False, unique=True)
packages = db.relationship("Package", backref="license", lazy="dynamic")
def __init__(self, v):
self.name = v
def __str__(self):
return self.name
class PackageType(enum.Enum):
MOD = "Mod"
GAME = "Game"
TXP = "Texture Pack"
def toName(self):
return self.name.lower()
def __str__(self):
return self.name
@classmethod
def choices(cls):
return [(choice, choice.value) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == PackageType else PackageType[item]
class PackagePropertyKey(enum.Enum):
name = "Name"
title = "Title"
shortDesc = "Short Description"
desc = "Description"
type = "Type"
license = "License"
tags = "Tags"
harddeps = "Hard Dependencies"
softdeps = "Soft Dependencies"
repo = "Repository"
website = "Website"
issueTracker = "Issue Tracker"
forums = "Forum Topic ID"
def convert(self, value):
if self == PackagePropertyKey.tags:
return ",".join([t.title for t in value])
elif self == PackagePropertyKey.harddeps or self == PackagePropertyKey.softdeps:
return ",".join([t.author.username + "/" + t.name for t in value])
else:
return str(value)
tags = db.Table("tags",
db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True),
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
)
harddeps = db.Table("harddeps",
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
db.Column("dependency_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
)
softdeps = db.Table("softdeps",
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
db.Column("dependency_id", db.Integer, db.ForeignKey("package.id"), primary_key=True)
)
class Package(db.Model):
id = db.Column(db.Integer, primary_key=True)
# Basic details
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
name = db.Column(db.String(100), nullable=False)
title = db.Column(db.String(100), nullable=False)
shortDesc = db.Column(db.String(200), nullable=False)
desc = db.Column(db.Text, nullable=True)
type = db.Column(db.Enum(PackageType))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
license_id = db.Column(db.Integer, db.ForeignKey("license.id"))
approved = db.Column(db.Boolean, nullable=False, default=False)
soft_deleted = db.Column(db.Boolean, nullable=False, default=False)
# Downloads
repo = db.Column(db.String(200), nullable=True)
website = db.Column(db.String(200), nullable=True)
issueTracker = db.Column(db.String(200), nullable=True)
forums = db.Column(db.Integer, nullable=True)
tags = db.relationship("Tag", secondary=tags, lazy="subquery",
backref=db.backref("packages", lazy=True))
harddeps = db.relationship("Package",
secondary=harddeps,
primaryjoin=id==harddeps.c.package_id,
secondaryjoin=id==harddeps.c.dependency_id,
backref="dependents")
softdeps = db.relationship("Package",
secondary=softdeps,
primaryjoin=id==softdeps.c.package_id,
secondaryjoin=id==softdeps.c.dependency_id,
backref="softdependents")
releases = db.relationship("PackageRelease", backref="package",
lazy="dynamic", order_by=db.desc("package_release_releaseDate"))
screenshots = db.relationship("PackageScreenshot", backref="package",
lazy="dynamic")
requests = db.relationship("EditRequest", backref="package",
lazy="dynamic")
def __init__(self, package=None):
if package is None:
return
self.author_id = package.author_id
self.created_at = package.created_at
self.approved = package.approved
for e in PackagePropertyKey:
setattr(self, e.name, getattr(package, e.name))
def getAsDictionary(self, base_url):
return {
"name": self.name,
"title": self.title,
"author": self.author.display_name,
"shortDesc": self.shortDesc,
"type": self.type.toName(),
"license": self.license.name,
"repo": self.repo,
"url": base_url + self.getDownloadURL(),
"release": self.getDownloadRelease().id if self.getDownloadRelease() is not None else None,
"screenshots": [base_url + ss.url for ss in self.screenshots]
}
def getDetailsURL(self):
return url_for("package_page",
author=self.author.username, name=self.name)
def getEditURL(self):
return url_for("create_edit_package_page",
author=self.author.username, name=self.name)
def getApproveURL(self):
return url_for("approve_package_page",
author=self.author.username, name=self.name)
def getDeleteURL(self):
return url_for("delete_package_page",
author=self.author.username, name=self.name)
def getNewScreenshotURL(self):
return url_for("create_screenshot_page",
author=self.author.username, name=self.name)
def getCreateReleaseURL(self):
return url_for("create_release_page",
author=self.author.username, name=self.name)
def getCreateEditRequestURL(self):
return url_for("create_edit_editrequest_page",
author=self.author.username, name=self.name)
def getDownloadURL(self):
return url_for("package_download_page",
author=self.author.username, name=self.name)
def getMainScreenshotURL(self):
screenshot = self.screenshots.filter_by(approved=True).first()
return screenshot.url if screenshot is not None else None
def getDownloadRelease(self):
for rel in self.releases:
if rel.approved:
return rel
return None
def canImportScreenshot(self):
if self.repo is None:
return False
url = urlparse(self.repo)
if url.netloc == "github.com":
return True
return False
def canMakeReleaseFromVCS(self):
if self.repo is None:
return False
url = urlparse(self.repo)
if url.netloc == "github.com":
return True
return False
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Package.checkPerm()")
isOwner = user == self.author
# Members can edit their own packages, and editors can edit any packages
if perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
return isOwner or user.rank.atLeast(UserRank.EDITOR)
if perm == Permission.EDIT_PACKAGE or perm == Permission.APPROVE_CHANGES:
return user.rank.atLeast(UserRank.MEMBER if isOwner else UserRank.EDITOR)
# Editors can change authors, approve new packages, and approve releases
elif perm == Permission.CHANGE_AUTHOR or perm == Permission.APPROVE_NEW \
or perm == Permission.APPROVE_RELEASE or perm == Permission.APPROVE_SCREENSHOT:
return user.rank.atLeast(UserRank.EDITOR)
# Moderators can delete packages
elif perm == Permission.DELETE_PACKAGE or perm == Permission.CHANGE_RELEASE_URL:
return user.rank.atLeast(UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to packages".format(perm.name))
class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
title = db.Column(db.String(100), nullable=False)
backgroundColor = db.Column(db.String(6), nullable=False)
textColor = db.Column(db.String(6), nullable=False)
def __init__(self, title, backgroundColor="000000", textColor="ffffff"):
self.title = title
self.backgroundColor = backgroundColor
self.textColor = textColor
import re
regex = re.compile("[^a-z_]")
self.name = regex.sub("", self.title.lower().replace(" ", "_"))
class PackageRelease(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
title = db.Column(db.String(100), nullable=False)
releaseDate = db.Column(db.DateTime, nullable=False)
url = db.Column(db.String(200), nullable=False)
approved = db.Column(db.Boolean, nullable=False, default=False)
task_id = db.Column(db.String(37), nullable=True)
def getEditURL(self):
return url_for("edit_release_page",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def __init__(self):
self.releaseDate = datetime.now()
class PackageScreenshot(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
title = db.Column(db.String(100), nullable=False)
url = db.Column(db.String(100), nullable=False)
approved = db.Column(db.Boolean, nullable=False, default=False)
def getEditURL(self):
return url_for("edit_screenshot_page",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def getThumbnailURL(self):
return self.url # TODO
class EditRequest(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"))
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
title = db.Column(db.String(100), nullable=False)
desc = db.Column(db.String(1000), nullable=True)
# 0 - open
# 1 - merged
# 2 - rejected
status = db.Column(db.Integer, nullable=False, default=0)
changes = db.relationship("EditRequestChange", backref="request",
lazy="dynamic")
def getURL(self):
return url_for("view_editrequest_page",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def getApproveURL(self):
return url_for("approve_editrequest_page",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def getRejectURL(self):
return url_for("reject_editrequest_page",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def getEditURL(self):
return url_for("create_edit_editrequest_page",
author=self.package.author.username,
name=self.package.name,
id=self.id)
def applyAll(self, package):
for change in self.changes:
change.apply(package)
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to EditRequest.checkPerm()")
isOwner = user == self.author
# Members can edit their own packages, and editors can edit any packages
if perm == Permission.EDIT_EDITREQUEST:
return isOwner or user.rank.atLeast(UserRank.EDITOR)
else:
raise Exception("Permission {} is not related to packages".format(perm.name))
class EditRequestChange(db.Model):
id = db.Column(db.Integer, primary_key=True)
request_id = db.Column(db.Integer, db.ForeignKey("edit_request.id"))
key = db.Column(db.Enum(PackagePropertyKey), nullable=False)
# TODO: make diff instead
oldValue = db.Column(db.Text, nullable=True)
newValue = db.Column(db.Text, nullable=True)
def apply(self, package):
if self.key == PackagePropertyKey.tags:
package.tags.clear()
for tagTitle in self.newValue.split(","):
tag = Tag.query.filter_by(title=tagTitle.strip()).first()
package.tags.append(tag)
elif self.key == PackagePropertyKey.harddeps:
package.harddeps.clear()
for pair in self.newValue.split(","):
key, value = pair.split("/")
if key is None or value is None:
continue
user = User.query.filter_by(username=key).first()
if user is None:
continue
dep = Package.query.filter_by(author=user, name=value, soft_deleted=False).first()
if dep is None:
continue
package.harddeps.append(dep)
elif self.key == PackagePropertyKey.softdeps:
package.softdeps.clear()
for pair in self.newValue.split(","):
key, value = pair.split("/")
if key is None or value is None:
continue
user = User.query.filter_by(username=key).first()
if user is None:
raise Exception("No such user!")
continue
dep = Package.query.filter_by(author=user, name=value).first()
if dep is None:
raise Exception("No such package!")
continue
package.softdeps.append(dep)
else:
setattr(package, self.key.name, self.newValue)
# Setup Flask-User
db_adapter = SQLAlchemyAdapter(db, User) # Register the User model
user_manager = UserManager(db_adapter, app) # Initialize Flask-User

177
app/models/__init__.py Normal file
View File

@ -0,0 +1,177 @@
# 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/>.
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy_searchable import make_searchable
from app import app
# Initialise database
db = SQLAlchemy(app)
migrate = Migrate(app, db)
make_searchable(db.metadata)
from .packages import *
from .users import *
from .threads import *
class APIToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
access_token = db.Column(db.String(34), unique=True, nullable=False)
name = db.Column(db.String(100), nullable=False)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="tokens")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
def canOperateOnPackage(self, package):
if self.package and self.package != package:
return False
return package.author == self.owner
class AuditSeverity(enum.Enum):
NORMAL = 0 # Normal user changes
USER = 1 # Security user changes
EDITOR = 2 # Editor changes
MODERATION = 3 # Destructive / moderator changes
def __str__(self):
return self.name
def getTitle(self):
return self.name.replace("_", " ").title()
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == AuditSeverity else AuditSeverity[item.upper()]
class AuditLogEntry(db.Model):
id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
causer = db.relationship("User", foreign_keys=[causer_id], back_populates="audit_log_entries")
severity = db.Column(db.Enum(AuditSeverity), nullable=False)
title = db.Column(db.String(100), nullable=False)
url = db.Column(db.String(200), nullable=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="audit_log_entries")
description = db.Column(db.Text, nullable=True, default=None)
def __init__(self, causer, severity, title, url, package=None, description=None):
if len(title) > 100:
title = title[:99] + ""
self.causer = causer
self.severity = severity
self.title = title
self.url = url
self.package = package
self.description = description
REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com",
"minetest.net", "dropboxusercontent.com", "4shared.com",
"digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net",
"imageshack.com", "imgur.com"]
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", back_populates="forum_topics")
wip = db.Column(db.Boolean, default=False, nullable=False)
discarded = db.Column(db.Boolean, default=False, nullable=False)
type = db.Column(db.Enum(PackageType), nullable=False)
title = db.Column(db.String(200), nullable=False)
name = db.Column(db.String(30), nullable=True)
link = db.Column(db.String(200), nullable=True)
posts = db.Column(db.Integer, nullable=False)
views = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def getRepoURL(self):
if self.link is None:
return None
for item in REPO_BLACKLIST:
if item in self.link:
return None
return self.link.replace("repo.or.cz/w/", "repo.or.cz/")
def getAsDictionary(self):
return {
"author": self.author.username,
"name": self.name,
"type": self.type.toName(),
"title": self.title,
"id": self.topic_id,
"link": self.link,
"posts": self.posts,
"views": self.views,
"is_wip": self.wip,
"discarded": self.discarded,
"created_at": self.created_at.isoformat(),
}
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to ForumTopic.checkPerm()")
if perm == Permission.TOPIC_DISCARD:
return self.author == user or user.rank.atLeast(UserRank.EDITOR)
else:
raise Exception("Permission {} is not related to topics".format(perm.name))
if app.config.get("LOG_SQL"):
import logging
logging.basicConfig()
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)

1191
app/models/packages.py Normal file

File diff suppressed because it is too large Load Diff

240
app/models/threads.py Normal file
View File

@ -0,0 +1,240 @@
# 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 datetime
from typing import Tuple, List
from flask import url_for
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)
)
class Thread(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="threads")
is_review_thread = db.relationship("Package", foreign_keys=[Package.review_thread_id], back_populates="review_thread")
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), nullable=True)
review = db.relationship("PackageReview", foreign_keys=[review_id], cascade="all, delete")
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="threads", foreign_keys=[author_id])
title = db.Column(db.String(100), nullable=False)
private = db.Column(db.Boolean, server_default="0", nullable=False)
locked = db.Column(db.Boolean, server_default="0", nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
replies = db.relationship("ThreadReply", back_populates="thread", lazy="dynamic",
order_by=db.asc("thread_reply_id"), cascade="all, delete, delete-orphan")
watchers = db.relationship("User", secondary=watchers, backref="watching")
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)
def getUnsubscribeURL(self):
return url_for("threads.unsubscribe", id=self.id)
def checkPerm(self, user, perm):
if not user.is_authenticated:
return perm == Permission.SEE_THREAD and not self.private
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Thread.checkPerm()")
isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
if self.package:
isMaintainer = isMaintainer or user in self.package.maintainers
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.SEE_THREAD:
return canSee
elif perm == Permission.COMMENT_THREAD:
return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
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))
def get_latest_reply(self):
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
class ThreadReply(db.Model):
id = db.Column(db.Integer, primary_key=True)
thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=False)
thread = db.relationship("Thread", back_populates="replies", foreign_keys=[thread_id])
comment = db.Column(db.String(2000), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
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
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to ThreadReply.checkPerm()")
if perm == Permission.EDIT_REPLY:
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
else:
raise Exception("Permission {} is not related to threads".format(perm.name))
class PackageReview(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="reviews")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
recommends = db.Column(db.Boolean, nullable=False)
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.getURL("packages.review")
def getDeleteURL(self):
return url_for("packages.delete_review",
author=self.package.author.username,
name=self.package.name,
reviewer=self.author.username)
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
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to PackageReview.checkPerm()")
if perm == Permission.DELETE_REVIEW:
return user == self.author or user.rank.atLeast(UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to reviews".format(perm.name))
class PackageReviewVote(db.Model):
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)
review = db.relationship("PackageReview", foreign_keys=[review_id], back_populates="votes")
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="review_votes")
is_positive = db.Column(db.Boolean, nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)

479
app/models/users.py Normal file
View File

@ -0,0 +1,479 @@
# 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 datetime
import enum
from flask_login import UserMixin
from sqlalchemy import desc, text
from app import gravatar
from . import db
class UserRank(enum.Enum):
BANNED = 0
NOT_JOINED = 1
NEW_MEMBER = 2
MEMBER = 3
TRUSTED_MEMBER = 4
APPROVER = 5
EDITOR = 6
BOT = 7
MODERATOR = 8
ADMIN = 9
def atLeast(self, min):
return self.value >= min.value
def getTitle(self):
return self.name.replace("_", " ").title()
def toName(self):
return self.name.lower()
def __str__(self):
return self.name
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == UserRank else UserRank[item.upper()]
class Permission(enum.Enum):
EDIT_PACKAGE = "EDIT_PACKAGE"
DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE"
DELETE_RELEASE = "DELETE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
APPROVE_RELEASE = "APPROVE_RELEASE"
APPROVE_NEW = "APPROVE_NEW"
EDIT_TAGS = "EDIT_TAGS"
CREATE_TAG = "CREATE_TAG"
CHANGE_RELEASE_URL = "CHANGE_RELEASE_URL"
CHANGE_USERNAMES = "CHANGE_USERNAMES"
CHANGE_RANK = "CHANGE_RANK"
CHANGE_EMAIL = "CHANGE_EMAIL"
SEE_THREAD = "SEE_THREAD"
CREATE_THREAD = "CREATE_THREAD"
COMMENT_THREAD = "COMMENT_THREAD"
LOCK_THREAD = "LOCK_THREAD"
DELETE_THREAD = "DELETE_THREAD"
DELETE_REPLY = "DELETE_REPLY"
EDIT_REPLY = "EDIT_REPLY"
UNAPPROVE_PACKAGE = "UNAPPROVE_PACKAGE"
TOPIC_DISCARD = "TOPIC_DISCARD"
CREATE_TOKEN = "CREATE_TOKEN"
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
DELETE_REVIEW = "DELETE_REVIEW"
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
# Only return true if the permission is valid for *all* contexts
# See Package.checkPerm for package-specific contexts
def check(self, user):
if not user.is_authenticated:
return False
if self == Permission.APPROVE_NEW or \
self == Permission.APPROVE_RELEASE or \
self == Permission.APPROVE_SCREENSHOT 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.")
@staticmethod
def checkPerm(user, perm):
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Permission.check")
return perm.check(user)
def display_name_default(context):
return context.get_current_parameters()["username"]
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)
reset_password_token = db.Column(db.String(100), nullable=False, server_default="")
def get_id(self):
return self.username
rank = db.Column(db.Enum(UserRank), nullable=False)
# Account linking
github_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
forums_username = db.Column(db.String(50, collation="NOCASE"), nullable=True, unique=True)
# Access token for webhook setup
github_access_token = db.Column(db.String(50), nullable=True, server_default=None)
# User email information
email = db.Column(db.String(255), nullable=True, unique=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
locale = db.Column(db.String(10), nullable=True, default=None)
# User information
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
display_name = db.Column(db.String(100), nullable=False, default=display_name_default)
# Links
website_url = db.Column(db.String(255), nullable=True, default=None)
donate_url = db.Column(db.String(255), nullable=True, default=None)
# Content
notifications = db.relationship("Notification", foreign_keys="Notification.user_id",
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id",
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user",
cascade="all, delete, delete-orphan")
email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id",
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer",
order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title"))
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", 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.display_name = username
self.is_active = active
self.email = email
self.password = password
self.rank = UserRank.NOT_JOINED
def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self)
def isClaimed(self):
return self.rank.atLeast(UserRank.NEW_MEMBER)
def getProfilePicURL(self):
if self.profile_pic:
return self.profile_pic
elif self.rank == UserRank.BOT:
return "/static/bot_avatar.png"
else:
return gravatar(self.email or f"{self.username}@content.minetest.net")
def checkPerm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to User.checkPerm()")
# 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_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.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)
else:
return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
else:
raise Exception("Permission {} is not related to users".format(perm.name))
def canCommentRL(self):
from app.models import ThreadReply
factor = 1
if self.rank.atLeast(UserRank.ADMIN):
return True
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor *= 2
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > one_min_ago).count() >= 3 * factor:
return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > hour_ago).count() >= 20 * factor:
return False
return True
def canOpenThreadRL(self):
from app.models import Thread
factor = 1
if self.rank.atLeast(UserRank.ADMIN):
return True
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor *= 5
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return Thread.query.filter_by(author=self) \
.filter(Thread.created_at > hour_ago).count() < 2 * factor
def __eq__(self, other):
if other is None:
return False
if not self.is_authenticated or not other.is_authenticated:
return False
assert self.id > 0
return self.id == other.id
def can_see_edit_profile(self, current_user):
return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
self.checkPerm(current_user, Permission.CHANGE_RANK)
def can_delete(self):
from app.models import ForumTopic
return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0
class UserEmailVerification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
email = db.Column(db.String(100), nullable=False)
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):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(100), nullable=False, unique=True)
blacklisted = db.Column(db.Boolean, nullable=False, default=False)
token = db.Column(db.String(32), nullable=True, default=None)
def __init__(self, email):
self.email = email
self.blacklisted = False
self.token = None
class NotificationType(enum.Enum):
# Package / release / etc
PACKAGE_EDIT = 1
# Approval review actions
PACKAGE_APPROVAL = 2
# New thread
NEW_THREAD = 3
# New Review
NEW_REVIEW = 4
# Posted reply to subscribed thread
THREAD_REPLY = 5
# A bot notification
BOT = 6
# Added / removed as maintainer
MAINTAINER = 7
# Editor misc
EDITOR_ALERT = 8
# Editor misc
EDITOR_MISC = 9
# Any other
OTHER = 0
def getTitle(self):
return self.name.replace("_", " ").title()
def toName(self):
return self.name.lower()
def get_description(self):
if self == NotificationType.PACKAGE_EDIT:
return "When another user edits your packages, releases, etc."
elif self == NotificationType.PACKAGE_APPROVAL:
return "Notifications from editors related to the package approval process."
elif self == NotificationType.NEW_THREAD:
return "When a thread is created on your package."
elif self == NotificationType.NEW_REVIEW:
return "When a user posts a review on your package."
elif self == NotificationType.THREAD_REPLY:
return "When someone replies to a thread you're watching."
elif self == NotificationType.BOT:
return "From a bot - for example, update notifications."
elif self == NotificationType.MAINTAINER:
return "When your package's maintainers change."
elif self == NotificationType.EDITOR_ALERT:
return "For editors: Important alerts."
elif self == NotificationType.EDITOR_MISC:
return "For editors: Minor notifications, including new threads."
elif self == NotificationType.OTHER:
return "Minor notifications not important enough for a dedicated category."
else:
return ""
def __str__(self):
return self.name
def __lt__(self, other):
return self.value < other.value
@classmethod
def choices(cls):
return [(choice, choice.getTitle()) for choice in cls]
@classmethod
def coerce(cls, item):
return item if type(item) == NotificationType else NotificationType[item.upper()]
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
user = db.relationship("User", foreign_keys=[user_id], back_populates="notifications")
causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
causer = db.relationship("User", foreign_keys=[causer_id], back_populates="caused_notifications")
type = db.Column(db.Enum(NotificationType), nullable=False, default=NotificationType.OTHER)
emailed = db.Column(db.Boolean(), nullable=False, default=False)
title = db.Column(db.String(100), nullable=False)
url = db.Column(db.String(200), nullable=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="notifications")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def __init__(self, user, causer, type, title, url, package=None):
if len(title) > 100:
title = title[:99] + ""
self.user = user
self.causer = causer
self.type = type
self.title = title
self.url = url
self.package = package
def can_send_email(self):
prefs = self.user.notification_preferences
return prefs and self.user.email and prefs.get_can_email(self.type)
def can_send_digest(self):
prefs = self.user.notification_preferences
return prefs and self.user.email and prefs.get_can_digest(self.type)
class UserNotificationPreferences(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
user = db.relationship("User", back_populates="notification_preferences")
# 2 = immediate emails
# 1 = daily digest emails
# 0 = no emails
pref_package_edit = db.Column(db.Integer, nullable=False)
pref_package_approval = db.Column(db.Integer, nullable=False)
pref_new_thread = db.Column(db.Integer, nullable=False)
pref_new_review = db.Column(db.Integer, nullable=False)
pref_thread_reply = db.Column(db.Integer, nullable=False)
pref_bot = db.Column(db.Integer, nullable=False)
pref_maintainer = db.Column(db.Integer, nullable=False)
pref_editor_alert = db.Column(db.Integer, nullable=False)
pref_editor_misc = db.Column(db.Integer, nullable=False)
pref_other = db.Column(db.Integer, nullable=False)
def __init__(self, user):
self.user = user
self.pref_package_edit = 1
self.pref_package_approval = 1
self.pref_new_thread = 1
self.pref_new_review = 1
self.pref_thread_reply = 2
self.pref_bot = 1
self.pref_maintainer = 1
self.pref_editor_alert = 1
self.pref_editor_misc = 0
self.pref_other = 0
def get_can_email(self, notification_type):
return getattr(self, "pref_" + notification_type.toName()) == 2
def set_can_email(self, notification_type, value):
value = 2 if value else 0
setattr(self, "pref_" + notification_type.toName(), value)
def get_can_digest(self, notification_type):
return getattr(self, "pref_" + notification_type.toName()) >= 1
def set_can_digest(self, notification_type, value):
if self.get_can_email(notification_type):
return
value = 1 if value else 0
setattr(self, "pref_" + notification_type.toName(), value)

BIN
app/public/favicon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
app/public/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

BIN
app/public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large Load Diff

5
app/public/static/fa/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Brands';
font-style: normal;
font-weight: normal;
font-display: auto;
src: url("../webfonts/fa-brands-400.eot");
src: url("../webfonts/fa-brands-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.woff") format("woff"), url("../webfonts/fa-brands-400.ttf") format("truetype"), url("../webfonts/fa-brands-400.svg#fontawesome") format("svg"); }
.fab {
font-family: 'Font Awesome 5 Brands'; }

View File

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:normal;font-display:auto;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}

4417
app/public/static/fa/css/fontawesome.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,15 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Free';
font-style: normal;
font-weight: 400;
font-display: auto;
src: url("../webfonts/fa-regular-400.eot");
src: url("../webfonts/fa-regular-400.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.woff") format("woff"), url("../webfonts/fa-regular-400.ttf") format("truetype"), url("../webfonts/fa-regular-400.svg#fontawesome") format("svg"); }
.far {
font-family: 'Font Awesome 5 Free';
font-weight: 400; }

View File

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:auto;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-family:"Font Awesome 5 Free";font-weight:400}

View File

@ -0,0 +1,16 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face {
font-family: 'Font Awesome 5 Free';
font-style: normal;
font-weight: 900;
font-display: auto;
src: url("../webfonts/fa-solid-900.eot");
src: url("../webfonts/fa-solid-900.eot?#iefix") format("embedded-opentype"), url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.woff") format("woff"), url("../webfonts/fa-solid-900.ttf") format("truetype"), url("../webfonts/fa-solid-900.svg#fontawesome") format("svg"); }
.fa,
.fas {
font-family: 'Font Awesome 5 Free';
font-weight: 900; }

View File

@ -0,0 +1,5 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.fas{font-family:"Font Awesome 5 Free";font-weight:900}

View File

@ -0,0 +1,371 @@
/*!
* Font Awesome Free 5.12.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
*/
svg:not(:root).svg-inline--fa {
overflow: visible; }
.svg-inline--fa {
display: inline-block;
font-size: inherit;
height: 1em;
overflow: visible;
vertical-align: -.125em; }
.svg-inline--fa.fa-lg {
vertical-align: -.225em; }
.svg-inline--fa.fa-w-1 {
width: 0.0625em; }
.svg-inline--fa.fa-w-2 {
width: 0.125em; }
.svg-inline--fa.fa-w-3 {
width: 0.1875em; }
.svg-inline--fa.fa-w-4 {
width: 0.25em; }
.svg-inline--fa.fa-w-5 {
width: 0.3125em; }
.svg-inline--fa.fa-w-6 {
width: 0.375em; }
.svg-inline--fa.fa-w-7 {
width: 0.4375em; }
.svg-inline--fa.fa-w-8 {
width: 0.5em; }
.svg-inline--fa.fa-w-9 {
width: 0.5625em; }
.svg-inline--fa.fa-w-10 {
width: 0.625em; }
.svg-inline--fa.fa-w-11 {
width: 0.6875em; }
.svg-inline--fa.fa-w-12 {
width: 0.75em; }
.svg-inline--fa.fa-w-13 {
width: 0.8125em; }
.svg-inline--fa.fa-w-14 {
width: 0.875em; }
.svg-inline--fa.fa-w-15 {
width: 0.9375em; }
.svg-inline--fa.fa-w-16 {
width: 1em; }
.svg-inline--fa.fa-w-17 {
width: 1.0625em; }
.svg-inline--fa.fa-w-18 {
width: 1.125em; }
.svg-inline--fa.fa-w-19 {
width: 1.1875em; }
.svg-inline--fa.fa-w-20 {
width: 1.25em; }
.svg-inline--fa.fa-pull-left {
margin-right: .3em;
width: auto; }
.svg-inline--fa.fa-pull-right {
margin-left: .3em;
width: auto; }
.svg-inline--fa.fa-border {
height: 1.5em; }
.svg-inline--fa.fa-li {
width: 2em; }
.svg-inline--fa.fa-fw {
width: 1.25em; }
.fa-layers svg.svg-inline--fa {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0; }
.fa-layers {
display: inline-block;
height: 1em;
position: relative;
text-align: center;
vertical-align: -.125em;
width: 1em; }
.fa-layers svg.svg-inline--fa {
-webkit-transform-origin: center center;
transform-origin: center center; }
.fa-layers-text, .fa-layers-counter {
display: inline-block;
position: absolute;
text-align: center; }
.fa-layers-text {
left: 50%;
top: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
-webkit-transform-origin: center center;
transform-origin: center center; }
.fa-layers-counter {
background-color: #ff253a;
border-radius: 1em;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #fff;
height: 1.5em;
line-height: 1;
max-width: 5em;
min-width: 1.5em;
overflow: hidden;
padding: .25em;
right: 0;
text-overflow: ellipsis;
top: 0;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: top right;
transform-origin: top right; }
.fa-layers-bottom-right {
bottom: 0;
right: 0;
top: auto;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: bottom right;
transform-origin: bottom right; }
.fa-layers-bottom-left {
bottom: 0;
left: 0;
right: auto;
top: auto;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: bottom left;
transform-origin: bottom left; }
.fa-layers-top-right {
right: 0;
top: 0;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: top right;
transform-origin: top right; }
.fa-layers-top-left {
left: 0;
right: auto;
top: 0;
-webkit-transform: scale(0.25);
transform: scale(0.25);
-webkit-transform-origin: top left;
transform-origin: top left; }
.fa-lg {
font-size: 1.33333em;
line-height: 0.75em;
vertical-align: -.0667em; }
.fa-xs {
font-size: .75em; }
.fa-sm {
font-size: .875em; }
.fa-1x {
font-size: 1em; }
.fa-2x {
font-size: 2em; }
.fa-3x {
font-size: 3em; }
.fa-4x {
font-size: 4em; }
.fa-5x {
font-size: 5em; }
.fa-6x {
font-size: 6em; }
.fa-7x {
font-size: 7em; }
.fa-8x {
font-size: 8em; }
.fa-9x {
font-size: 9em; }
.fa-10x {
font-size: 10em; }
.fa-fw {
text-align: center;
width: 1.25em; }
.fa-ul {
list-style-type: none;
margin-left: 2.5em;
padding-left: 0; }
.fa-ul > li {
position: relative; }
.fa-li {
left: -2em;
position: absolute;
text-align: center;
width: 2em;
line-height: inherit; }
.fa-border {
border: solid 0.08em #eee;
border-radius: .1em;
padding: .2em .25em .15em; }
.fa-pull-left {
float: left; }
.fa-pull-right {
float: right; }
.fa.fa-pull-left,
.fas.fa-pull-left,
.far.fa-pull-left,
.fal.fa-pull-left,
.fab.fa-pull-left {
margin-right: .3em; }
.fa.fa-pull-right,
.fas.fa-pull-right,
.far.fa-pull-right,
.fal.fa-pull-right,
.fab.fa-pull-right {
margin-left: .3em; }
.fa-spin {
-webkit-animation: fa-spin 2s infinite linear;
animation: fa-spin 2s infinite linear; }
.fa-pulse {
-webkit-animation: fa-spin 1s infinite steps(8);
animation: fa-spin 1s infinite steps(8); }
@-webkit-keyframes fa-spin {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); }
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg); } }
@keyframes fa-spin {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); }
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg); } }
.fa-rotate-90 {
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";
-webkit-transform: rotate(90deg);
transform: rotate(90deg); }
.fa-rotate-180 {
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";
-webkit-transform: rotate(180deg);
transform: rotate(180deg); }
.fa-rotate-270 {
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";
-webkit-transform: rotate(270deg);
transform: rotate(270deg); }
.fa-flip-horizontal {
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";
-webkit-transform: scale(-1, 1);
transform: scale(-1, 1); }
.fa-flip-vertical {
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";
-webkit-transform: scale(1, -1);
transform: scale(1, -1); }
.fa-flip-both, .fa-flip-horizontal.fa-flip-vertical {
-ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";
-webkit-transform: scale(-1, -1);
transform: scale(-1, -1); }
:root .fa-rotate-90,
:root .fa-rotate-180,
:root .fa-rotate-270,
:root .fa-flip-horizontal,
:root .fa-flip-vertical,
:root .fa-flip-both {
-webkit-filter: none;
filter: none; }
.fa-stack {
display: inline-block;
height: 2em;
position: relative;
width: 2.5em; }
.fa-stack-1x,
.fa-stack-2x {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0; }
.svg-inline--fa.fa-stack-1x {
height: 1em;
width: 1.25em; }
.svg-inline--fa.fa-stack-2x {
height: 2em;
width: 2.5em; }
.fa-inverse {
color: #fff; }
.sr-only {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px; }
.sr-only-focusable:active, .sr-only-focusable:focus {
clip: auto;
height: auto;
margin: 0;
overflow: visible;
position: static;
width: auto; }
.svg-inline--fa .fa-primary {
fill: var(--fa-primary-color, currentColor);
opacity: 1;
opacity: var(--fa-primary-opacity, 1); }
.svg-inline--fa .fa-secondary {
fill: var(--fa-secondary-color, currentColor);
opacity: 0.4;
opacity: var(--fa-secondary-opacity, 0.4); }
.svg-inline--fa.fa-swap-opacity .fa-primary {
opacity: 0.4;
opacity: var(--fa-secondary-opacity, 0.4); }
.svg-inline--fa.fa-swap-opacity .fa-secondary {
opacity: 1;
opacity: var(--fa-primary-opacity, 1); }
.svg-inline--fa mask .fa-primary,
.svg-inline--fa mask .fa-secondary {
fill: black; }
.fad.fa-inverse {
color: #fff; }

File diff suppressed because one or more lines are too long

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