Compare commits

...

89 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
100 changed files with 13548 additions and 6273 deletions

View File

@ -1,4 +1,4 @@
FROM python:3.6 FROM python:3.10
RUN groupadd -g 5123 cdb && \ RUN groupadd -g 5123 cdb && \
useradd -r -u 5123 -g cdb cdb useradd -r -u 5123 -g cdb cdb

View File

@ -4,7 +4,9 @@
Content database for Minetest mods, games, and more.\ Content database for Minetest mods, games, and more.\
Developed by rubenwardy, license AGPLv3.0+. Developed by rubenwardy, license AGPLv3.0+.
See [Getting Started](docs/getting_started.md). See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
## How-tos ## How-tos

View File

@ -39,6 +39,7 @@ app.config["LANGUAGES"] = {
"fr": "Français", "fr": "Français",
"id": "Bahasa Indonesia", "id": "Bahasa Indonesia",
"ms": "Bahasa Melayu", "ms": "Bahasa Melayu",
"ru": "русский язык",
} }
app.config.from_pyfile(os.environ["FLASK_CONFIG"]) app.config.from_pyfile(os.environ["FLASK_CONFIG"])
@ -65,7 +66,7 @@ login_manager.init_app(app)
login_manager.login_view = "users.login" login_manager.login_view = "users.login"
from .sass import sass from .sass import init_app as sass
sass(app) sass(app)
@ -121,16 +122,28 @@ def page_not_found(e):
@babel.localeselector @babel.localeselector
def get_locale(): def get_locale():
if not request:
return None
locales = app.config["LANGUAGES"].keys() locales = app.config["LANGUAGES"].keys()
if request: if current_user.is_authenticated and current_user.locale in locales:
locale = request.cookies.get("locale") return current_user.locale
if locale in locales:
return locale
return request.accept_languages.best_match(locales) 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
return None
@app.route("/set-locale/", methods=["POST"]) @app.route("/set-locale/", methods=["POST"])
@ -152,4 +165,8 @@ def set_locale():
expire_date = expire_date + datetime.timedelta(days=5*365) expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("locale", locale, expires=expire_date) resp.set_cookie("locale", locale, expires=expire_date)
if current_user.is_authenticated:
current_user.locale = locale
models.db.session.commit()
return resp return resp

View File

@ -16,20 +16,26 @@
import os import os
import sys
from typing import List from typing import List
import requests import requests
from celery import group from celery import group
from flask import * from flask import redirect, url_for, flash, current_app, jsonify
from sqlalchemy import or_ from sqlalchemy import or_, and_
from app.models import * 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.forumtasks import importTopicList, checkAllForumAccounts
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
from app.utils import addNotification, get_system_user from app.utils import addNotification, get_system_user
from app.utils.image import get_image_size
actions = {} actions = {}
def action(title: str): def action(title: str):
def func(f): def func(f):
name = f.__name__ name = f.__name__
@ -42,20 +48,21 @@ def action(title: str):
return func return func
@action("Delete stuck releases") @action("Delete stuck releases")
def del_stuck_releases(): def del_stuck_releases():
PackageRelease.query.filter(PackageRelease.task_id != None).delete() PackageRelease.query.filter(PackageRelease.task_id.isnot(None)).delete()
db.session.commit() db.session.commit()
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Check releases")
@action("Check ZIP releases")
def check_releases(): def check_releases():
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all() releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = [] tasks = []
for release in releases: for release in releases:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"]) tasks.append(checkZipRelease.s(release.id, release.file_path))
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async() result = group(tasks).apply_async()
@ -65,14 +72,14 @@ def check_releases():
return redirect(url_for("todo.view_editor")) return redirect(url_for("todo.view_editor"))
@action("Reimport packages")
@action("Check the first release of all packages")
def reimport_packages(): def reimport_packages():
tasks = [] tasks = []
for package in Package.query.filter(Package.state!=PackageState.DELETED).all(): for package in Package.query.filter(Package.state != PackageState.DELETED).all():
release = package.releases.first() release = package.releases.first()
if release: if release:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"]) tasks.append(checkZipRelease.s(release.id, release.file_path))
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async() result = group(tasks).apply_async()
@ -82,42 +89,46 @@ def reimport_packages():
return redirect(url_for("todo.view_editor")) return redirect(url_for("todo.view_editor"))
@action("Import topic list")
@action("Import forum topic list")
def import_topic_list(): def import_topic_list():
task = importTopicList.delay() task = importTopicList.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics"))) return redirect(url_for("tasks.check", id=task.id, r=url_for("todo.topics")))
@action("Check all forum accounts") @action("Check all forum accounts")
def check_all_forum_accounts(): def check_all_forum_accounts():
task = checkAllForumAccounts.delay() task = checkAllForumAccounts.delay()
return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page"))) return redirect(url_for("tasks.check", id=task.id, r=url_for("admin.admin_page")))
@action("Import screenshots") @action("Import screenshots")
def import_screenshots(): def import_screenshots():
packages = Package.query \ packages = Package.query \
.filter(Package.state!=PackageState.DELETED) \ .filter(Package.state != PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \ .outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
.filter(PackageScreenshot.id==None) \ .filter(PackageScreenshot.id.is_(None)) \
.all() .all()
for package in packages: for package in packages:
importRepoScreenshot.delay(package.id) importRepoScreenshot.delay(package.id)
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Clean uploads")
@action("Remove unused uploads")
def clean_uploads(): def clean_uploads():
upload_dir = app.config['UPLOAD_DIR'] upload_dir = current_app.config['UPLOAD_DIR']
(_, _, filenames) = next(os.walk(upload_dir)) (_, _, filenames) = next(os.walk(upload_dir))
existing_uploads = set(filenames) existing_uploads = set(filenames)
if len(existing_uploads) != 0: if len(existing_uploads) != 0:
def getURLsFromDB(column): def get_filenames_from_column(column):
results = db.session.query(column).filter(column != None, column != "").all() results = db.session.query(column).filter(column.isnot(None), column != "").all()
return set([os.path.basename(x[0]) for x in results]) return set([os.path.basename(x[0]) for x in results])
release_urls = getURLsFromDB(PackageRelease.url) release_urls = get_filenames_from_column(PackageRelease.url)
screenshot_urls = getURLsFromDB(PackageScreenshot.url) screenshot_urls = get_filenames_from_column(PackageScreenshot.url)
db_urls = release_urls.union(screenshot_urls) db_urls = release_urls.union(screenshot_urls)
unreachable = existing_uploads.difference(db_urls) unreachable = existing_uploads.difference(db_urls)
@ -136,7 +147,8 @@ def clean_uploads():
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Delete metapackages")
@action("Delete unused metapackages")
def del_meta_packages(): def del_meta_packages():
query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any()) query = MetaPackage.query.filter(~MetaPackage.dependencies.any(), ~MetaPackage.packages.any())
count = query.count() count = query.count()
@ -146,6 +158,7 @@ def del_meta_packages():
flash("Deleted " + str(count) + " unused meta packages", "success") flash("Deleted " + str(count) + " unused meta packages", "success")
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Delete removed packages") @action("Delete removed packages")
def del_removed_packages(): def del_removed_packages():
query = Package.query.filter_by(state=PackageState.DELETED) query = Package.query.filter_by(state=PackageState.DELETED)
@ -158,24 +171,6 @@ def del_removed_packages():
flash("Deleted {} soft deleted packages packages".format(count), "success") flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Add update config")
def add_update_config():
added = 0
for pkg in Package.query.filter(Package.repo != None, Package.releases.any(), Package.update_config == None).all():
pkg.update_config = PackageUpdateConfig()
pkg.update_config.auto_created = True
release: PackageRelease = pkg.releases.first()
if release and release.commit_hash:
pkg.update_config.last_commit = release.commit_hash
db.session.add(pkg.update_config)
added += 1
db.session.commit()
flash("Added {} update configs".format(added), "success")
return redirect(url_for("admin.admin_page"))
@action("Run update configs") @action("Run update configs")
def run_update_config(): def run_update_config():
@ -184,29 +179,31 @@ def run_update_config():
flash("Started update configs", "success") flash("Started update configs", "success")
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
def _package_list(packages: List[str]): def _package_list(packages: List[str]):
# Who needs translations? # Who needs translations?
if len(packages) >= 3: if len(packages) >= 3:
packages[len(packages) - 1] = "and " + packages[len(packages) - 1] packages[len(packages) - 1] = "and " + packages[len(packages) - 1]
packages_list = ", ".join(packages) packages_list = ", ".join(packages)
else: else:
packages_list = "and ".join(packages) packages_list = " and ".join(packages)
return packages_list return packages_list
@action("Send WIP package notification") @action("Send WIP package notification")
def remind_wip(): def remind_wip():
users = User.query.filter(User.packages.any(or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED))) users = User.query.filter(User.packages.any(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = db.session.query(Package.title).filter( packages = db.session.query(Package.title).filter(
Package.author_id==user.id, Package.author_id == user.id,
or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \ or_(Package.state == PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \
.all() .all()
packages = [pkg[0] for pkg in packages] packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages) packages_list = _package_list(packages)
havent = "haven't" if len(packages) > 1 else "hasn't" havent = "haven't" if len(packages) > 1 else "hasn't"
if len(packages_list) + 54 > 100: if len(packages_list) + 54 > 100:
packages_list = packages_list[0:(100-54-1)] + "" packages_list = packages_list[0:(100-54-1)] + ""
addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL, addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
@ -214,6 +211,7 @@ def remind_wip():
url_for('todo.view_user', username=user.username)) url_for('todo.view_user', username=user.username))
db.session.commit() db.session.commit()
@action("Send outdated package notification") @action("Send outdated package notification")
def remind_outdated(): def remind_outdated():
users = User.query.filter(User.maintained_packages.any( users = User.query.filter(User.maintained_packages.any(
@ -234,6 +232,7 @@ def remind_outdated():
db.session.commit() db.session.commit()
@action("Import licenses from SPDX") @action("Import licenses from SPDX")
def import_licenses(): def import_licenses():
renames = { renames = {
@ -284,7 +283,56 @@ def import_licenses():
@action("Delete inactive users") @action("Delete inactive users")
def delete_inactive_users(): def delete_inactive_users():
users = User.query.filter(User.is_active==False, User.packages==None, User.forum_topics==None, User.rank==UserRank.NOT_JOINED).all() 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: for user in users:
db.session.delete(user) db.session.delete(user)
db.session.commit() 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

@ -14,10 +14,10 @@
# You should have received a copy of the GNU Affero 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import * from flask import redirect, render_template, url_for, request, flash
from flask_login import current_user, login_user from flask_login import current_user, login_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, SubmitField
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length
from app.utils import rank_required, addAuditLog, addNotification, get_system_user from app.utils import rank_required, addAuditLog, addNotification, get_system_user
from . import bp from . import bp
@ -48,9 +48,10 @@ def admin_page():
else: else:
flash("Unknown action: " + action, "danger") flash("Unknown action: " + action, "danger")
deleted_packages = Package.query.filter(Package.state==PackageState.DELETED).all() deleted_packages = Package.query.filter(Package.state == PackageState.DELETED).all()
return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions) return render_template("admin/list.html", deleted_packages=deleted_packages, actions=actions)
class SwitchUserForm(FlaskForm): class SwitchUserForm(FlaskForm):
username = StringField("Username") username = StringField("Username")
submit = SubmitField("Switch") submit = SubmitField("Switch")
@ -69,14 +70,13 @@ def switch_user():
else: else:
flash("Unable to login as user", "danger") flash("Unable to login as user", "danger")
# Process GET or invalid POST # Process GET or invalid POST
return render_template("admin/switch_user.html", form=form) return render_template("admin/switch_user.html", form=form)
class SendNotificationForm(FlaskForm): class SendNotificationForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 300)]) title = StringField("Title", [InputRequired(), Length(1, 300)])
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/") url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
submit = SubmitField("Send") submit = SubmitField("Send")
@ -86,7 +86,7 @@ def send_bulk_notification():
form = SendNotificationForm(request.form) form = SendNotificationForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user, addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk notification", None, None, form.title.data) "Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all() users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None) addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
@ -121,5 +121,10 @@ def restore():
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.view")) 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() deleted_packages = Package.query \
return render_template("admin/restore.html", deleted_packages=deleted_packages) .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

@ -39,8 +39,8 @@ def audit():
return render_template("admin/audit.html", log=pagination.items, pagination=pagination) return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
@bp.route("/admin/audit/<int:id>/") @bp.route("/admin/audit/<int:id_>/")
@rank_required(UserRank.MODERATOR) @rank_required(UserRank.MODERATOR)
def audit_view(id): def audit_view(id_):
entry = AuditLogEntry.query.get(id) entry = AuditLogEntry.query.get(id_)
return render_template("admin/audit_view.html", entry=entry) return render_template("admin/audit_view.html", entry=entry)

View File

@ -14,18 +14,17 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 import *
from flask_login import current_user from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import TextAreaField, SubmitField, StringField
from wtforms.validators import * from wtforms.validators import InputRequired, Length
from app.markdown import render_markdown from app.markdown import render_markdown
from app.models import *
from app.tasks.emails import send_user_email from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog from app.utils import rank_required, addAuditLog
from . import bp from . import bp
from ...models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm): class SendEmailForm(FlaskForm):
@ -55,7 +54,7 @@ def send_single_email():
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
task = send_user_email.delay(user.email, form.subject.data, text, html) task = send_user_email.delay(user.email, user.locale or "en",form.subject.data, text, html)
return redirect(url_for("tasks.check", id=task.id, r=next_url)) return redirect(url_for("tasks.check", id=task.id, r=next_url))
return render_template("admin/send_email.html", form=form, user=user) return render_template("admin/send_email.html", form=form, user=user)
@ -67,12 +66,12 @@ def send_bulk_email():
form = SendEmailForm(request.form) form = SendEmailForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user, addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk email", None, None, form.text.data) "Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
for user in User.query.filter(User.email != None).all(): for user in User.query.filter(User.email.isnot(None)).all():
send_user_email.delay(user.email, form.subject.data, text, html) send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))

View File

@ -15,15 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import * from flask import redirect, render_template, abort, url_for, request, flash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, BooleanField, SubmitField, URLField
from wtforms.fields.html5 import URLField from wtforms.validators import InputRequired, Length, Optional
from wtforms.validators import *
from app.models import *
from app.utils import rank_required, nonEmptyOrNone from app.utils import rank_required, nonEmptyOrNone
from . import bp from . import bp
from ...models import UserRank, License, db
@bp.route("/licenses/") @bp.route("/licenses/")
@ -31,11 +30,13 @@ from . import bp
def license_list(): def license_list():
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all()) return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
class LicenseForm(FlaskForm): class LicenseForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3,100)]) name = StringField("Name", [InputRequired(), Length(3, 100)])
is_foss = BooleanField("Is FOSS") is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional], filters=[nonEmptyOrNone]) url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
submit = SubmitField("Save") submit = SubmitField("Save")
@bp.route("/licenses/new/", methods=["GET", "POST"]) @bp.route("/licenses/new/", methods=["GET", "POST"])
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"]) @bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])

View File

@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import * from flask import redirect, render_template, abort, url_for, request
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import * from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.models import *
from . import bp from . import bp
from ...models import Permission, Tag, db
@bp.route("/tags/") @bp.route("/tags/")
@ -40,12 +40,14 @@ def tag_list():
return render_template("admin/tags/list.html", tags=query.all()) return render_template("admin/tags/list.html", tags=query.all())
class TagForm(FlaskForm): class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3,100)]) title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)]) 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")]) 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") is_protected = BooleanField("Is Protected")
submit = SubmitField("Save") submit = SubmitField("Save")
@bp.route("/tags/new/", methods=["GET", "POST"]) @bp.route("/tags/new/", methods=["GET", "POST"])
@bp.route("/tags/<name>/edit/", methods=["GET", "POST"]) @bp.route("/tags/<name>/edit/", methods=["GET", "POST"])

View File

@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import * from flask import redirect, render_template, abort, url_for, request, flash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import * from wtforms.validators import InputRequired, Length
from app.models import *
from app.utils import rank_required from app.utils import rank_required
from . import bp from . import bp
from ...models import UserRank, MinetestRelease, db
@bp.route("/versions/") @bp.route("/versions/")
@ -30,10 +30,12 @@ from . import bp
def version_list(): def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all()) return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm): class VersionForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3,100)]) name = StringField("Name", [InputRequired(), Length(3, 100)])
protocol = IntegerField("Protocol") protocol = IntegerField("Protocol")
submit = SubmitField("Save") submit = SubmitField("Save")
@bp.route("/versions/new/", methods=["GET", "POST"]) @bp.route("/versions/new/", methods=["GET", "POST"])
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"]) @bp.route("/versions/<name>/edit/", methods=["GET", "POST"])

View File

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

View File

@ -13,13 +13,14 @@
# #
# You should have received a copy of the GNU Affero 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import math import math
from typing import List from typing import List
import flask_sqlalchemy import flask_sqlalchemy
from flask import request, jsonify, current_app from flask import request, jsonify, current_app
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.orm import subqueryload, joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from app import csrf from app import csrf
@ -30,7 +31,8 @@ from app.querybuilder import QueryBuilder
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
from . import bp from . import bp
from .auth import is_api_authd from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
api_order_screenshots, api_edit_package, api_set_cover_image
from functools import wraps from functools import wraps
@ -302,7 +304,7 @@ def create_screenshot(token: APIToken, package: Package):
if file is None: if file is None:
error(400, "Missing 'file' in multipart body") error(400, "Missing 'file' in multipart body")
return api_create_screenshot(token, package, data["title"], file) return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/") @bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@ -355,7 +357,7 @@ def order_screenshots(token: APIToken, package: Package):
error(401, "Authentication needed") error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS): if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to delete screenshots") error(403, "You do not have the permission to change screenshots")
if not token.canOperateOnPackage(package): if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
@ -367,6 +369,28 @@ def order_screenshots(token: APIToken, package: Package):
return api_order_screenshots(token, package, request.json) 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/") @bp.route("/api/packages/<author>/<name>/reviews/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
@ -477,6 +501,26 @@ def homepage():
}) })
@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/") @bp.route("/api/minetest_versions/")
@cors_allowed @cors_allowed
def versions(): def versions():

View File

@ -19,7 +19,7 @@ from flask import jsonify, abort, make_response, url_for, current_app
from app.logic.packages import do_edit_package 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.releases import LogicError, do_create_vcs_release, do_create_zip_release
from app.logic.screenshots import do_create_screenshot, do_order_screenshots from app.logic.screenshots import do_create_screenshot, do_order_screenshots, do_set_cover_image
from app.models import APIToken, Package, MinetestRelease, PackageScreenshot from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
@ -69,13 +69,13 @@ def api_create_zip_release(token: APIToken, package: Package, title: str, file,
}) })
def api_create_screenshot(token: APIToken, package: Package, title: str, file, reason="API"): def api_create_screenshot(token: APIToken, package: Package, title: str, file, is_cover_image: bool, reason="API"):
if not token.canOperateOnPackage(package): if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
reason += ", token=" + token.name reason += ", token=" + token.name
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, reason) ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
return jsonify({ return jsonify({
"success": True, "success": True,
@ -94,6 +94,17 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]):
}) })
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"): def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
if not token.canOperateOnPackage(package): if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")

View File

@ -20,7 +20,7 @@ from flask_babel import lazy_gettext
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import * from wtforms.validators import *
from app.models import db, User, APIToken, Package, Permission from app.models import db, User, APIToken, Package, Permission

View File

@ -53,12 +53,11 @@ def view(name):
.filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \ .filter(Dependency.optional==True, Package.state==PackageState.APPROVED) \
.all() .all()
similar_topics = None similar_topics = ForumTopic.query \
if mpackage.packages.filter_by(state=PackageState.APPROVED).count() == 0: .filter_by(name=name) \
similar_topics = ForumTopic.query \ .filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.filter_by(name=name) \ .order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \ .all()
.all()
return render_template("metapackages/view.html", mpackage=mpackage, return render_template("metapackages/view.html", mpackage=mpackage,
dependers=dependers, optional_dependers=optional_dependers, dependers=dependers, optional_dependers=optional_dependers,

View File

@ -65,4 +65,4 @@ def get_package_tabs(user: User, package: Package):
] ]
from . import packages, screenshots, releases, reviews 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

@ -24,7 +24,7 @@ from flask_login import login_required
from sqlalchemy import or_, func from sqlalchemy import or_, func
from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.orm import joinedload, subqueryload
from wtforms import * from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.validators import * from wtforms.validators import *
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
@ -115,9 +115,6 @@ def getReleases(package):
@bp.route("/packages/<author>/<name>/") @bp.route("/packages/<author>/<name>/")
@is_package_page @is_package_page
def view(package): def view(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
show_similar = not package.approved and ( show_similar = not package.approved and (
current_user in package.maintainers or current_user in package.maintainers or
package.checkPerm(current_user, Permission.APPROVE_NEW)) package.checkPerm(current_user, Permission.APPROVE_NEW))
@ -208,9 +205,6 @@ def shield(package, type):
@bp.route("/packages/<author>/<name>/download/") @bp.route("/packages/<author>/<name>/download/")
@is_package_page @is_package_page
def download(package): def download(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
release = package.getDownloadRelease() release = package.getDownloadRelease()
if release is None: if release is None:
@ -250,6 +244,7 @@ class PackageForm(FlaskForm):
website = StringField(lazy_gettext("Website 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]) 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)]) 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")) submit = SubmitField(lazy_gettext("Save"))
@ -288,15 +283,15 @@ def create_edit(author=None, name=None):
# Initial form class from post data and default data # Initial form class from post data and default data
if request.method == "GET": if request.method == "GET":
if package is None: if package is None:
form.name.data = request.args.get("bname") form.name.data = request.args.get("bname")
form.title.data = request.args.get("title") form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo") form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums") form.forums.data = request.args.get("forums")
form.license.data = None form.license.data = None
form.media_license.data = None form.media_license.data = None
else: else:
form.tags.data = list(package.tags) form.tags.data = package.tags
form.content_warnings.data = list(package.content_warnings) form.content_warnings.data = package.content_warnings
if request.method == "POST" and form.type.data == PackageType.TXP: if request.method == "POST" and form.type.data == PackageType.TXP:
form.license.data = form.media_license.data form.license.data = form.media_license.data
@ -333,6 +328,7 @@ def create_edit(author=None, name=None):
"website": form.website.data, "website": form.website.data,
"issueTracker": form.issueTracker.data, "issueTracker": form.issueTracker.data,
"forums": form.forums.data, "forums": form.forums.data,
"video_url": form.video_url.data,
}) })
if wasNew and package.repo is not None: if wasNew and package.repo is not None:
@ -591,9 +587,6 @@ def alias_create_edit(package: Package, alias_id: int = None):
@login_required @login_required
@is_package_page @is_package_page
def share(package): def share(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
return render_template("packages/share.html", package=package, return render_template("packages/share.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="share") tabs=get_package_tabs(current_user, package), current_tab="share")
@ -601,9 +594,6 @@ def share(package):
@bp.route("/packages/<author>/<name>/similar/") @bp.route("/packages/<author>/<name>/similar/")
@is_package_page @is_package_page
def similar(package): def similar(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
packages_modnames = {} packages_modnames = {}
for metapackage in package.provides: for metapackage in package.provides:
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id, packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,

View File

@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
from flask_login import login_required from flask_login import login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import * from wtforms.validators import *
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
@ -33,9 +33,6 @@ from . import bp, get_package_tabs
@bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/releases/", methods=["GET", "POST"])
@is_package_page @is_package_page
def list_releases(package): def list_releases(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
return render_template("packages/releases_list.html", return render_template("packages/releases_list.html",
package=package, package=package,
tabs=get_package_tabs(current_user, package), current_tab="releases") tabs=get_package_tabs(current_user, package), current_tab="releases")
@ -52,7 +49,7 @@ def get_mt_releases(is_max):
class CreatePackageReleaseForm(FlaskForm): class CreatePackageReleaseForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload") 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) vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None)
fileUpload = FileField(lazy_gettext("File Upload")) fileUpload = FileField(lazy_gettext("File Upload"))
@ -60,7 +57,8 @@ class CreatePackageReleaseForm(FlaskForm):
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name) 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()], 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) query_factory=lambda: get_mt_releases(True), get_pk=lambda a: a.id, get_label=lambda a: a.name)
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
class EditPackageReleaseForm(FlaskForm): class EditPackageReleaseForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)])
@ -110,9 +108,6 @@ def create_release(package):
@bp.route("/packages/<author>/<name>/releases/<id>/download/") @bp.route("/packages/<author>/<name>/releases/<id>/download/")
@is_package_page @is_package_page
def download_release(package, id): def download_release(package, id):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
release = PackageRelease.query.get(id) release = PackageRelease.query.get(id)
if release is None or release.package != package: if release is None or release.package != package:
abort(404) abort(404)

View File

@ -25,8 +25,8 @@ from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms.validators import * from wtforms.validators import *
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \ from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
Permission Permission, AuditSeverity
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required 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 from app.tasks.webhooktasks import post_discord_webhook
@ -54,9 +54,6 @@ def review(package):
flash(gettext("You can't review your own package!"), "danger") flash(gettext("You can't review your own package!"), "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getURL("packages.view"))
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
review = PackageReview.query.filter_by(package=package, author=current_user).first() review = PackageReview.query.filter_by(package=package, author=current_user).first()
form = ReviewForm(formdata=request.form, obj=review) form = ReviewForm(formdata=request.form, obj=review)
@ -129,14 +126,19 @@ def review(package):
form=form, package=package, review=review) form=form, package=package, review=review)
@bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"]) @bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"])
@login_required @login_required
@is_package_page @is_package_page
def delete_review(package): def delete_review(package, reviewer):
review = PackageReview.query.filter_by(package=package, author=current_user).first() review = PackageReview.query \
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
.first()
if review is None or review.package != package: if review is None or review.package != package:
abort(404) abort(404)
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
abort(403)
thread = review.thread thread = review.thread
reply = ThreadReply() reply = ThreadReply()
@ -147,10 +149,17 @@ def delete_review(package):
thread.review = None 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) 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) addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
db.session.delete(review) db.session.delete(review)
package.recalcScore()
db.session.commit() db.session.commit()
return redirect(thread.getViewURL()) return redirect(thread.getViewURL())
@ -228,4 +237,4 @@ def review_votes(package):
user_biases_info.sort(key=lambda x: -abs(x.balance)) user_biases_info.sort(key=lambda x: -abs(x.balance))
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews, return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
user_biases=user_biases_info) user_biases=user_biases_info)

View File

@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_login import login_required from flask_login import login_required
from wtforms import * from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import * from wtforms.validators import *
from app.utils import * from app.utils import *
@ -87,7 +87,7 @@ def create_screenshot(package):
form = CreateScreenshotForm() form = CreateScreenshotForm()
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data) do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data, False)
return redirect(package.getURL("packages.screenshots")) return redirect(package.getURL("packages.screenshots"))
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")

View File

@ -54,7 +54,8 @@ def report():
task = None task = None
for admin in User.query.filter_by(rank=UserRank.ADMIN).all(): for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
task = send_user_email.delay(admin.email, f"User report from {user_info}", text) 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) post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)

View File

@ -17,7 +17,7 @@
from celery import uuid from celery import uuid
from flask import * from flask import *
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import or_ from sqlalchemy import or_, and_
from app.models import * from app.models import *
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
@ -168,6 +168,11 @@ def view_user(username=None):
Package.state == PackageState.CHANGES_NEEDED)) \ Package.state == PackageState.CHANGES_NEEDED)) \
.order_by(db.asc(Package.created_at)).all() .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 \ outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED, .filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \ Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
@ -180,12 +185,14 @@ def view_user(username=None):
.all() .all()
needs_tags = user.maintained_packages \ needs_tags = user.maintained_packages \
.filter(Package.state != PackageState.DELETED) \ .filter(Package.state != PackageState.DELETED, Package.tags==None) \
.filter_by(tags=None).order_by(db.asc(Package.title)).all() .order_by(db.asc(Package.title)).all()
return render_template("todo/user.html", current_tab="user", user=user, return render_template("todo/user.html", current_tab="user", user=user,
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages, unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
needs_tags=needs_tags, topics_to_add=topics_to_add) 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"]) @bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])

View File

@ -17,7 +17,7 @@
from flask import * from flask import *
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext, get_locale
from flask_login import current_user, login_required, logout_user, login_user from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from sqlalchemy import or_ from sqlalchemy import or_
@ -142,7 +142,7 @@ def handle_register(form):
user_by_email = User.query.filter_by(email=form.email.data).first() user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email: if user_by_email:
send_anon_email.delay(form.email.data, gettext("Email already in use"), 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.", 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)) display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
@ -168,7 +168,7 @@ def handle_register(form):
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()
send_verify_email.delay(form.email.data, token) send_verify_email.delay(form.email.data, token, get_locale().language)
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
@ -209,25 +209,11 @@ def forgot_password():
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()
send_verify_email.delay(form.email.data, token) send_verify_email.delay(form.email.data, token, get_locale().language)
else: else:
send_anon_email.delay(email, "Unable to find account", """ html = render_template("emails/unable_to_find_account.html")
<p> send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
We were unable to perform the password reset as we could not find an account html, html)
associated with this email.
</p>
<p>
This may be because you used another email with your account, or because you never
confirmed your email.
</p>
<p>
You can use GitHub to log in if it is associated with your account.
Otherwise, you may need to contact rubenwardy for help.
</p>
<p>
If you weren't expecting to receive this email, then you can safely ignore it.
</p>
""")
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
@ -269,7 +255,7 @@ def handle_set_password(form):
user_by_email = User.query.filter_by(email=form.email.data).first() user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email: if user_by_email:
send_anon_email.delay(form.email.data, gettext("Email already in use"), 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.", 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)) display_name=user_by_email.display_name))
else: else:
@ -282,7 +268,7 @@ def handle_set_password(form):
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()
send_verify_email.delay(form.email.data, token) send_verify_email.delay(form.email.data, token, get_locale().language)
flash(gettext("Your password has been changed successfully."), "success") flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
@ -360,6 +346,7 @@ def verify_email():
if user.email: if user.email:
send_user_email.delay(user.email, send_user_email.delay(user.email,
user.locale or "en",
gettext("Email address changed"), gettext("Email address changed"),
gettext("Your email address has changed. If you didn't request this, please contact an administrator.")) gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
@ -401,7 +388,7 @@ def unsubscribe_verify():
sub.token = randomString(32) sub.token = randomString(32)
db.session.commit() db.session.commit()
send_unsubscribe_verify.delay(form.email.data) send_unsubscribe_verify.delay(form.email.data, get_locale().language)
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))

View File

@ -1,5 +1,5 @@
from flask import * from flask import *
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext, get_locale
from flask_login import current_user, login_required, logout_user from flask_login import current_user, login_required, logout_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from sqlalchemy import or_ from sqlalchemy import or_
@ -156,7 +156,7 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()
send_verify_email.delay(newEmail, token) send_verify_email.delay(newEmail, token, get_locale().language)
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
db.session.commit() db.session.commit()
@ -342,7 +342,7 @@ def modtools_set_email(username):
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()
send_verify_email.delay(user.email, token) send_verify_email.delay(user.email, token, user.locale or "en")
flash(f"Set email and sent a password reset on {user.username}", "success") flash(f"Set email and sent a password reset on {user.username}", "success")
return redirect(url_for("users.modtools", username=username)) return redirect(url_for("users.modtools", username=username))

View File

@ -89,6 +89,8 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `website`: Website URL. * `website`: Website URL.
* `issue_tracker`: Issue tracker URL. * `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID. * `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/` * GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates * Returns dependencies, with suggested candidates
* If query argument `only_hard` is present, only hard deps will be returned. * If query argument `only_hard` is present, only hard deps will be returned.
@ -224,6 +226,7 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
* `url`: absolute URL to screenshot. * `url`: absolute URL to screenshot.
* `created_at`: ISO time. * `created_at`: ISO time.
* `order`: Number used in ordering. * `order`: Number used in ordering.
* `is_cover_image`: true for cover image.
* GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read) * GET `/api/packages/<username>/<name>/screenshots/<id>/` (Read)
* Returns screenshot dictionary like above. * Returns screenshot dictionary like above.
* POST `/api/packages/<username>/<name>/screenshots/new/` (Create) * POST `/api/packages/<username>/<name>/screenshots/new/` (Create)
@ -231,12 +234,16 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
* Body is multipart form data. * Body is multipart form data.
* `title`: human-readable name for the screenshot, shown as a caption and alt text. * `title`: human-readable name for the screenshot, shown as a caption and alt text.
* `file`: multipart file to upload, like `<input type=file>`. * `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) * DELETE `/api/packages/<username>/<name>/screenshots/<id>/` (Delete)
* Requires authentication. * Requires authentication.
* Deletes screenshot. * Deletes screenshot.
* POST `/api/packages/<username>/<name>/screenshots/order/` * POST `/api/packages/<username>/<name>/screenshots/order/`
* Requires authentication. * Requires authentication.
* Body is a JSON array containing the screenshot IDs in their order. * 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. 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. The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
@ -248,6 +255,11 @@ Examples:
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \ -H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -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 # Delete screenshot
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \ curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
@ -257,6 +269,11 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/screensho
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d "[13, 2, 5, 7]" -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 }"
``` ```
@ -329,9 +346,11 @@ Supported query parameters:
### Tags ### Tags
* GET `/api/tags/` ([View](/api/tags/)): List of: * GET `/api/tags/` ([View](/api/tags/)): List of:
* `name`: technical name * `name`: technical name.
* `title`: human-readable title * `title`: human-readable title.
* `description`: tag description or null * `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 ### Content Warnings
@ -375,3 +394,5 @@ Supported query parameters:
* `pop_txp`: popular textures * `pop_txp`: popular textures
* `pop_game`: popular games * `pop_game`: popular games
* `high_reviewed`: highest reviewed * `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

@ -25,8 +25,8 @@ A flag can be:
There are also two meta-flags, which are designed so that we can change how different platforms filter the package list 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. without making a release.
* `android_default`: currently same as `*, deprecated`. Hides all content warnings, WIP packages, and deprecated packages * `android_default`: currently same as `*, deprecated`. Hides all content warnings and deprecated packages
* `desktop_default`: currently same as `deprecated`. Hides all WIP and deprecated packages * `desktop_default`: currently same as `deprecated`. Hides deprecated packages
## Content Warnings ## Content Warnings

View File

@ -67,7 +67,7 @@ is available.
### Meta and packaging ### Meta and packaging
* MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200). * MUST: `screenshot.png` is present and up-to-date, with a correct aspect ratio (3:2, at least 300x200).
* MUST: Have a high resolution cover image on ContentDB (at least 1280x768 pixels). * 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. It may be shown cropped to 16:9 aspect ratio, or shorter.
* MUST: mod.conf/game.conf/texture_pack.conf present with: * MUST: mod.conf/game.conf/texture_pack.conf present with:
* name (if mod or game) * name (if mod or game)

View File

@ -61,6 +61,7 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `website`: Website URL. * `website`: Website URL.
* `issue_tracker`: Issue tracker URL. * `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID. * `forums`: forum topic ID.
* `video_url`: URL to a video.
Use `null` to unset fields where relevant. Use `null` to unset fields where relevant.

View File

@ -1,5 +1,8 @@
title: Privacy Policy 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 ## What Information is Collected
**All users:** **All users:**
@ -9,13 +12,14 @@ title: Privacy Policy
* IP address * IP address
* Page URL * Page URL
* Response status code * Response status code
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
**With an account:** **With an account:**
* Email address * Email address
* Passwords (hashed and salted using BCrypt) * Passwords (hashed and salted using BCrypt)
* Profile information, such as website URLs and donation URLs * Profile information, such as website URLs and donation URLs
* Comments and threads * Comments, threads, and reviews
* Audit log actions (such as edits and logins) and their time stamps * Audit log actions (such as edits and logins) and their time stamps
ContentDB collects usernames of content creators from the forums, ContentDB collects usernames of content creators from the forums,
@ -30,10 +34,12 @@ Please avoid giving other personal information as we do not want it.
* Logged HTTP requests may be used for debugging ContentDB. * Logged HTTP requests may be used for debugging ContentDB.
* Email addresses are used to: * Email addresses are used to:
* Provide essential system messages, such as password resets. * Provide essential system messages, such as password resets and privacy policy updates.
* Send notifications - the user may configure this to their needs, including opting out. * 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. * Passwords are used to authenticate the user.
* The audit log is used to record actions that may be harmful * The audit log is used to record actions that may be harmful.
* Preferred language/locale is used to translate emails and the ContentDB interface.
* Other information is displayed as part of ContentDB's service. * Other information is displayed as part of ContentDB's service.
## Who has access ## Who has access
@ -43,7 +49,7 @@ Please avoid giving other personal information as we do not want it.
* Encrypted backups may be shared with selected Minetest staff members (moderators + core devs). * Encrypted backups may be shared with selected Minetest staff members (moderators + core devs).
The keys and the backups themselves are given to different people, The keys and the backups themselves are given to different people,
requiring at least two staff members to read a backup. requiring at least two staff members to read a backup.
* Emails are visible to moderators and the admin. * Email addresses are visible to moderators and the admin.
They have access to assist users, and they are not permitted to share email addresses. 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. * Hashing protects passwords from being read whilst stored in the database or in backups.
* Profile information is public, including URLs and linked accounts. * Profile information is public, including URLs and linked accounts.
@ -52,11 +58,12 @@ Please avoid giving other personal information as we do not want it.
* The complete audit log is visible to moderators. * The complete audit log is visible to moderators.
Users may see their own audit log actions on their account settings page. 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. 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. * We may be required to share information with law enforcement.
## Location ## Location
The ContentDB production server is currently located in Canada. The ContentDB production server is currently located in Germany.
Backups are stored in the UK. Backups are stored in the UK.
Encrypted backups may be stored in other countries, such as the US or EU. Encrypted backups may be stored in other countries, such as the US or EU.

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)

View File

@ -23,6 +23,7 @@ from app.logic.LogicError import LogicError
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \ from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
License, UserRank, PackageDevState License, UserRank, PackageDevState
from app.utils import addAuditLog from app.utils import addAuditLog
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: str): def check(cond: bool, msg: str):
@ -61,6 +62,7 @@ ALLOWED_FIELDS = {
"issue_tracker": str, "issue_tracker": str,
"issueTracker": str, "issueTracker": str,
"forums": int, "forums": int,
"video_url": str,
} }
ALIASES = { ALIASES = {
@ -128,8 +130,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if "media_license" in data: if "media_license" in data:
data["media_license"] = get_license(data["media_license"]) 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", for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums"]: "repo", "website", "issueTracker", "forums", "video_url"]:
if key in data: if key in data:
setattr(package, key, data[key]) setattr(package, key, data[key])
@ -152,7 +159,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
raise LogicError(400, "Unknown tag: " + tag_id) raise LogicError(400, "Unknown tag: " + tag_id)
if not was_web and tag.is_protected: if not was_web and tag.is_protected:
break continue
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR): 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)) raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))

View File

@ -6,9 +6,10 @@ from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file from app.logic.uploads import upload_file
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
from app.utils import addNotification, addAuditLog 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, reason: str = None): def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30) thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count() count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
if count >= 20: if count >= 20:
@ -27,6 +28,13 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
ss.url = uploaded_url ss.url = uploaded_url
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT) ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
ss.order = counter 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) db.session.add(ss)
if reason is None: if reason is None:
@ -39,6 +47,10 @@ def do_create_screenshot(user: User, package: Package, title: str, file, reason:
db.session.commit() db.session.commit()
if is_cover_image:
package.cover_image = ss
db.session.commit()
return ss return ss
@ -58,3 +70,18 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id))) raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
db.session.commit() 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")

View File

@ -70,10 +70,15 @@ class FlaskMailHandler(logging.Handler):
return subject return subject
def emit(self, record): def emit(self, record):
subject = self.getSubject(record)
text = self.format(record) if self.formatter else None text = self.format(record) if self.formatter else None
html = "<pre>{}</pre>".format(text) 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: for email in self.send_to:
send_user_email.delay(email, self.getSubject(record), text, html) send_user_email.delay(email, "en", subject, text, html)
def build_handler(app): def build_handler(app):

View File

@ -117,8 +117,8 @@ class ForumTopic(db.Model):
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="forum_topics") author = db.relationship("User", back_populates="forum_topics")
wip = db.Column(db.Boolean, server_default="0") wip = db.Column(db.Boolean, default=False, nullable=False)
discarded = db.Column(db.Boolean, server_default="0") discarded = db.Column(db.Boolean, default=False, nullable=False)
type = db.Column(db.Enum(PackageType), nullable=False) type = db.Column(db.Enum(PackageType), nullable=False)
title = db.Column(db.String(200), nullable=False) title = db.Column(db.String(200), nullable=False)

View File

@ -26,6 +26,7 @@ from sqlalchemy_utils.types import TSVectorType
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank, User
from .. import app
class PackageQuery(BaseQuery, SearchQueryMixin): class PackageQuery(BaseQuery, SearchQueryMixin):
@ -343,6 +344,25 @@ class Dependency(db.Model):
return retval return retval
class PackageGameSupport(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
package = db.relationship("Package", foreign_keys=[package_id])
game_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
game = db.relationship("Package", foreign_keys=[game_id])
supports = db.Column(db.Boolean, nullable=False, default=True)
confidence = db.Column(db.Integer, nullable=False, default=1)
__table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),)
def __init__(self, package, game):
self.package = package
self.game = game
class Package(db.Model): class Package(db.Model):
query_class = PackageQuery query_class = PackageQuery
@ -389,11 +409,18 @@ class Package(db.Model):
website = db.Column(db.String(200), nullable=True) website = db.Column(db.String(200), nullable=True)
issueTracker = db.Column(db.String(200), nullable=True) issueTracker = db.Column(db.String(200), nullable=True)
forums = db.Column(db.Integer, nullable=True) forums = db.Column(db.Integer, nullable=True)
video_url = db.Column(db.String(200), nullable=True, default=None)
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages") provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages")
dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id]) dependencies = db.relationship("Dependency", back_populates="depender", lazy="dynamic", foreign_keys=[Dependency.depender_id])
supported_games = db.relationship("PackageGameSupport", back_populates="package", lazy="dynamic",
foreign_keys=[PackageGameSupport.package_id])
game_supported_mods = db.relationship("PackageGameSupport", back_populates="game", lazy="dynamic",
foreign_keys=[PackageGameSupport.game_id])
tags = db.relationship("Tag", secondary=Tags, back_populates="packages") tags = db.relationship("Tag", secondary=Tags, back_populates="packages")
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages") content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
@ -405,7 +432,7 @@ class Package(db.Model):
lazy="dynamic", order_by=db.asc("package_screenshot_order"), cascade="all, delete, delete-orphan") lazy="dynamic", order_by=db.asc("package_screenshot_order"), cascade="all, delete, delete-orphan")
main_screenshot = db.relationship("PackageScreenshot", uselist=False, foreign_keys="PackageScreenshot.package_id", main_screenshot = db.relationship("PackageScreenshot", uselist=False, foreign_keys="PackageScreenshot.package_id",
lazy=True, order_by=db.asc("package_screenshot_order"), lazy=True, order_by=db.asc("package_screenshot_order"), viewonly=True,
primaryjoin="and_(Package.id==PackageScreenshot.package_id, PackageScreenshot.approved)") primaryjoin="and_(Package.id==PackageScreenshot.package_id, PackageScreenshot.approved)")
cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None) cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None)
@ -448,6 +475,14 @@ class Package(db.Model):
for e in PackagePropertyKey: for e in PackagePropertyKey:
setattr(self, e.name, getattr(package, e.name)) setattr(self, e.name, getattr(package, e.name))
@classmethod
def get_by_key(cls, key):
parts = key.split("/")
if len(parts) != 2:
return None
return Package.query.filter(Package.name == parts[1], Package.author.has(username=parts[0])).first()
def getId(self): def getId(self):
return "{}/{}".format(self.author.username, self.name) return "{}/{}".format(self.author.username, self.name)
@ -469,6 +504,11 @@ class Package(db.Model):
def getSortedOptionalDependencies(self): def getSortedOptionalDependencies(self):
return self.getSortedDependencies(False) return self.getSortedDependencies(False)
def getSortedSupportedGames(self):
supported = self.supported_games.all()
supported.sort(key=lambda x: -x.game.score)
return supported
def getAsDictionaryKey(self): def getAsDictionaryKey(self):
return { return {
"name": self.name, "name": self.name,
@ -527,6 +567,7 @@ class Package(db.Model):
"website": self.website, "website": self.website,
"issue_tracker": self.issueTracker, "issue_tracker": self.issueTracker,
"forums": self.forums, "forums": self.forums,
"video_url": self.video_url,
"tags": [x.name for x in self.tags], "tags": [x.name for x in self.tags],
"content_warnings": [x.name for x in self.content_warnings], "content_warnings": [x.name for x in self.content_warnings],
@ -539,7 +580,15 @@ class Package(db.Model):
"release": release and release.id, "release": release and release.id,
"score": round(self.score * 10) / 10, "score": round(self.score * 10) / 10,
"downloads": self.downloads "downloads": self.downloads,
"game_support": [
{
"supports": support.supports,
"confidence": support.confidence,
"game": support.game.getAsDictionaryShort(base_url, version)
} for support in self.supported_games.all()
]
} }
def getThumbnailOrPlaceholder(self, level=2): def getThumbnailOrPlaceholder(self, level=2):
@ -607,10 +656,7 @@ class Package(db.Model):
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
isApprover = user.rank.atLeast(UserRank.APPROVER) isApprover = user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.SEE_PACKAGE: if perm == Permission.CREATE_THREAD:
return self.state == PackageState.APPROVED or isMaintainer or isApprover
elif perm == Permission.CREATE_THREAD:
return user.rank.atLeast(UserRank.MEMBER) return user.rank.atLeast(UserRank.MEMBER)
# Members can edit their own packages, and editors can edit any packages # Members can edit their own packages, and editors can edit any packages
@ -684,7 +730,8 @@ class Package(db.Model):
needsScreenshot = \ needsScreenshot = \
(self.type == self.type.GAME or self.type == self.type.TXP) and \ (self.type == self.type.GAME or self.type == self.type.TXP) and \
self.screenshots.count() == 0 self.screenshots.count() == 0
return self.releases.count() > 0 and not needsScreenshot
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
elif state == PackageState.CHANGES_NEEDED: elif state == PackageState.CHANGES_NEEDED:
return self.checkPerm(user, Permission.APPROVE_NEW) return self.checkPerm(user, Permission.APPROVE_NEW)
@ -815,7 +862,13 @@ class Tag(db.Model):
def getAsDictionary(self): def getAsDictionary(self):
description = self.description if self.description != "" else None description = self.description if self.description != "" else None
return { "name": self.name, "title": self.title, "description": description } return {
"name": self.name,
"title": self.title,
"description": description,
"is_protected": self.is_protected,
"views": self.views,
}
class MinetestRelease(db.Model): class MinetestRelease(db.Model):
@ -883,6 +936,10 @@ class PackageRelease(db.Model):
# If the release is approved, then the task_id must be null and the url must be present # If the release is approved, then the task_id must be null and the url must be present
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)") CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
@property
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
def getAsDictionary(self): def getAsDictionary(self):
return { return {
"id": self.id, "id": self.id,
@ -984,6 +1041,9 @@ class PackageRelease(db.Model):
class PackageScreenshot(db.Model): class PackageScreenshot(db.Model):
HARD_MIN_SIZE = (920, 517)
SOFT_MIN_SIZE = (1280, 720)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False) package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
@ -995,6 +1055,22 @@ class PackageScreenshot(db.Model):
approved = db.Column(db.Boolean, nullable=False, default=False) approved = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
def is_very_small(self):
return self.width < 720 or self.height < 405
def is_too_small(self):
return self.width < PackageScreenshot.HARD_MIN_SIZE[0] or self.height < PackageScreenshot.HARD_MIN_SIZE[1]
def is_low_res(self):
return self.width < PackageScreenshot.SOFT_MIN_SIZE[0] or self.height < PackageScreenshot.SOFT_MIN_SIZE[1]
@property
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
def getEditURL(self): def getEditURL(self):
return url_for("packages.edit_screenshot", return url_for("packages.edit_screenshot",
author=self.package.author.username, author=self.package.author.username,
@ -1016,8 +1092,11 @@ class PackageScreenshot(db.Model):
"order": self.order, "order": self.order,
"title": self.title, "title": self.title,
"url": base_url + self.url, "url": base_url + self.url,
"width": self.width,
"height": self.height,
"approved": self.approved, "approved": self.approved,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"is_cover_image": self.package.cover_image == self,
} }

View File

@ -200,7 +200,8 @@ class PackageReview(db.Model):
def getDeleteURL(self): def getDeleteURL(self):
return url_for("packages.delete_review", return url_for("packages.delete_review",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name) name=self.package.name,
reviewer=self.author.username)
def getVoteUrl(self, next_url=None): def getVoteUrl(self, next_url=None):
return url_for("packages.review_vote", return url_for("packages.review_vote",
@ -213,6 +214,20 @@ class PackageReview(db.Model):
(pos, neg, _) = self.get_totals() (pos, neg, _) = self.get_totals()
self.score = 3 * (pos - neg) + 1 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): class PackageReviewVote(db.Model):
review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True) review_id = db.Column(db.Integer, db.ForeignKey("package_review.id"), primary_key=True)

View File

@ -59,7 +59,6 @@ class UserRank(enum.Enum):
class Permission(enum.Enum): class Permission(enum.Enum):
SEE_PACKAGE = "SEE_PACKAGE"
EDIT_PACKAGE = "EDIT_PACKAGE" EDIT_PACKAGE = "EDIT_PACKAGE"
DELETE_PACKAGE = "DELETE_PACKAGE" DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR" CHANGE_AUTHOR = "CHANGE_AUTHOR"
@ -87,6 +86,7 @@ class Permission(enum.Enum):
TOPIC_DISCARD = "TOPIC_DISCARD" TOPIC_DISCARD = "TOPIC_DISCARD"
CREATE_TOKEN = "CREATE_TOKEN" CREATE_TOKEN = "CREATE_TOKEN"
EDIT_MAINTAINERS = "EDIT_MAINTAINERS" EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
DELETE_REVIEW = "DELETE_REVIEW"
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS" CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME" CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
@ -148,6 +148,8 @@ class User(db.Model, UserMixin):
email = db.Column(db.String(255), nullable=True, unique=True) email = db.Column(db.String(255), nullable=True, unique=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None) email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
locale = db.Column(db.String(10), nullable=True, default=None)
# User information # User information
profile_pic = db.Column(db.String(255), nullable=True, server_default=None) 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") is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")

View File

@ -1,3 +1,6 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
$("textarea.markdown").each(function() { $("textarea.markdown").each(function() {
async function render(plainText, preview) { async function render(plainText, preview) {
const response = await fetch(new Request("/api/markdown/", { const response = await fetch(new Request("/api/markdown/", {

View File

@ -1,3 +1,6 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
const min = $("#min_rel"); const min = $("#min_rel");
const max = $("#max_rel"); const max = $("#max_rel");
const none = $("#min_rel option:first-child").attr("value"); const none = $("#min_rel option:first-child").attr("value");

View File

@ -1,3 +1,6 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
$(".topic-discard").click(function() { $(".topic-discard").click(function() {
const ele = $(this); const ele = $(this);
const tid = ele.attr("data-tid"); const tid = ele.attr("data-tid");

View File

@ -0,0 +1,37 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
document.querySelectorAll(".video-embed").forEach(ele => {
try {
const href = ele.getAttribute("href");
const url = new URL(href);
if (url.host == "www.youtube.com") {
ele.addEventListener("click", () => {
ele.parentNode.classList.add("d-block");
ele.classList.add("embed-responsive");
ele.classList.add("embed-responsive-16by9");
ele.innerHTML = `
<iframe title="YouTube video player" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>`;
const embedURL = new URL("https://www.youtube.com/");
embedURL.pathname = "/embed/" + url.searchParams.get("v");
embedURL.searchParams.set("autoplay", "1");
const iframe = ele.children[0];
iframe.setAttribute("src", embedURL);
});
ele.setAttribute("data-src", href);
ele.removeAttribute("href");
ele.querySelector(".label").innerText = "YouTube";
}
} catch (e) {
console.error(url);
return;
}
});

View File

@ -75,6 +75,10 @@ class QueryBuilder:
if self.search is not None and self.search.strip() == "": if self.search is not None and self.search.strip() == "":
self.search = None self.search = None
self.game = args.get("game")
if self.game:
self.game = Package.get_by_key(self.game)
def setSortIfNone(self, name, dir="desc"): def setSortIfNone(self, name, dir="desc"):
if self.order_by is None: if self.order_by is None:
self.order_by = name self.order_by = name
@ -132,6 +136,9 @@ class QueryBuilder:
query = query.filter_by(author=author) query = query.filter_by(author=author)
if self.game:
query = query.filter(Package.supported_games.any(game=self.game))
for tag in self.tags: for tag in self.tags:
query = query.filter(Package.tags.any(Tag.id == tag.id)) query = query.filter(Package.tags.any(Tag.id == tag.id))

View File

@ -12,16 +12,16 @@ Code unabashedly adapted from https://github.com/weapp/flask-coffee2js
import os import os
import os.path import os.path
import codecs import codecs
from flask import * import sass
from scss import Scss from flask import send_from_directory
def _convert(dir, src, dst):
def _convert(dir_path, src, dst):
original_wd = os.getcwd() original_wd = os.getcwd()
os.chdir(dir) os.chdir(dir_path)
css = Scss()
source = codecs.open(src, 'r', encoding='utf-8').read() source = codecs.open(src, 'r', encoding='utf-8').read()
output = css.compile(source) output = sass.compile(string=source)
os.chdir(original_wd) os.chdir(original_wd)
@ -29,8 +29,9 @@ def _convert(dir, src, dst):
outfile.write(output) outfile.write(output)
outfile.close() outfile.close()
def _getDirPath(app, originalPath, create=False):
path = originalPath def _get_dir_path(app, original_path, create=False):
path = original_path
if not os.path.isdir(path): if not os.path.isdir(path):
path = os.path.join(app.root_path, path) path = os.path.join(app.root_path, path)
@ -39,25 +40,25 @@ def _getDirPath(app, originalPath, create=False):
if create: if create:
os.mkdir(path) os.mkdir(path)
else: else:
raise IOError("Unable to find " + originalPath) raise IOError("Unable to find " + original_path)
return path return path
def sass(app, inputDir='scss', outputPath='static', force=False, cacheDir="public/static"):
static_url_path = app.static_url_path def init_app(app, input_dir='scss', dest='static', force=False, cache_dir="public/static"):
inputDir = _getDirPath(app, inputDir) input_dir = _get_dir_path(app, input_dir)
cacheDir = _getDirPath(app, cacheDir or outputPath, True) cache_dir = _get_dir_path(app, cache_dir or dest, True)
def _sass(filepath): def _sass(filepath):
sassfile = "%s/%s.scss" % (inputDir, filepath) scss_file = "%s/%s.scss" % (input_dir, filepath)
cacheFile = "%s/%s.css" % (cacheDir, filepath) cache_file = "%s/%s.css" % (cache_dir, filepath)
# Source file exists, and needs regenerating # Source file exists, and needs regenerating
if os.path.isfile(sassfile) and (force or not os.path.isfile(cacheFile) or if os.path.isfile(scss_file) and (force or not os.path.isfile(cache_file) or
os.path.getmtime(sassfile) > os.path.getmtime(cacheFile)): os.path.getmtime(scss_file) > os.path.getmtime(cache_file)):
_convert(inputDir, sassfile, cacheFile) _convert(input_dir, scss_file, cache_file)
app.logger.debug('Compiled %s into %s' % (sassfile, cacheFile)) app.logger.debug('Compiled %s into %s' % (scss_file, cache_file))
return send_from_directory(cacheDir, filepath + ".css") return send_from_directory(cache_dir, filepath + ".css")
app.add_url_rule("/%s/<path:filepath>.css" % outputPath, 'sass', _sass) app.add_url_rule("/%s/<path:filepath>.css" % dest, 'sass', _sass)

View File

@ -1,5 +1,6 @@
@import "components.scss"; @import "components.scss";
@import "packages.scss"; @import "packages.scss";
@import "gallery.scss";
@import "packagegrid.scss"; @import "packagegrid.scss";
@import "comments.scss"; @import "comments.scss";

93
app/scss/gallery.scss Normal file
View File

@ -0,0 +1,93 @@
.gallery {
list-style: none;
padding: 0;
margin: 0 0 2em;
overflow: auto hidden;
li, li a {
list-style: none;
margin: 0;
padding: 0;
}
li {
display: inline-block;
vertical-align: middle;
margin: 5px;
padding: 0;
a {
display: block;
text-decoration: none;
&:hover {
text-decoration: none;
}
}
}
.gallery-image {
position: relative;
&:hover img {
filter: brightness(1.1);
}
}
img {
width: 200px;
height: 133px;
object-fit: cover;
}
}
.video-embed {
min-width: 200px;
min-height: 133px;
background: #111;
position: relative;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer;
.fa-play {
display: block;
font-size: 200%;
color: #f44;
}
&:hover {
background: #191919;
.fa-play {
color: red;
}
}
.label {
position: absolute;
top: 0.25rem;
right: 0.5rem;
color: #555;
font-size: 80%;
}
}
.screenshot-add {
display: block !important;
width: 200px;
height: 133px;
background: #444;
color: #666;
text-align: center;
line-height: 133px !important;
font-size: 80px;
&:hover {
background: #555;
color: #999;
text-decoration: none;
}
}

View File

@ -1,32 +1,3 @@
.screenshot_list {
list-style: none;
padding: 0;
margin: 0 0 2em;
li, li a {
list-style: none;
margin: 0;
padding: 0;
}
li {
display: inline-block;
vertical-align: middle;
margin: 5px;
padding: 0;
a {
display: block;
}
}
img {
width: 200px;
height: 133px;
object-fit: cover;
}
}
.badge-tr { .badge-tr {
position: absolute; position: absolute;
top: 5px; top: 5px;
@ -34,23 +5,6 @@
color: #ccc !important;; color: #ccc !important;;
} }
.screenshot-add {
display: block !important;
width: 200px;
height: 133px;
background: #444;
color: #666;
text-align: center;
line-height: 133px !important;
font-size: 80px;
&:hover {
background: #555;
color: #999;
text-decoration: none;
}
}
.info-row { .info-row {
vertical-align: middle; vertical-align: middle;

View File

@ -16,6 +16,7 @@
from flask import render_template, escape from flask import render_template, escape
from flask_babel import force_locale, gettext
from flask_mail import Message from flask_mail import Message
from app import mail from app import mail
from app.models import Notification, db, EmailSubscription, User from app.models import Notification, db, EmailSubscription, User
@ -36,112 +37,121 @@ def get_email_subscription(email):
@celery.task() @celery.task()
def send_verify_email(email, token): def send_verify_email(email, token, locale):
sub = get_email_subscription(email) sub = get_email_subscription(email)
if sub.blacklisted: if sub.blacklisted:
return return
msg = Message("Confirm email address", recipients=[email]) with force_locale(locale or "en"):
msg = Message("Confirm email address", recipients=[email])
msg.body = """ msg.body = """
This email has been sent to you because someone (hopefully you) This email has been sent to you because someone (hopefully you)
has entered your email address as a user's email. has entered your email address as a user's email.
If it wasn't you, then just delete this email.
If this was you, then please click this link to confirm the address:
{}
""".format(abs_url_for('users.verify_email', token=token))
If it wasn't you, then just delete this email. msg.html = render_template("emails/verify.html", token=token, sub=sub)
mail.send(msg)
If this was you, then please click this link to confirm the address:
{}
""".format(abs_url_for('users.verify_email', token=token))
msg.html = render_template("emails/verify.html", token=token, sub=sub)
mail.send(msg)
@celery.task() @celery.task()
def send_unsubscribe_verify(email): def send_unsubscribe_verify(email, locale):
sub = get_email_subscription(email) sub = get_email_subscription(email)
if sub.blacklisted: if sub.blacklisted:
return return
msg = Message("Confirm unsubscribe", recipients=[email]) with force_locale(locale or "en"):
msg = Message("Confirm unsubscribe", recipients=[email])
msg.body = """ msg.body = """
We're sorry to see you go. You just need to do one more thing before your email is blacklisted. We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
Click this link to blacklist email: {} Click this link to blacklist email: {}
""".format(abs_url_for('users.unsubscribe', token=sub.token)) """.format(abs_url_for('users.unsubscribe', token=sub.token))
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub) msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
mail.send(msg) mail.send(msg)
@celery.task() @celery.task(rate_limit="25/m")
def send_email_with_reason(email, subject, text, html, reason): def send_email_with_reason(email: str, locale: str, subject: str, text: str, html: str, reason: str):
sub = get_email_subscription(email) sub = get_email_subscription(email)
if sub.blacklisted: if sub.blacklisted:
return return
from flask_mail import Message with force_locale(locale or "en"):
msg = Message(subject, recipients=[email]) from flask_mail import Message
msg = Message(subject, recipients=[email])
msg.body = text msg.body = text
html = html or f"<pre>{escape(text)}</pre>" html = html or f"<pre>{escape(text)}</pre>"
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub) msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
mail.send(msg) mail.send(msg)
@celery.task() @celery.task(rate_limit="25/m")
def send_user_email(email: str, subject: str, text: str, html=None): def send_user_email(email: str, locale: str, subject: str, text: str, html=None):
return send_email_with_reason(email, subject, text, html, with force_locale(locale or "en"):
"You are receiving this email because you are a registered user of ContentDB.") return send_email_with_reason(email, locale, subject, text, html,
gettext("You are receiving this email because you are a registered user of ContentDB."))
@celery.task() @celery.task(rate_limit="25/m")
def send_anon_email(email: str, subject: str, text: str, html=None): def send_anon_email(email: str, locale: str, subject: str, text: str, html=None):
return send_email_with_reason(email, subject, text, html, with force_locale(locale or "en"):
"You are receiving this email because someone (hopefully you) entered your email address as a user's email.") return send_email_with_reason(email, locale, subject, text, html,
gettext("You are receiving this email because someone (hopefully you) entered your email address as a user's email."))
def send_single_email(notification): def send_single_email(notification, locale):
sub = get_email_subscription(notification.user.email) sub = get_email_subscription(notification.user.email)
if sub.blacklisted: if sub.blacklisted:
return return
msg = Message(notification.title, recipients=[notification.user.email]) with force_locale(locale or "en"):
msg = Message(notification.title, recipients=[notification.user.email])
msg.body = """ msg.body = """
New notification: {} New notification: {}
View: {} View: {}
Manage email settings: {} Manage email settings: {}
Unsubscribe: {} Unsubscribe: {}
""".format(notification.title, abs_url(notification.url), """.format(notification.title, abs_url(notification.url),
abs_url_for("users.email_notifications", username=notification.user.username), abs_url_for("users.email_notifications", username=notification.user.username),
abs_url_for("users.unsubscribe", token=sub.token)) abs_url_for("users.unsubscribe", token=sub.token))
msg.html = render_template("emails/notification.html", notification=notification, sub=sub) msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
mail.send(msg) mail.send(msg)
def send_notification_digest(notifications: [Notification]): def send_notification_digest(notifications: [Notification], locale):
user = notifications[0].user user = notifications[0].user
sub = get_email_subscription(user.email) sub = get_email_subscription(user.email)
if sub.blacklisted: if sub.blacklisted:
return return
msg = Message("{} new notifications".format(len(notifications)), recipients=[user.email]) with force_locale(locale or "en"):
msg = Message(gettext("%(num)d new notifications", num=len(notifications)), recipients=[user.email])
msg.body = "".join(["<{}> {}\nView: {}\n\n".format(notification.causer.display_name, notification.title, abs_url(notification.url)) for notification in notifications]) msg.body = "".join(["<{}> {}\n{}: {}\n\n".format(notification.causer.display_name, notification.title, gettext("View"), abs_url(notification.url)) for notification in notifications])
msg.body += "Manage email settings: {}\nUnsubscribe: {}".format( msg.body += "{}: {}\n{}: {}".format(
abs_url_for("users.email_notifications", username=user.username), gettext("Manage email settings"),
abs_url_for("users.unsubscribe", token=sub.token)) abs_url_for("users.email_notifications", username=user.username),
gettext("Unsubscribe"),
abs_url_for("users.unsubscribe", token=sub.token))
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub) msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
mail.send(msg) mail.send(msg)
@celery.task() @celery.task()
@ -154,7 +164,7 @@ def send_pending_digests():
notification.emailed = True notification.emailed = True
if len(to_send) > 0: if len(to_send) > 0:
send_notification_digest(to_send) send_notification_digest(to_send, user.locale or "en")
db.session.commit() db.session.commit()
@ -174,6 +184,6 @@ def send_pending_notifications():
db.session.commit() db.session.commit()
if len(to_send) > 1: if len(to_send) > 1:
send_notification_digest(to_send) send_notification_digest(to_send, user.locale or "en")
elif len(to_send) > 0: elif len(to_send) > 0:
send_single_email(to_send[0]) send_single_email(to_send[0], user.locale or "en")

View File

@ -13,6 +13,7 @@
# #
# You should have received a copy of the GNU Affero 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import json import json
import os, shutil, gitdb import os, shutil, gitdb
from zipfile import ZipFile from zipfile import ZipFile
@ -22,11 +23,13 @@ from kombu import uuid
from app.models import * from app.models import *
from app.tasks import celery, TaskError from app.tasks import celery, TaskError
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_system_user from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
from .minetestcheck import build_tree, MinetestCheckError, ContentType from .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError from ..logic.LogicError import LogicError
from ..logic.game_support import GameSupportResolver
from ..logic.packages import do_edit_package, ALIASES from ..logic.packages import do_edit_package, ALIASES
from ..utils.image import get_image_size
@celery.task() @celery.task()
@ -112,6 +115,11 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
for meta in getMetaPackages(optional_depends): for meta in getMetaPackages(optional_depends):
db.session.add(Dependency(package, meta=meta, optional=True)) db.session.add(Dependency(package, meta=meta, optional=True))
# Update game supports
if package.type == PackageType.MOD:
resolver = GameSupportResolver()
resolver.update(package)
# Update min/max # Update min/max
if tree.meta.get("min_minetest_version"): if tree.meta.get("min_minetest_version"):
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None) release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
@ -213,6 +221,10 @@ def importRepoScreenshot(id):
ss.package = package ss.package = package
ss.title = "screenshot.png" ss.title = "screenshot.png"
ss.url = "/uploads/" + filename ss.url = "/uploads/" + filename
ss.width, ss.height = get_image_size(destPath)
if ss.is_too_small():
return None
db.session.add(ss) db.session.add(ss)
db.session.commit() db.session.commit()

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title> <title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css"> <link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=32"> <link rel="stylesheet" type="text/css" href="/static/custom.css?v=34">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" /> <link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16"> <link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
<link rel="icon" href="/favicon-128.png" sizes="128x128"> <link rel="icon" href="/favicon-128.png" sizes="128x128">

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
{% for type, group in notifications | groupby("package.title") %} {% for title, group in notifications | selectattr("package") | groupby("package.title") %}
<h2> <h2>
{{ type or _("Other Notifications") }} {{ title }}
</h2> </h2>
<ul> <ul>
@ -17,6 +17,23 @@
</ul> </ul>
{% endfor %} {% endfor %}
{% set other_notifications = notifications | selectattr("package", "none") %}
{% if other_notifications %}
<h2>
{{ _("Other Notifications") }}
</h2>
<ul>
{% for notification in other_notifications %}
<li>
<a href="{{ notification.url | abs_url }}">{{ notification.title }}</a> -
{{ _("from %(username)s.", username=notification.causer.username) }}
</li>
{% endfor %}
</ul>
{% endif %}
<p style="margin-top: 3em;"> <p style="margin-top: 3em;">
<a class="btn" href="{{ abs_url_for('notifications.list_all') }}"> <a class="btn" href="{{ abs_url_for('notifications.list_all') }}">
{{ _("View Notifications") }} {{ _("View Notifications") }}

View File

@ -0,0 +1,13 @@
<p>
{{ _("We were unable to perform the password reset as we could not find an account associated with this email.") }}
</p>
<p>
{{ _("This may be because you used another email with your account, or because you never confirmed your email.") }}
</p>
<p>
{{ _("You can use GitHub to log in if it is associated with your account.") }}
{{ _("Otherwise, you may need to contact rubenwardy for help.") }}
</p>
<p>
{{ _("If you weren't expecting to receive this email, then you can safely ignore it.") }}
</p>

View File

@ -3,7 +3,7 @@
{% for entry in log %} {% for entry in log %}
<a class="list-group-item list-group-item-action" <a class="list-group-item list-group-item-action"
{% if entry.description and current_user.rank.atLeast(current_user.rank.MODERATOR) %} {% if entry.description and current_user.rank.atLeast(current_user.rank.MODERATOR) %}
href="{{ url_for('admin.audit_view', id=entry.id) }}"> href="{{ url_for('admin.audit_view', id_=entry.id) }}">
{% else %} {% else %}
href="{{ entry.url }}"> href="{{ entry.url }}">
{% endif %} {% endif %}

View File

@ -14,19 +14,24 @@
</div> </div>
{% set level = "warning" %} {% set level = "warning" %}
{% if package.releases.count() == 0 %} {% if package.releases.filter_by(task_id=None).count() == 0 %}
{% set message %} {% set message %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %} {% if package.checkPerm(current_user, "MAKE_RELEASE") %}
{% if package.update_config %} {% if package.update_config %}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.create_release") }}"> <a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.create_release') }}">
{{ _("Create first release") }} {{ _("Create release") }}
</a> </a>
{% else %} {% else %}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.setup_releases") }}"> <a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.setup_releases') }}">
{{ _("Set up releases") }} {{ _("Set up releases") }}
</a> </a>
{% endif %} {% endif %}
{{ _("You need to create a release before this package can be approved.") }}
{% if package.releases.count() == 0 %}
{{ _("You need to create a release before this package can be approved.") }}
{% else %}
{{ _("Release is still importing, or has an error.") }}
{% endif %}
{% else %} {% else %}
{{ _("A release is required before this package can be approved.") }} {{ _("A release is required before this package can be approved.") }}
{% endif %} {% endif %}

View File

@ -4,18 +4,21 @@
{{ mpackage.name }} - {{ _("Meta Packages") }} {{ mpackage.name }} - {{ _("Meta Packages") }}
{% endblock %} {% endblock %}
{% from "macros/packagegridtile.html" import render_pkggrid %}
{% block content %} {% block content %}
<h1>{{ _("Meta Package \"%(name)s\"", name=mpackage.name) }}</h1> <h1>{{ _("Meta Package \"%(name)s\"", name=mpackage.name) }}</h1>
<h2>{{ _("Provided By") }}</h2> <h2>{{ _("Provided By") }}</h2>
{% from "macros/packagegridtile.html" import render_pkggrid %} <h3>{{ _("Games") }}</h3>
{{ render_pkggrid(mpackage.packages.filter_by(state="APPROVED").all()) }} {{ render_pkggrid(mpackage.packages.filter_by(type="GAME", state="APPROVED").all()) }}
<h3>{{ _("Mods") }}</h3>
{{ render_pkggrid(mpackage.packages.filter_by(type="MOD", state="APPROVED").all()) }}
{% if similar_topics %} {% if similar_topics %}
<p> <h3>{{ _("Forum Topics") }}</h3>
{{ _("Unfortunately, this isn't on ContentDB yet! Here's some forum topic(s):") }}
</p>
<ul> <ul>
{% for t in similar_topics %} {% for t in similar_topics %}
<li> <li>

View File

@ -117,6 +117,7 @@
pattern="[0-9]+", pattern="[0-9]+",
prefix="forum.minetest.net/viewtopic.php?t=", prefix="forum.minetest.net/viewtopic.php?t=",
placeholder=_("Tip: paste in a forum topic URL")) }} placeholder=_("Tip: paste in a forum topic URL")) }}
{{ render_field(form.video_url, class_="pkg_meta", hint=_("YouTube videos will be shown in an embed.")) }}
</fieldset> </fieldset>
<div class="pkg_meta mt-5">{{ render_submit_field(form.submit) }}</div> <div class="pkg_meta mt-5">{{ render_submit_field(form.submit) }}</div>

View File

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}
{{ _("Community Hub") }} -
{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}
{% endblock %}
{% block headextra %}
<meta name="og:title" content="{{ self.title() }}"/>
<meta name="og:description" content="{{ _('Mods for %(title)s', title=package.title) }}"/>
<meta name="description" content="{{ _('Mods for %(title)s', title=package.title) }}"/>
<meta name="og:url" content="{{ package.getURL('packages.game_hub', absolute=True) }}"/>
{% if package.getMainScreenshotURL() %}
<meta name="og:image" content="{{ package.getMainScreenshotURL(absolute=True) }}"/>
{% endif %}
{% endblock %}
{% block content %}
{% from "macros/packagegridtile.html" import render_pkggrid %}
<h1 class="mb-5">
{{ _("Community Hub") }} -
<a href="{{ package.getURL('packages.view') }}">
{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}
</a>
</h1>
<a href="{{ url_for('packages.list_all', sort='approved_at', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Recently Added") }}</h2>
{{ render_pkggrid(new) }}
<a href="{{ url_for('packages.list_all', sort='last_release', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Recently Updated") }}</h2>
{{ render_pkggrid(updated) }}
<a href="{{ url_for('packages.list_all', type='mod', sort='score', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Top Mods") }}</h2>
{{ render_pkggrid(pop_mod) }}
<a href="{{ url_for('packages.list_all', sort='reviews', order='desc', game=package.getId()) }}" class="btn btn-secondary float-right">
{{ _("See more") }}
</a>
<h2 class="my-3">{{ _("Highest Reviewed") }}</h2>
{{ render_pkggrid(high_reviewed) }}
{% endblock %}

View File

@ -32,8 +32,6 @@
<p class="mt-3"> <p class="mt-3">
{{ _("Note: Min and max versions will be used to hide the package on {{ _("Note: Min and max versions will be used to hide the package on
platforms not within the range.") }} platforms not within the range.") }}
{{ _("You cannot select the oldest version for min or the newest version
for max as this does not make sense - you can't predict the future.") }}
<br /> <br />
{{ _("Leave both as None if in doubt.") }} {{ _("Leave both as None if in doubt.") }}
</p> </p>

View File

@ -62,11 +62,6 @@
{{ _("You can <a href='/help/package_config/'>set this automatically</a> in the .conf of your package.") }} {{ _("You can <a href='/help/package_config/'>set this automatically</a> in the .conf of your package.") }}
</p> </p>
<p>
{{ _("You cannot select the oldest version for min or the newest version
for max as this does not make sense - you can't predict the future.") }}
</p>
<p class="mt-5"> <p class="mt-5">
{{ render_submit_field(form.submit) }} {{ render_submit_field(form.submit) }}
</p> </p>

View File

@ -82,11 +82,6 @@
<br /> <br />
{{ _("Leave both as None if in doubt.") }} {{ _("Leave both as None if in doubt.") }}
</p> </p>
<p>
{{ _("You cannot select the oldest version for min or the newest version
for max as this does not make sense - you can't predict the future.") }}
</p>
<p class="mt-5"> <p class="mt-5">
{{ render_submit_field(form.submit) }} {{ render_submit_field(form.submit) }}
</p> </p>

View File

@ -1,11 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{{ _("Add a screenshot") }} | {{ package.title }} {{ _("Add a screenshot") }} - {{ package.title }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>{{ _("Add a screenshot") }}</h1> <h1>{{ _("Add a screenshot") }}</h1>
<p class="mb-4">
{{ _("The recommended resolution is 1920x1080, and screenshots must be at least %(width)dx%(height)d.",
width=920, height=517) }}
</p>
{% from "macros/forms.html" import render_field, render_submit_field %} {% from "macros/forms.html" import render_field, render_submit_field %}
<form method="POST" action="" enctype="multipart/form-data"> <form method="POST" action="" enctype="multipart/form-data">

View File

@ -6,7 +6,7 @@
{% block content %} {% block content %}
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %} {% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<a href="{{ package.getURL("packages.create_screenshot") }}" class="btn btn-primary float-right"> <a href="{{ package.getURL('packages.create_screenshot') }}" class="btn btn-primary float-right">
<i class="fas fa-plus mr-1"></i> <i class="fas fa-plus mr-1"></i>
{{ _("Add Image") }} {{ _("Add Image") }}
</a> </a>
@ -26,16 +26,34 @@
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<img class="img-fluid" style="max-height: 64px;" <img class="img-fluid" style="max-height: 64px;" src="{{ ss.getThumbnailURL() }}" />
src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
</div> </div>
<div class="col"> <div class="col">
{{ ss.title }} {{ ss.title }}
{% if not ss.approved %}
<div class="text-muted"> <div class="mt-1 text-muted">
{{ _("Awaiting approval") }} {{ ss.width }} x {{ ss.height }}
</div> {% if ss.is_low_res() %}
{% endif %} {% if ss.is_very_small() %}
<span class="badge badge-danger ml-3">
{{ _("Way too small") }}
</span>
{% elif ss.is_too_small() %}
<span class="badge badge-warning ml-3">
{{ _("Too small") }}
</span>
{% else %}
<span class="badge badge-secondary ml-3">
{{ _("Not HD") }}
</span>
{% endif %}
{% endif %}
{% if not ss.approved %}
<span class="ml-3">
{{ _("Awaiting approval") }}
</span>
{% endif %}
</div>
</div> </div>
<form action="{{ ss.getDeleteURL() }}" method="POST" class="col-auto text-right" role="form"> <form action="{{ ss.getDeleteURL() }}" method="POST" class="col-auto text-right" role="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
@ -78,6 +96,11 @@
{{ render_submit_field(form.submit, tabindex=280) }} {{ render_submit_field(form.submit, tabindex=280) }}
</form> </form>
<h2>{{ _("Videos") }}</h2>
<p>
{{ _("You can set a video on the Edit Details page") }}
</p>
{% endblock %} {% endblock %}
{% block scriptextra %} {% block scriptextra %}

View File

@ -1,4 +1,5 @@
{% set query=package.name %} {% set query=package.name %}
{% set release = package.getDownloadRelease() %}
{% extends "base.html" %} {% extends "base.html" %}
@ -16,6 +17,10 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block scriptextra %}
<script src="/static/video_embed.js"></script>
{% endblock %}
{% macro render_license(license) %} {% macro render_license(license) %}
{% if license.url %} {% if license.url %}
<a href="{{ license.url }}">{{ license.name }}</a> <a href="{{ license.url }}">{{ license.name }}</a>
@ -24,6 +29,52 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% block download_btn %}
{% if release %}
<a class="btn btn-block btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
href="{{ package.getURL('packages.download') }}">
<div>
{{ _("Download") }}
</div>
{% if release and (release.min_rel or release.max_rel) %}
<small class="count display-block">
{% if release.min_rel and release.max_rel %}
{{ _("Minetest %(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
{% elif release.min_rel %}
{{ _("For Minetest %(min)s and above", min=release.min_rel.name) }}
{% elif release.max_rel %}
{{ _("Minetest %(max)s and below", max=release.max_rel.name) }}
{% endif %}
</small>
{% endif %}
</a>
{% if package.type == package.type.MOD %}
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
{% elif package.type == package.type.GAME %}
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
{% elif package.type == package.type.TXP %}
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
{% else %}
{{ 0 / 0 }}
{% endif %}
<p class="text-center mt-1 mb-4">
<a href="{{ installing_url }}">
<small>
<i class="fas fa-question-circle mr-1"></i>
{{ _("How do I install this?") }}
</small>
</a>
</p>
{% else %}
<i>
{{ _("No downloads available") }}
</i>
{% endif %}
{% endblock %}
{% block container %} {% block container %}
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %} {% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
{% set package_warning=_("Non-free code and media") %} {% set package_warning=_("Non-free code and media") %}
@ -32,7 +83,6 @@
{% elif not package.media_license.is_foss %} {% elif not package.media_license.is_foss %}
{% set package_warning=_("Non-free media") %} {% set package_warning=_("Non-free media") %}
{% endif %} {% endif %}
{% set release = package.getDownloadRelease() %}
<main> <main>
{% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %} {% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %}
<header class="jumbotron pb-3" <header class="jumbotron pb-3"
@ -43,19 +93,19 @@
<div class="container"> <div class="container">
<div class="btn-group float-right mb-4"> <div class="btn-group float-right mb-4">
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %} {% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
<a class="btn btn-primary" href="{{ package.getURL("packages.create_edit") }}"> <a class="btn btn-primary" href="{{ package.getURL('packages.create_edit') }}">
<i class="fas fa-pen mr-1"></i> <i class="fas fa-pen mr-1"></i>
{{ _("Edit") }} {{ _("Edit") }}
</a> </a>
{% endif %} {% endif %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %} {% if package.checkPerm(current_user, "MAKE_RELEASE") %}
<a class="btn btn-primary" href="{{ package.getURL("packages.create_release") }}"> <a class="btn btn-primary" href="{{ package.getURL('packages.create_release') }}">
<i class="fas fa-plus mr-1"></i> <i class="fas fa-plus mr-1"></i>
{{ _("Release") }} {{ _("Release") }}
</a> </a>
{% endif %} {% endif %}
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %} {% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
<a class="btn btn-danger" href="{{ package.getURL("packages.remove") }}"> <a class="btn btn-danger" href="{{ package.getURL('packages.remove') }}">
<i class="fas fa-trash mr-1"></i> <i class="fas fa-trash mr-1"></i>
{{ _("Remove") }} {{ _("Remove") }}
</a> </a>
@ -157,46 +207,6 @@
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% if release and (release.min_rel or release.max_rel) %}
<div class="btn col-md-auto">
<img src="https://www.minetest.net/media/icon.svg" style="max-height: 1.2em;">
<span class="count">
{% if release.min_rel and release.max_rel %}
{{ _("%(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
{% elif release.min_rel %}
{{ _("%(min)s and above", min=release.min_rel.name) }}
{% elif release.max_rel %}
{{ _("%(max)s and below", max=release.max_rel.name) }}
{% endif %}
</span>
</div>
{% endif %}
<div class="btn-group btn-group-horizontal col-md-auto">
{% if release %}
<a class="btn btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
href="{{ package.getURL("packages.download") }}">
{{ _("Download") }}
</a>
{% if package.type == package.type.MOD %}
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
{% elif package.type == package.type.GAME %}
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
{% elif package.type == package.type.TXP %}
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
{% else %}
{{ 0 / 0 }}
{% endif %}
<a href="{{ installing_url }}" class="btn btn-download">
<i class="fas fa-question-circle"></i>
</a>
{% else %}
<i>
{{ _("No downloads available") }}
</i>
{% endif %}
</div>
</div> </div>
</div> </div>
</header> </header>
@ -222,37 +232,55 @@
</section> </section>
{% endif %} {% endif %}
<div class="container d-block d-md-none">
{{ self.download_btn() }}
</div>
<section class="container mt-4"> <section class="container mt-4">
<div class="row"> <div class="row">
<div class="col-md-9" style="padding-right: 45px;"> <div class="col-md-9" style="padding-right: 45px;">
{% set screenshots = package.screenshots.all() %} {% set screenshots = package.screenshots.all() %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<a href="{{ package.getURL("packages.screenshots") }}" class="btn btn-primary float-right">
<i class="fas fa-images mr-1"></i>
{{ _("Edit") }}
</a>
{% endif %}
<ul class="screenshot_list"> {% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% for ss in screenshots %} <a href="{{ package.getURL('packages.screenshots') }}" class="btn btn-primary float-right">
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %} <i class="fas fa-images mr-1"></i>
<li> {{ _("Edit") }}
<a href="{{ ss.url }}" class="position-relative"> </a>
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" /> {% endif %}
{% if not ss.approved %}
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span> {% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") or package.video_url %}
{% endif %} <ul class="gallery">
</a> {% if package.video_url %}
</li>
{% endif %}
{% else %}
<li> <li>
<a href="{{ package.getURL("packages.create_screenshot") }}"> <a href="{{ package.video_url }}" class="video-embed">
<i class="fas fa-plus screenshot-add"></i> <i class="fas fa-play"></i>
<div class="label">
<i class="fas fa-external-link-square-alt"></i>
</div>
</a> </a>
</li> </li>
{% endfor %} {% endif %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% for ss in screenshots %}
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<li>
<a href="{{ ss.url }}" class="gallery-image">
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
{% if not ss.approved %}
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% else %}
<li>
<a href="{{ package.getURL('packages.create_screenshot') }}">
<i class="fas fa-plus screenshot-add"></i>
</a>
</li>
{% endfor %}
{% endif %}
</ul> </ul>
{% endif %} {% endif %}
@ -294,9 +322,20 @@
{% from "macros/packagegridtile.html" import render_pkggrid %} {% from "macros/packagegridtile.html" import render_pkggrid %}
{{ render_pkggrid(packages_uses) }} {{ render_pkggrid(packages_uses) }}
{% endif %} {% endif %}
{% if package.type == package.type.GAME %}
<h2>{{ _("Content") }}</h2>
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-primary">
{{ _("View content for game") }}
</a>
{% endif %}
</div> </div>
<aside class="col-md-3 info-sidebar"> <aside class="col-md-3 info-sidebar">
<div class="d-none d-md-block">
{{ self.download_btn() }}
</div>
{% if package.checkPerm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %} {% if package.checkPerm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
{% set config = package.update_config %} {% set config = package.update_config %}
<div class="alert alert-warning"> <div class="alert alert-warning">
@ -339,6 +378,12 @@
</div> </div>
{% endif %} {% endif %}
{% if package.type == package.type.GAME %}
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-block mb-4 btn-primary">
{{ _("View content for game") }}
</a>
{% endif %}
{% if package.type != package.type.TXP %} {% if package.type != package.type.TXP %}
<h3>{{ _("Dependencies") }}</h3> <h3>{{ _("Dependencies") }}</h3>
<dl> <dl>
@ -387,6 +432,23 @@
</dl> </dl>
{% endif %} {% endif %}
{% if package.type == package.type.MOD %}
<h3>{{ _("Compatible Games") }}</h3>
{% for support in package.getSortedSupportedGames() %}
<a class="badge badge-secondary"
href="{{ support.game.getURL('packages.view') }}">
{{ _("%(title)s by %(display_name)s",
title=support.game.title, display_name=support.game.author.display_name) }}
</a>
{% else %}
{{ _("No specific game is required") }}
{% endfor %}
<p class="text-muted small mt-2 mb-0">
{{ _("This is an experimental feature.") }}
{{ _("Supported games are determined by an algorithm, and may not be correct.") }}
</p>
{% endif %}
<h3> <h3>
{{ _("Information") }} {{ _("Information") }}
</h3> </h3>
@ -482,7 +544,7 @@
{% if package.approved and current_user != package.author %} {% if package.approved and current_user != package.author %}
| |
{% endif %} {% endif %}
<a href="{{ package.getURL("packages.audit") }}"> <a href="{{ package.getURL('packages.audit') }}">
{{ _("See audit log") }} {{ _("See audit log") }}
</a> </a>
{% endif %} {% endif %}

View File

@ -25,6 +25,9 @@
{{ _("Only the admin will be able to see who made the report.") }} {{ _("Only the admin will be able to see who made the report.") }}
{% endif %} {% endif %}
</p> </p>
<p class="alert alert-info">
{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }}
</p>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -36,10 +36,16 @@
<input type="submit" class="btn btn-primary" value="{{ _('Subscribe') }}" /> <input type="submit" class="btn btn-primary" value="{{ _('Subscribe') }}" />
</form> </form>
{% endif %} {% endif %}
{% if thread and thread.checkPerm(current_user, "DELETE_THREAD") %} {% if thread.checkPerm(current_user, "DELETE_THREAD") %}
<a href="{{ url_for('threads.delete_thread', id=thread.id) }}" class="float-right mr-2 btn btn-danger">{{ _('Delete') }}</a> <a href="{{ url_for('threads.delete_thread', id=thread.id) }}" class="float-right mr-2 btn btn-danger">{{ _('Delete') }}</a>
{% endif %} {% endif %}
{% if thread and thread.checkPerm(current_user, "LOCK_THREAD") %} {% if thread.review and thread.review.checkPerm(current_user, "DELETE_REVIEW") and current_user.username != thread.review.author.username %}
<form method="post" action="{{ thread.review.getDeleteURL() }}" class="float-right mr-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="btn btn-danger" value="{{ _('Convert to Thread') }}" />
</form>
{% endif %}
{% if thread.checkPerm(current_user, "LOCK_THREAD") %}
{% if thread.locked %} {% if thread.locked %}
<form method="post" action="{{ url_for('threads.set_lock', id=thread.id, lock=0) }}" class="float-right mr-2"> <form method="post" action="{{ url_for('threads.set_lock', id=thread.id, lock=0) }}" class="float-right mr-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />

View File

@ -14,6 +14,7 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
<h2>{{ _("Unapproved Packages Needing Action") }}</h2> <h2>{{ _("Unapproved Packages Needing Action") }}</h2>
<div class="list-group mt-3 mb-5"> <div class="list-group mt-3 mb-5">
{% for package in unapproved_packages %} {% for package in unapproved_packages %}
@ -53,21 +54,75 @@
</form> </form>
{% endif %} {% endif %}
<h2>{{ _("Potentially Outdated Packages") }}</h2> <h2>{{ _("Potentially Outdated Packages") }}</h2>
<p class="alert alert-info">
{{ _("New: Git Update Detection has been set up on all packages to send notifications.") }}<br />
{{ _("Consider changing the update settings to create releases automatically instead.") }}
</p>
<p> <p>
{{ _("Instead of marking packages as outdated, you can automatically create releases when New Commits or New Tags are pushed to Git by clicking 'Update Settings'.") }} {{ _("Instead of marking packages as outdated, you can automatically create releases when New Commits or New Tags are pushed to Git by clicking 'Update Settings'.") }}
{% if outdated_packages %} {% if outdated_packages %}
{{ _("To remove a package from below, create a release or change the update settings.") }} {{ _("To remove a package from below, create a release or change the update settings.") }}
{% endif %} {% endif %}
</p> </p>
{% from "macros/todo.html" import render_outdated_packages %} {% from "macros/todo.html" import render_outdated_packages %}
{{ render_outdated_packages(outdated_packages, current_user) }} {{ render_outdated_packages(outdated_packages, current_user) }}
<div class="mt-5"></div> <div class="mt-5"></div>
<h2 id="small-screenshots">{{ _("Small Screenshots") }}</h2>
{% if packages_with_small_screenshots %}
<p>
{{ _("These packages have screenshots that are too small, and should be replaced.") }}
{{ _("Red and orange are screenshots below the limit, and grey screenshots are below the recommended resolution.") }}
{{ _("The recommended resolution is 1920x1080, and screenshots must be at least %(width)dx%(height)d.",
width=920, height=517) }}
<span class="badge badge-danger ml-3">
{{ _("Way too small") }}
</span>
<span class="badge badge-warning">
{{ _("Too small") }}
</span>
<span class="badge badge-secondary">
{{ _("Not HD") }}
</span>
</p>
{% endif %}
<div class="list-group mt-3 mb-5">
{% for package in packages_with_small_screenshots %}
<a class="list-group-item list-group-item-action" href="{{ package.getURL('packages.screenshots') }}">
<div class="row">
<div class="col-sm-3 text-muted" style="min-width: 200px;">
<img
class="img-fluid"
style="max-height: 22px; max-width: 22px;"
src="{{ package.getThumbnailOrPlaceholder() }}" />
<span class="pl-2">
{{ package.title }}
</span>
</div>
<div class="col-sm">
{% for ss in package.screenshots %}
{% if ss.is_low_res() %}
{% if ss.is_very_small() %}
{% set badge_color = "badge-danger" %}
{% elif ss.is_too_small() %}
{% set badge_color = "badge-warning" %}
{% else %}
{% set badge_color = "badge-secondary" %}
{% endif %}
<span class="badge {{ badge_color }} ml-2" title="{{ ss.title }}">
{{ ss.width }} x {{ ss.height }}
</span>
{% endif %}
{% endfor %}
</div>
</div>
</a>
{% else %}
<p class="text-muted">{{ _("Nothing to do :)") }}</p>
{% endfor %}
</div>
<a class="btn btn-secondary float-right" href="{{ url_for('todo.tags', author=user.username) }}"> <a class="btn btn-secondary float-right" href="{{ url_for('todo.tags', author=user.username) }}">
{{_ ("See All") }}</a> {{_ ("See All") }}</a>
<h2>{{ _("Packages Without Tags") }}</h2> <h2>{{ _("Packages Without Tags") }}</h2>

View File

@ -41,8 +41,8 @@
</strong>. </strong>.
</p> </p>
<p class="mb-0"> <p class="mb-0">
{{ _("ContentDB will no longer be able to send "forget password" and other essential system emails. {{ _('ContentDB will no longer be able to send "forget password" and other essential system emails.
Consider editing your email notification preferences instead.") }} Consider editing your email notification preferences instead.') }}
</p> </p>
</div> </div>
{% else %} {% else %}

View File

@ -0,0 +1,13 @@
from app.utils.url import clean_youtube_url
def test_clean_youtube_url():
assert clean_youtube_url(
"https://www.youtube.com/watch?v=AABBCC") == "https://www.youtube.com/watch?v=AABBCC"
assert clean_youtube_url(
"https://www.youtube.com/watch?v=boGcB4H5-WA&other=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
assert clean_youtube_url("https://www.youtube.com/watch?kk=boGcB4H5-WA&other=1") is None
assert clean_youtube_url("https://www.bob.com/watch?v=AABBCC") is None
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA") == "https://www.youtube.com/watch?v=boGcB4H5-WA"
assert clean_youtube_url("https://youtu.be/boGcB4H5-WA?this=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA"

View File

@ -14,13 +14,12 @@
# You should have received a copy of the GNU Affero 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import re
import secrets import secrets
from .flask import * from .flask import *
from .models import * from .models import *
from .user import * from .user import *
import re
YESES = ["yes", "true", "1", "on"] YESES = ["yes", "true", "1", "on"]

View File

@ -45,6 +45,9 @@ def abs_url_samesite(path):
return urlunparse(base._replace(path=path)) return urlunparse(base._replace(path=path))
def url_current(abs=False): def url_current(abs=False):
if request.args is None or request.view_args is None:
return None
args = MultiDict(request.args) args = MultiDict(request.args)
dargs = dict(args.lists()) dargs = dict(args.lists())
dargs.update(request.view_args) dargs.update(request.view_args)

24
app/utils/image.py Normal file
View File

@ -0,0 +1,24 @@
# 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 typing import Tuple
from PIL import Image
def get_image_size(path: str) -> Tuple[int,int]:
im = Image.open(path)
return im.size

View File

@ -18,8 +18,7 @@
from functools import wraps from functools import wraps
from flask import abort, redirect, url_for, request from flask import abort, redirect, url_for, request
from flask_login import current_user from flask_login import current_user
from app.models import User, NotificationType, Package, UserRank, Notification, db, AuditSeverity, AuditLogEntry, \ from app.models import User, NotificationType, Package, UserRank, Notification, db, AuditSeverity, AuditLogEntry, ThreadReply, Thread, PackageState, PackageType, PackageAlias
ThreadReply, Thread, PackageState, PackageType, PackageAlias
def getPackageByInfo(author, name): def getPackageByInfo(author, name):
@ -40,15 +39,14 @@ def is_package_page(f):
if not ("author" in kwargs and "name" in kwargs): if not ("author" in kwargs and "name" in kwargs):
abort(400) abort(400)
author = kwargs.pop("author") author = kwargs["author"]
name = kwargs.pop("name") name = kwargs["name"]
package = getPackageByInfo(author, name) package = getPackageByInfo(author, name)
if package is None: if package is None:
package = getPackageByInfo(author, name + "_game") package = getPackageByInfo(author, name + "_game")
if package and package.type == PackageType.GAME: if package and package.type == PackageType.GAME:
args = dict(kwargs) args = dict(kwargs)
args["author"] = author
args["name"] = name + "_game" args["name"] = name + "_game"
return redirect(url_for(request.endpoint, **args)) return redirect(url_for(request.endpoint, **args))
@ -61,6 +59,8 @@ def is_package_page(f):
abort(404) abort(404)
del kwargs["author"]
del kwargs["name"]
return f(package=package, *args, **kwargs) return f(package=package, *args, **kwargs)
return decorated_function return decorated_function

46
app/utils/url.py Normal file
View File

@ -0,0 +1,46 @@
# 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 urllib.parse as urlparse
from typing import Optional, Dict, List
def url_set_query(url: str, params: Dict[str, str]) -> str:
url_parts = list(urlparse.urlparse(url))
query = dict(urlparse.parse_qsl(url_parts[4]))
query.update(params)
url_parts[4] = urlparse.urlencode(query)
return urlparse.urlunparse(url_parts)
def url_get_query(parsed_url: urlparse.ParseResult) -> Dict[str, List[str]]:
return urlparse.parse_qs(parsed_url.query)
def clean_youtube_url(url: str) -> Optional[str]:
parsed = urlparse.urlparse(url)
print(parsed)
if (parsed.netloc == "www.youtube.com" or parsed.netloc == "youtube.com") and parsed.path == "/watch":
print(url_get_query(parsed))
video_id = url_get_query(parsed).get("v", [None])[0]
if video_id:
return url_set_query("https://www.youtube.com/watch", {"v": video_id})
elif parsed.netloc == "youtu.be":
return url_set_query("https://www.youtube.com/watch", {"v": parsed.path[1:]})
return None

View File

@ -5,7 +5,7 @@ BASE_URL = "http://" + SERVER_NAME
SECRET_KEY = "" SECRET_KEY = ""
WTF_CSRF_SECRET_KEY = "" WTF_CSRF_SECRET_KEY = ""
SQLALCHEMY_DATABASE_URI = "postgres://contentdb:password@db:5432/contentdb" SQLALCHEMY_DATABASE_URI = "postgresql://contentdb:password@db:5432/contentdb"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
GITHUB_CLIENT_ID = "" GITHUB_CLIENT_ID = ""

View File

@ -8,7 +8,7 @@ services:
- config.env - config.env
redis: redis:
image: 'redis:3.0-alpine' image: 'redis:6.2-alpine'
command: redis-server command: redis-server
volumes: volumes:
- './data/redis:/data' - './data/redis:/data'
@ -30,7 +30,7 @@ services:
worker: worker:
build: . build: .
command: celery -A app.tasks.celery worker command: celery -A app.tasks.celery worker --concurrency 1
env_file: env_file:
- config.env - config.env
environment: environment:

105
docs/dev_intro.md Normal file
View File

@ -0,0 +1,105 @@
# Developer Introduction
## Overview
ContentDB is a Python [Flask](https://flask.palletsprojects.com/en/2.0.x/) webservice.
There's a PostgreSQL database, manipulated using the [SQLAlchemy ORM](https://docs.sqlalchemy.org/en/14/).
When a user makes a request, Python Flask will direct the request to a *route* in an *blueprint*.
A [blueprint](https://flask.palletsprojects.com/en/2.0.x/blueprints/) is a Flask construct to hold a set of routes.
Routes are implemented using Python, and likely to respond by using database *models* and rendering HTML *templates*.
Routes may also use functions in the `app/logic/` module, which is a directory containing reusable functions. This
allows the API, background tasks, and the front-end to reuse code.
To avoid blocking web requests, background tasks run as
[Celery](https://docs.celeryproject.org/en/stable/getting-started/introduction.html) tasks.
## Locations
### The App
The `app` directory contains the Python Flask application.
* `blueprints` contains all the Python code behind each endpoint / route.
* `templates` contains all the HTML templates used to generate responses. Each directory in here matches a directory in blueprints.
* `models` contains all the database table classes. ContentDB uses [SQLAlchemy](https://docs.sqlalchemy.org/en/14/) to interact with PostgreSQL.
* `flatpages` contains all the markdown user documentation, including `/help/`.
* `public` contains files that should be added to the web server unedited. Examples include CSS libraries, images, and JS scripts.
* `scss` contains the stylesheet files, that are compiled into CSS.
* `tasks` contains the background tasks executed by [Celery](https://docs.celeryproject.org/en/stable/getting-started/introduction.html).
* `logic` is a collection of reusable functions. For example, shared code to create a release or edit a package is here.
* `tests` contains the Unit Tests and UI tests.
* `utils` contain generic Python utilities, for example common code to manage Flask requests.
There are also a number of Python files in the `app` directory. The most important one is `querybuilder.py`,
which is used to generate SQLAlachemy queries for packages and topics.
### Supporting directories
* `migrations` contains code to manage database updates.
* `translations` contains user-maintained translations / locales.
* `utils` contains bash scripts to aid development and deployment.
## How to find stuff
Generally, you want to start by finding the endpoint and then seeing the code it calls.
Endpoints are sensibly organised in `app/blueprints`.
You can also use a file search. For example, to find the package edit endpoint, search for `"/packages/<author>/<name>/edit/"`.
## Users and Permissions
Many routes need to check whether a user can do a particular thing. Rather than hard coding this,
models tend to have a `checkPerm` function which takes a user and a `Permission`.
A permission may be something like `Permission.EDIT_PACKAGE` or `Permission.DELETE_THREAD`.
```bash
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
abort(403)
```
## Translations
ContentDB uses [Flask-Babel](https://flask-babel.tkte.ch/) for translation. All strings need to be tagged using
a gettext function.
### Translating templates (HTML)
```html
<div class="something" title="{{ _('This is translatable now') }}">
{{ _("Please remember to do something related to this page or something") }}
</div>
```
With parameters:
```html
<p>
{{ _("Hello %(username)s, you have %(count)d new messages", username=username, count=count) }}
</p>
```
See <https://pythonhosted.org/Flask-Babel/#flask.ext.babel.Babel.localeselector> and
<https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xiv-i18n-and-l10n>.
### Translating Python
If the text is within a request, then you can use gettext like so:
```py
flash(gettext("Some error message"), "danger")
```
If the text is global, for example as part of a python class, then you need to use lazy_gettext:
```py
class PackageForm(FlaskForm):
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)])
```

View File

@ -54,3 +54,5 @@ To hot/live update CDB whilst it is running, use:
./utils/reload.sh ./utils/reload.sh
This will only work with python code and templates, it won't update tasks or config. This will only work with python code and templates, it won't update tasks or config.
Now consider reading the [Developer Introduction](dev_intro.md).

View File

@ -0,0 +1,25 @@
"""empty message
Revision ID: 011e42c52d21
Revises: 6e57b2b4dcdf
Create Date: 2022-01-25 18:48:46.367409
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '011e42c52d21'
down_revision = '6e57b2b4dcdf'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('package', sa.Column('video_url', sa.String(length=200), nullable=True))
def downgrade():
op.drop_column('package', 'video_url')

View File

@ -0,0 +1,54 @@
"""empty message
Revision ID: 3710e5fbbe87
Revises: f6ef5f35abca
Create Date: 2022-01-27 18:50:11.705061
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '3710e5fbbe87'
down_revision = 'f6ef5f35abca'
branch_labels = None
depends_on = None
def upgrade():
command = """
CREATE OR REPLACE FUNCTION parse_websearch(config regconfig, search_query text)
RETURNS tsquery AS $$
SELECT
string_agg(
(
CASE
WHEN position('''' IN words.word) > 0 THEN CONCAT(words.word, ':*')
ELSE words.word
END
),
' '
)::tsquery
FROM (
SELECT trim(
regexp_split_to_table(
websearch_to_tsquery(config, lower(search_query))::text,
' '
)
) AS word
) AS words
$$ LANGUAGE SQL IMMUTABLE;
CREATE OR REPLACE FUNCTION parse_websearch(search_query text)
RETURNS tsquery AS $$
SELECT parse_websearch('pg_catalog.simple', search_query);
$$ LANGUAGE SQL IMMUTABLE;"""
op.execute(command)
def downgrade():
op.execute('DROP FUNCTION public.parse_websearch(regconfig, text);')
op.execute('DROP FUNCTION public.parse_websearch(text);')

View File

@ -0,0 +1,24 @@
"""empty message
Revision ID: 6e57b2b4dcdf
Revises: 17b303f33f68
Create Date: 2022-01-22 20:35:25.494712
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6e57b2b4dcdf'
down_revision = '17b303f33f68'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('user', sa.Column('locale', sa.String(length=10), nullable=True))
def downgrade():
op.drop_column('user', 'locale')

View File

@ -0,0 +1,34 @@
"""empty message
Revision ID: e571b3498f9e
Revises: 3710e5fbbe87
Create Date: 2022-02-01 19:30:59.537512
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'e571b3498f9e'
down_revision = '3710e5fbbe87'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('package_game_support',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('package_id', sa.Integer(), nullable=False),
sa.Column('game_id', sa.Integer(), nullable=False),
sa.Column('supports', sa.Boolean(), nullable=False),
sa.Column('confidence', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['game_id'], ['package.id'], ),
sa.ForeignKeyConstraint(['package_id'], ['package.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('game_id', 'package_id', name='_package_game_support_uc')
)
def downgrade():
op.drop_table('package_game_support')

View File

@ -0,0 +1,26 @@
"""empty message
Revision ID: f6ef5f35abca
Revises: 011e42c52d21
Create Date: 2022-01-26 00:10:46.610784
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'f6ef5f35abca'
down_revision = '011e42c52d21'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('package_screenshot', sa.Column('height', sa.Integer(), nullable=False, server_default="0"))
op.add_column('package_screenshot', sa.Column('width', sa.Integer(), nullable=False, server_default="0"))
def downgrade():
op.drop_column('package_screenshot', 'width')
op.drop_column('package_screenshot', 'height')

View File

@ -1,79 +1,82 @@
alembic==1.5.3 alembic==1.7.5
amqp==5.0.5 amqp==5.0.9
attrs==20.3.0 attrs==21.4.0
Babel==2.9.1 Babel==2.9.1
bcrypt==3.2.0 bcrypt==3.2.0
beautifulsoup4==4.9.3 beautifulsoup4==4.10.0
billiard==3.6.3.0 billiard==3.6.4.0
bleach==3.3.0 bleach==4.1.0
blinker==1.4 blinker==1.4
celery==5.0.5 celery==5.2.3
certifi==2020.12.5 certifi==2021.10.8
chardet==4.0.0 cffi==1.15.0
click==7.1.2 charset-normalizer==2.0.10
click-didyoumean==0.0.3 click==8.0.3
click-didyoumean==0.3.0
click-plugins==1.1.1 click-plugins==1.1.1
click-repl==0.1.6 click-repl==0.2.0
coverage==5.4 coverage==6.3
decorator==4.4.2 decorator==5.1.1
dnspython==2.1.0 Deprecated==1.2.13
email-validator==1.1.2 dnspython==2.2.0
Flask==1.1.2 email-validator==1.1.3
Flask-Babel==1.0.0 Flask==2.0.2
Flask-FlatPages==0.7.2 Flask-Babel==2.0.0
Flask-FlatPages==0.8.1
Flask-Gravatar==0.5.0 Flask-Gravatar==0.5.0
Flask-Login==0.5.0 Flask-Login==0.5.0
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-Migrate==2.6.0 Flask-Migrate==3.1.0
Flask-SQLAlchemy==2.4.4 Flask-SQLAlchemy==2.5.1
Flask-WTF==0.14.3 Flask-WTF==1.0.0
git-archive-all==1.23.0 git-archive-all==1.23.0
gitdb==4.0.5 gitdb==4.0.9
GitHub-Flask==3.2.0 GitHub-Flask==3.2.0
GitPython==3.1.12 GitPython==3.1.26
gunicorn==20.0.4 greenlet==1.1.2
importlib-metadata==3.4.0 gunicorn==20.1.0
idna==3.3
iniconfig==1.1.1 iniconfig==1.1.1
itsdangerous==1.1.0 itsdangerous==2.0.1
Jinja2==2.11.3 Jinja2==3.0.3
kombu==5.0.2 kombu==5.2.3
lxml==4.6.3 libsass==0.21.0
Mako==1.1.4 lxml==4.7.1
Markdown==3.3.3 Mako==1.1.6
MarkupSafe==1.1.1 Markdown==3.3.6
packaging==20.9 MarkupSafe==2.0.1
packaging==21.3
passlib==1.7.4 passlib==1.7.4
Pillow==8.3.2 Pillow==9.0.0
pluggy==0.13.1 pluggy==1.0.0
prompt-toolkit==3.0.14 prompt-toolkit==3.0.26
psycopg2==2.8.6 psycopg2==2.9.3
py==1.10.0 py==1.11.0
Pygments==2.7.4 pycparser==2.21
pyparsing==2.4.7 Pygments==2.11.2
pyScss==1.3.7 pyparsing==3.0.7
pytest==6.2.2 pytest==6.2.5
pytest-cov==2.11.1 pytest-cov==3.0.0
python-dateutil==2.8.1 pytz==2021.3
python-editor==1.0.4 PyYAML==6.0
pytz==2021.1 redis==4.1.2
PyYAML==5.4.1 requests==2.27.1
redis==3.5.3 six==1.16.0
requests==2.25.1 smmap==5.0.0
six==1.15.0 soupsieve==2.3.1
smmap==3.0.5 SQLAlchemy==1.4.31
soupsieve==2.1 SQLAlchemy-Searchable==1.4.1
SQLAlchemy==1.3.23 SQLAlchemy-Utils==0.38.2
SQLAlchemy-Searchable==1.2.0
SQLAlchemy-Utils==0.36.8
toml==0.10.2 toml==0.10.2
typing-extensions==3.7.4.3 tomli==2.0.0
ua-parser==0.10.0 ua-parser==0.10.0
urllib3==1.26.5 urllib3==1.26.8
user-agents==2.2.0 user-agents==2.2.0
validators==0.18.2 validators==0.18.2
vine==5.0.0 vine==5.0.0
wcwidth==0.2.5 wcwidth==0.2.5
webencodings==0.5.1 webencodings==0.5.1
Werkzeug==0.16.1 Werkzeug==2.0.2
WTForms==2.2.1 wrapt==1.13.3
zipp==3.4.0 WTForms==3.0.1
WTForms-SQLAlchemy==0.3

View File

@ -24,7 +24,7 @@ GitPython
git-archive-all git-archive-all
lxml lxml
pillow pillow
pyScss libsass
redis redis
psycopg2 psycopg2
@ -38,8 +38,9 @@ ua-parser
user-agents user-agents
Werkzeug Werkzeug
WTForms
SQLAlchemy SQLAlchemy
WTForms
WTForms-SQLAlchemy
requests requests
alembic alembic

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ BASE_URL="http://" + SERVER_NAME
SECRET_KEY="changeme" SECRET_KEY="changeme"
WTF_CSRF_SECRET_KEY="changeme" WTF_CSRF_SECRET_KEY="changeme"
SQLALCHEMY_DATABASE_URI = "postgres://contentdb:password@db:5432/contentdb" SQLALCHEMY_DATABASE_URI = "postgresql://contentdb:password@db:5432/contentdb"
GITHUB_CLIENT_ID = "" GITHUB_CLIENT_ID = ""
GITHUB_CLIENT_SECRET = "" GITHUB_CLIENT_SECRET = ""

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
pybabel extract -F babel.cfg -k lazy_gettext -o translations/messages.pot . pybabel extract -F babel.cfg -k lazy_gettext -o translations/messages.pot .
pybabel update -i translations/messages.pot -d translations pybabel update -i translations/messages.pot -d translations --no-fuzzy-matching