Compare commits

..

1 Commits

Author SHA1 Message Date
rubenwardy
d3bff6fda1 Add reserved namespaces policy
Fixes #222
2022-01-21 22:52:51 +00:00
351 changed files with 16172 additions and 119871 deletions

View File

@@ -1,4 +1,4 @@
FROM python:3.10.11 FROM python:3.6
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

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

View File

@@ -13,22 +13,20 @@
# #
# 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 datetime import datetime
import os
import redis
from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response from flask import *
from flask_babel import Babel, gettext
from flask_flatpages import FlatPages
from flask_github import GitHub
from flask_gravatar import Gravatar from flask_gravatar import Gravatar
from flask_login import logout_user, current_user, LoginManager
from flask_mail import Mail from flask_mail import Mail
from flask_github import GitHub
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from flask_flatpages import FlatPages
from flask_babel import Babel, gettext
from flask_login import logout_user, current_user, LoginManager
import os, redis
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
app = Flask(__name__, static_folder="public/static") app = Flask(__name__, static_folder="public/static")
app.config["FLATPAGES_ROOT"] = "flatpages" app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md" app.config["FLATPAGES_EXTENSION"] = ".md"
@@ -38,30 +36,20 @@ app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations"
app.config["LANGUAGES"] = { app.config["LANGUAGES"] = {
"en": "English", "en": "English",
"de": "Deutsch", "de": "Deutsch",
"es": "Español",
"fr": "Français", "fr": "Français",
"id": "Bahasa Indonesia", "id": "Bahasa Indonesia",
"it": "Italiano",
"ms": "Bahasa Melayu", "ms": "Bahasa Melayu",
"pl": "Język Polski",
"ru": "русский язык",
"sk": "Slovenčina",
"sv": "Svenska",
"tr": "Türkçe",
"uk": "Українська",
"vi": "tiếng Việt",
"zh_Hans": "汉语",
} }
app.config.from_pyfile(os.environ["FLASK_CONFIG"]) app.config.from_pyfile(os.environ["FLASK_CONFIG"])
redis_client = redis.Redis.from_url(app.config["REDIS_URL"]) r = redis.Redis.from_url(app.config["REDIS_URL"])
github = GitHub(app) github = GitHub(app)
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
mail = Mail(app) mail = Mail(app)
pages = FlatPages(app) pages = FlatPages(app)
babel = Babel() babel = Babel(app)
gravatar = Gravatar(app, gravatar = Gravatar(app,
size=64, size=64,
rating="g", rating="g",
@@ -77,7 +65,7 @@ login_manager.init_app(app)
login_manager.login_view = "users.login" login_manager.login_view = "users.login"
from .sass import init_app as sass from .sass import sass
sass(app) sass(app)
@@ -97,82 +85,52 @@ def load_user(user_id):
from .blueprints import create_blueprints from .blueprints import create_blueprints
create_blueprints(app) create_blueprints(app)
@app.route("/uploads/<path:path>") @app.route("/uploads/<path:path>")
def send_upload(path): def send_upload(path):
return send_from_directory(app.config["UPLOAD_DIR"], path) return send_from_directory(app.config["UPLOAD_DIR"], path)
@app.route("/<path:path>/") @app.route("/<path:path>/")
def flatpage(path): def flatpage(path):
page = pages.get_or_404(path) page = pages.get_or_404(path)
template = page.meta.get("template", "flatpage.html") template = page.meta.get("template", "flatpage.html")
return render_template(template, page=page) return render_template(template, page=page)
@app.before_request @app.before_request
def check_for_ban(): def check_for_ban():
if current_user.is_authenticated: if current_user.is_authenticated:
if current_user.ban and current_user.ban.has_expired: if current_user.rank == models.UserRank.BANNED:
models.db.session.delete(current_user.ban) flash(gettext("You have been banned."), "danger")
if current_user.rank == models.UserRank.BANNED:
current_user.rank = models.UserRank.MEMBER
models.db.session.commit()
elif current_user.ban or current_user.rank == models.UserRank.BANNED:
if current_user.ban:
flash(gettext("Banned:") + " " + current_user.ban.message, "danger")
else:
flash(gettext("You have been banned."), "danger")
logout_user() logout_user()
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
elif current_user.rank == models.UserRank.NOT_JOINED: elif current_user.rank == models.UserRank.NOT_JOINED:
current_user.rank = models.UserRank.NEW_MEMBER current_user.rank = models.UserRank.MEMBER
models.db.session.commit() models.db.session.commit()
from .utils import clearNotifications, is_safe_url
from .utils import clear_notifications, is_safe_url, create_session
@app.before_request @app.before_request
def check_for_notifications(): def check_for_notifications():
if current_user.is_authenticated: if current_user.is_authenticated:
clear_notifications(request.path) clearNotifications(request.path)
@app.errorhandler(404) @app.errorhandler(404)
def page_not_found(e): def page_not_found(e):
return render_template("404.html"), 404 return render_template("404.html"), 404
@app.errorhandler(500) @babel.localeselector
def server_error(e):
return render_template("500.html"), 500
def get_locale(): def get_locale():
if not request:
return None
locales = app.config["LANGUAGES"].keys() locales = app.config["LANGUAGES"].keys()
if current_user.is_authenticated and current_user.locale in locales: if request:
return current_user.locale locale = request.cookies.get("locale")
if locale in locales:
return locale
locale = request.cookies.get("locale") return request.accept_languages.best_match(locales)
if locale not in locales:
locale = request.accept_languages.best_match(locales)
if locale and current_user.is_authenticated: return None
with create_session() as new_session:
new_session.query(models.User) \
.filter(models.User.username == current_user.username) \
.update({"locale": locale})
new_session.commit()
return locale
babel.init_app(app, locale_selector=get_locale)
@app.route("/set-locale/", methods=["POST"]) @app.route("/set-locale/", methods=["POST"])
@@ -194,8 +152,4 @@ 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

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

View File

@@ -14,24 +14,22 @@
# 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 os import os
from typing import List from typing import List
import requests import requests
from celery import group, uuid from celery import group
from flask import redirect, url_for, flash, current_app from flask import *
from sqlalchemy import or_, and_ from sqlalchemy import or_
from app.models import PackageRelease, db, Package, PackageState, PackageScreenshot, MetaPackage, User, \ from app.models import *
NotificationType, PackageUpdateConfig, License, UserRank, PackageType from app.tasks.forumtasks import importTopicList, checkAllForumAccounts
from app.tasks.emails import send_pending_digests from app.tasks.importtasks import importRepoScreenshot, checkZipRelease, check_for_updates
from app.tasks.forumtasks import import_topic_list, check_all_forum_accounts from app.utils import addNotification, get_system_user
from app.tasks.importtasks import import_repo_screenshot, check_zip_release, check_for_updates, update_all_game_support
from app.utils import add_notification, get_system_user
actions = {} actions = {}
def action(title: str): def action(title: str):
def func(f): def func(f):
name = f.__name__ name = f.__name__
@@ -44,43 +42,84 @@ 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.isnot(None)).delete() PackageRelease.query.filter(PackageRelease.task_id != 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")
def check_releases():
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
@action("Import forum topic list") tasks = []
for release in releases:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("Reimport packages")
def reimport_packages():
tasks = []
for package in Package.query.filter(Package.state!=PackageState.DELETED).all():
release = package.releases.first()
if release:
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
tasks.append(checkZipRelease.s(release.id, zippath))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("Import topic list")
def import_topic_list(): def import_topic_list():
task = import_topic_list.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 = check_all_forum_accounts.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")
def import_screenshots():
packages = Package.query \
.filter(Package.state!=PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id==PackageScreenshot.package_id) \
.filter(PackageScreenshot.id==None) \
.all()
for package in packages:
importRepoScreenshot.delay(package.id)
@action("Delete unused uploads") return redirect(url_for("admin.admin_page"))
@action("Clean uploads")
def clean_uploads(): def clean_uploads():
upload_dir = current_app.config['UPLOAD_DIR'] upload_dir = 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 get_filenames_from_column(column): def getURLsFromDB(column):
results = db.session.query(column).filter(column.isnot(None), column != "").all() results = db.session.query(column).filter(column != 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 = get_filenames_from_column(PackageRelease.url) release_urls = getURLsFromDB(PackageRelease.url)
screenshot_urls = get_filenames_from_column(PackageScreenshot.url) screenshot_urls = getURLsFromDB(PackageScreenshot.url)
pp_urls = get_filenames_from_column(User.profile_pic)
db_urls = release_urls.union(screenshot_urls).union(pp_urls) db_urls = release_urls.union(screenshot_urls)
unreachable = existing_uploads.difference(db_urls) unreachable = existing_uploads.difference(db_urls)
import sys import sys
@@ -97,17 +136,46 @@ def clean_uploads():
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@action("Delete metapackages")
@action("Delete unused mod names") def del_meta_packages():
def del_mod_names():
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()
query.delete(synchronize_session=False) query.delete(synchronize_session=False)
db.session.commit() db.session.commit()
flash("Deleted " + str(count) + " unused mod names", "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")
def del_removed_packages():
query = Package.query.filter_by(state=PackageState.DELETED)
count = query.count()
for pkg in query.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.commit()
flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page"))
@action("Add update config")
def add_update_config():
added = 0
for pkg in Package.query.filter(Package.repo != None, Package.releases.any(), Package.update_config == None).all():
pkg.update_config = PackageUpdateConfig()
pkg.update_config.auto_created = True
release: PackageRelease = pkg.releases.first()
if release and release.commit_hash:
pkg.update_config.last_commit = release.commit_hash
db.session.add(pkg.update_config)
added += 1
db.session.commit()
flash("Added {} update configs".format(added), "success")
return redirect(url_for("admin.admin_page"))
@action("Run update configs") @action("Run update configs")
def run_update_config(): def run_update_config():
@@ -116,38 +184,36 @@ 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_( users = User.query.filter(User.packages.any(or_(Package.state==PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)))
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:
packages_list = packages_list[0:(100-54-1)] + ""
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL, addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"Did you forget? {packages_list} {havent} been submitted for review yet", f"Did you forget? {packages_list} {havent} been submitted for review yet",
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(
@@ -155,20 +221,19 @@ def remind_outdated():
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.maintainers.contains(user), Package.maintainers.any(User.id==user.id),
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \ Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
.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)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL, addNotification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"The following packages may be outdated: {packages_list}", f"The following packages may be outdated: {packages_list}",
url_for('todo.view_user', username=user.username)) url_for('todo.view_user', username=user.username))
db.session.commit() db.session.commit()
@action("Import licenses from SPDX") @action("Import licenses from SPDX")
def import_licenses(): def import_licenses():
renames = { renames = {
@@ -201,16 +266,17 @@ def import_licenses():
licenses = r.json()["licenses"] licenses = r.json()["licenses"]
existing_licenses = {} existing_licenses = {}
for license_data in License.query.all(): for license in License.query.all():
assert license_data.name not in renames.keys() assert license.name not in renames.keys()
existing_licenses[license_data.name.lower()] = license_data existing_licenses[license.name.lower()] = license
for license_data in licenses: for license in licenses:
obj = existing_licenses.get(license_data["licenseId"].lower()) obj = existing_licenses.get(license["licenseId"].lower())
if obj: if obj:
obj.url = license_data["reference"] obj.url = license["reference"]
elif license_data.get("isOsiApproved") and license_data.get("isFsfLibre") and not license_data["isDeprecatedLicenseId"]: elif license.get("isOsiApproved") and license.get("isFsfLibre") and \
obj = License(license_data["licenseId"], True, license_data["reference"]) not license["isDeprecatedLicenseId"]:
obj = License(license["licenseId"], True, license["reference"])
db.session.add(obj) db.session.add(obj)
db.session.commit() db.session.commit()
@@ -218,132 +284,7 @@ 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.any(), ~User.forum_topics.any(), users = User.query.filter(User.is_active==False, User.packages==None, User.forum_topics==None, User.rank==UserRank.NOT_JOINED).all()
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 == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED)))
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
or_(Package.author == user, Package.maintainers.contains(user)),
Package.video_url == None,
Package.type == PackageType.GAME,
Package.state == PackageState.APPROVED) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"You should add a video to {packages_list}",
url_for('users.profile', username=user.username))
db.session.commit()
@action("Send missing game support notifications")
def remind_missing_game_support():
users = User.query.filter(
User.maintained_packages.any(and_(
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False))).all()
system_user = get_system_user()
for user in users:
packages = db.session.query(Package.title).filter(
Package.maintainers.contains(user),
Package.state != PackageState.DELETED,
Package.type.in_([PackageType.MOD, PackageType.TXP]),
~Package.supported_games.any(),
Package.supports_all_games == False) \
.all()
packages = [pkg[0] for pkg in packages]
packages_list = _package_list(packages)
add_notification(user, system_user, NotificationType.PACKAGE_APPROVAL,
f"You need to confirm whether the following packages support all games: {packages_list}",
url_for('todo.all_game_support', username=user.username))
db.session.commit()
@action("Detect game support")
def detect_game_support():
task_id = uuid()
update_all_game_support.apply_async((), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=url_for("admin.admin_page")))
@action("Send pending notif digests")
def do_send_pending_digests():
send_pending_digests.delay()
@action("DANGER: Delete removed packages")
def del_removed_packages():
query = Package.query.filter_by(state=PackageState.DELETED)
count = query.count()
for pkg in query.all():
pkg.review_thread = None
db.session.delete(pkg)
db.session.commit()
flash("Deleted {} soft deleted packages packages".format(count), "success")
return redirect(url_for("admin.admin_page"))
@action("DANGER: Check all releases (postReleaseCheckUpdate)")
def check_releases():
releases = PackageRelease.query.filter(PackageRelease.url.like("/uploads/%")).all()
tasks = []
for release in releases:
tasks.append(check_zip_release.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("DANGER: Check latest release of all packages (postReleaseCheckUpdate)")
def reimport_packages():
tasks = []
for package in Package.query.filter(Package.state != PackageState.DELETED).all():
release = package.releases.first()
if release:
tasks.append(check_zip_release.s(release.id, release.file_path))
result = group(tasks).apply_async()
while not result.ready():
import time
time.sleep(0.1)
return redirect(url_for("todo.view_editor"))
@action("DANGER: Import screenshots from Git")
def import_screenshots():
packages = Package.query \
.filter(Package.state != PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
.filter(PackageScreenshot.id == None) \
.all()
for package in packages:
import_repo_screenshot.delay(package.id)
return redirect(url_for("admin.admin_page"))

View File

@@ -14,15 +14,15 @@
# 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 redirect, render_template, url_for, request, flash from flask import *
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 StringField, SubmitField, BooleanField from wtforms import *
from wtforms.validators import InputRequired, Length, Optional from wtforms.validators import InputRequired, Length
from app.utils import rank_required, add_audit_log, add_notification, get_system_user, nonempty_or_none from app.utils import rank_required, addAuditLog, addNotification, get_system_user
from . import bp from . import bp
from .actions import actions from .actions import actions
from app.models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType, PackageAlias from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
@bp.route("/admin/", methods=["GET", "POST"]) @bp.route("/admin/", methods=["GET", "POST"])
@@ -30,7 +30,17 @@ from app.models import UserRank, Package, db, PackageState, User, AuditSeverity,
def admin_page(): def admin_page():
if request.method == "POST": if request.method == "POST":
action = request.form["action"] action = request.form["action"]
if action in actions:
if action == "restore":
package = Package.query.get(request.form["package"])
if package is None:
flash("Unknown package", "danger")
else:
package.state = PackageState.READY_FOR_REVIEW
db.session.commit()
return redirect(url_for("admin.admin_page"))
elif action in actions:
ret = actions[action]["func"]() ret = actions[action]["func"]()
if ret: if ret:
return ret return ret
@@ -38,10 +48,9 @@ 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")
@@ -60,13 +69,14 @@ 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")
@@ -75,11 +85,11 @@ class SendNotificationForm(FlaskForm):
def send_bulk_notification(): def send_bulk_notification():
form = SendNotificationForm(request.form) form = SendNotificationForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
add_audit_log(AuditSeverity.MODERATION, current_user, addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data) "Sent bulk notification", None, None, form.title.data)
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all() users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
add_notification(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)
db.session.commit() db.session.commit()
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))
@@ -105,77 +115,11 @@ def restore():
else: else:
package.state = target package.state = target
add_audit_log(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}", addAuditLog(AuditSeverity.EDITOR, current_user, f"Restored package to state {target.value}",
package.get_url("packages.view"), package) package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
return redirect(package.get_url("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.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) return render_template("admin/restore.html", deleted_packages=deleted_packages)
class TransferPackageForm(FlaskForm):
old_username = StringField("Old Username", [InputRequired()])
new_username = StringField("New Username", [InputRequired()])
package = StringField("Package", [Optional()])
remove_maintainer = BooleanField("Remove current owner from maintainers")
submit = SubmitField("Transfer")
def perform_transfer(form: TransferPackageForm):
query = Package.query.filter(Package.author.has(username=form.old_username.data))
if nonempty_or_none(form.package.data):
query = query.filter_by(name=form.package.data)
packages = query.all()
if len(packages) == 0:
flash("Unable to find package(s)", "danger")
return
new_user = User.query.filter_by(username=form.new_username.data).first()
if new_user is None:
flash("Unable to find new user", "danger")
return
names = [x.name for x in packages]
already_existing = Package.query.filter(Package.author_id == new_user.id, Package.name.in_(names)).all()
if len(already_existing) > 0:
existing_names = [x.name for x in already_existing]
flash("Unable to transfer packages as names exist at destination: " + ", ".join(existing_names), "danger")
return
for package in packages:
if form.remove_maintainer.data:
package.maintainers.remove(package.author)
package.author = new_user
package.maintainers.append(new_user)
package.aliases.append(PackageAlias(form.old_username.data, package.name))
add_audit_log(AuditSeverity.MODERATION, current_user,
f"Transferred {form.old_username.data}/{package.name} to {form.new_username.data}",
package.get_url("packages.view"), package)
db.session.commit()
flash("Transferred " + ", ".join([x.name for x in packages]), "success")
return redirect(url_for("admin.transfer"))
@bp.route("/admin/transfer/", methods=["GET", "POST"])
@rank_required(UserRank.MODERATOR)
def transfer():
form = TransferPackageForm(formdata=request.form)
if form.validate_on_submit():
ret = perform_transfer(form)
if ret is not None:
return ret
# Process GET or invalid POST
return render_template("admin/transfer.html", form=form)

View File

@@ -15,9 +15,7 @@
# 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 render_template, request, abort from flask import render_template, request, abort
from flask_login import current_user, login_required from app.models import db, AuditLogEntry, UserRank, User
from app.models import db, AuditLogEntry, UserRank, User, Permission
from app.utils import rank_required, get_int_or_abort from app.utils import rank_required, get_int_or_abort
from . import bp from . import bp
@@ -37,15 +35,12 @@ def audit():
abort(404) abort(404)
query = query.filter_by(causer=user) query = query.filter_by(causer=user)
pagination = query.paginate(page=page, per_page=num) pagination = query.paginate(page, num, True)
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>/")
@login_required @rank_required(UserRank.MODERATOR)
def audit_view(id_): def audit_view(id):
entry: AuditLogEntry = AuditLogEntry.query.get_or_404(id_) entry = AuditLogEntry.query.get(id)
if not entry.check_perm(current_user, Permission.VIEW_AUDIT_DESCRIPTION):
abort(403)
return render_template("admin/audit_view.html", entry=entry) return render_template("admin/audit_view.html", entry=entry)

View File

@@ -14,17 +14,18 @@
# 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 TextAreaField, SubmitField, StringField from wtforms import *
from wtforms.validators import InputRequired, Length from wtforms.validators import *
from app.markdown import render_markdown from app.markdown import render_markdown
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk from app.models import *
from app.utils import rank_required, add_audit_log from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog
from . import bp from . import bp
from app.models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm): class SendEmailForm(FlaskForm):
@@ -49,12 +50,12 @@ def send_single_email():
form = SendEmailForm(request.form) form = SendEmailForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
add_audit_log(AuditSeverity.MODERATION, current_user, addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent email to {}".format(user.display_name), url_for("users.profile", username=username)) "Sent email to {}".format(user.display_name), url_for("users.profile", username=username))
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
task = send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html) task = send_user_email.delay(user.email, 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)
@@ -65,12 +66,13 @@ def send_single_email():
def send_bulk_email(): def send_bulk_email():
form = SendEmailForm(request.form) form = SendEmailForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
add_audit_log(AuditSeverity.MODERATION, current_user, addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data) "Sent bulk email", None, None, form.text.data)
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
task_send_bulk.delay(form.subject.data, text, html) for user in User.query.filter(User.email != None).all():
send_user_email.delay(user.email, form.subject.data, text, html)
return redirect(url_for("admin.admin_page")) return redirect(url_for("admin.admin_page"))

View File

@@ -15,15 +15,15 @@
# 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 redirect, render_template, abort, url_for, request, flash from flask import *
from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField, SubmitField, URLField from wtforms import *
from wtforms.validators import InputRequired, Length, Optional from wtforms.fields.html5 import URLField
from wtforms.validators import *
from app.utils import rank_required, nonempty_or_none, add_audit_log from app.models import *
from app.utils import rank_required, nonEmptyOrNone
from . import bp from . import bp
from app.models import UserRank, License, db, AuditSeverity
@bp.route("/licenses/") @bp.route("/licenses/")
@@ -31,13 +31,11 @@ from app.models import UserRank, License, db, AuditSeverity
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=[nonempty_or_none]) 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"])
@@ -57,15 +55,9 @@ def create_edit_license(name=None):
license = License(form.name.data) license = License(form.name.data)
db.session.add(license) db.session.add(license)
flash("Created license " + form.name.data, "success") flash("Created license " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created license {license.name}",
url_for("admin.license_list"))
else: else:
flash("Updated license " + form.name.data, "success") flash("Updated license " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited license {license.name}",
url_for("admin.license_list"))
form.populate_obj(license) form.populate_obj(license)
db.session.commit() db.session.commit()
return redirect(url_for("admin.license_list")) return redirect(url_for("admin.license_list"))

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 redirect, render_template, abort, url_for, request from flask import *
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 StringField, TextAreaField, BooleanField, SubmitField from wtforms import *
from wtforms.validators import InputRequired, Length, Optional, Regexp from wtforms.validators import *
from app.models import *
from . import bp from . import bp
from app.models import Permission, Tag, db, AuditSeverity
from app.utils import add_audit_log
@bp.route("/tags/") @bp.route("/tags/")
@@ -41,14 +40,12 @@ 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, name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
"Lower case letters (a-z), digits (0-9), and underscores (_) only")]) 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"])
@@ -60,24 +57,19 @@ def create_edit_tag(name=None):
if tag is None: if tag is None:
abort(404) abort(404)
if not Permission.check_perm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG): if not Permission.checkPerm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
abort(403) abort(403)
form = TagForm(obj=tag) form = TagForm( obj=tag)
if form.validate_on_submit(): if form.validate_on_submit():
if tag is None: if tag is None:
tag = Tag(form.title.data) tag = Tag(form.title.data)
tag.description = form.description.data tag.description = form.description.data
tag.is_protected = form.is_protected.data
db.session.add(tag) db.session.add(tag)
add_audit_log(AuditSeverity.EDITOR, current_user, f"Created tag {tag.name}",
url_for("admin.create_edit_tag", name=tag.name))
else: else:
form.populate_obj(tag) form.populate_obj(tag)
add_audit_log(AuditSeverity.EDITOR, current_user, f"Edited tag {tag.name}",
url_for("admin.create_edit_tag", name=tag.name))
db.session.commit() db.session.commit()
if Permission.EDIT_TAGS.check(current_user): if Permission.EDIT_TAGS.check(current_user):

View File

@@ -15,29 +15,25 @@
# 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 redirect, render_template, abort, url_for, request, flash from flask import *
from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SubmitField from wtforms import *
from wtforms.validators import InputRequired, Length from wtforms.validators import *
from app.utils import rank_required, add_audit_log from app.models import *
from app.utils import rank_required
from . import bp from . import bp
from app.models import UserRank, MinetestRelease, db, AuditSeverity
@bp.route("/versions/") @bp.route("/versions/")
@rank_required(UserRank.MODERATOR) @rank_required(UserRank.MODERATOR)
def version_list(): def version_list():
return render_template("admin/versions/list.html", return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
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"])
@@ -55,15 +51,9 @@ def create_edit_version(name=None):
version = MinetestRelease(form.name.data) version = MinetestRelease(form.name.data)
db.session.add(version) db.session.add(version)
flash("Created version " + form.name.data, "success") flash("Created version " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Created version {version.name}",
url_for("admin.license_list"))
else: else:
flash("Updated version " + form.name.data, "success") flash("Updated version " + form.name.data, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, f"Edited version {version.name}",
url_for("admin.version_list"))
form.populate_obj(version) form.populate_obj(version)
db.session.commit() db.session.commit()
return redirect(url_for("admin.version_list")) return redirect(url_for("admin.version_list"))

View File

@@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, render_template, abort, url_for, request from flask import *
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField from wtforms import *
from wtforms.validators import InputRequired, Length, Optional, Regexp from wtforms.validators import *
from app.models import *
from app.utils import rank_required from app.utils import rank_required
from . import bp from . import bp
from app.models import UserRank, ContentWarning, db
@bp.route("/admin/warnings/") @bp.route("/admin/warnings/")
@@ -30,14 +30,11 @@ from app.models import UserRank, ContentWarning, db
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), name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")]) submit = SubmitField("Save")
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,37 +13,31 @@
# #
# 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 functools import wraps
from typing import List from typing import List
import flask_sqlalchemy import flask_sqlalchemy
from flask import request, jsonify, current_app, Response 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 import and_, or_ 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
from app.logic.graphs import get_package_stats, get_package_stats_for_user, get_all_package_stats
from app.markdown import render_markdown from app.markdown import render_markdown
from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \ from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Permission, ForumTopic, \
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread, Collection, \ MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
PackageAlias
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, is_yes, get_request_date 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, \ from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package
api_order_screenshots, api_edit_package, api_set_cover_image from functools import wraps
from app.utils.minetest_hypertext import html_to_minetest
def cors_allowed(f): def cors_allowed(f):
@wraps(f) @wraps(f)
def inner(*args, **kwargs): def inner(*args, **kwargs):
res: Response = f(*args, **kwargs) res = f(*args, **kwargs)
res.headers["Access-Control-Allow-Origin"] = "*" res.headers["Access-Control-Allow-Origin"] = "*"
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
@@ -51,65 +45,26 @@ def cors_allowed(f):
return inner return inner
def cached(max_age: int):
def decorator(f):
@wraps(f)
def inner(*args, **kwargs):
res: Response = f(*args, **kwargs)
res.cache_control.max_age = max_age
return res
return inner
return decorator
@bp.route("/api/packages/") @bp.route("/api/packages/")
@cors_allowed @cors_allowed
@cached(300)
def packages(): def packages():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.build_package_query() query = qb.buildPackageQuery()
if request.args.get("fmt") == "keys": if request.args.get("fmt") == "keys":
return jsonify([pkg.as_key_dict() for pkg in query.all()]) return jsonify([package.getAsDictionaryKey() for package in query.all()])
pkgs = qb.convert_to_dictionary(query.all()) pkgs = qb.convertToDictionary(query.all())
if "engine_version" in request.args or "protocol_version" in request.args: if "engine_version" in request.args or "protocol_version" in request.args:
pkgs = [pkg for pkg in pkgs if pkg.get("release")] pkgs = [package for package in pkgs if package.get("release")]
# Promote featured packages
if "sort" not in request.args and \
"order" not in request.args and \
"q" not in request.args and \
"limit" not in request.args:
featured_lut = set()
featured = qb.convert_to_dictionary(query.filter(
Package.collections.any(and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))).all())
for pkg in featured:
featured_lut.add(f"{pkg['author']}/{pkg['name']}")
pkg["short_description"] = "Featured. " + pkg["short_description"]
not_featured = [pkg for pkg in pkgs if f"{pkg['author']}/{pkg['name']}" not in featured_lut]
pkgs = featured + not_featured
return jsonify(pkgs) return jsonify(pkgs)
@bp.route("/api/packages/<author>/<name>/") @bp.route("/api/packages/<author>/<name>/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def package_view(package): def package(package):
return jsonify(package.as_dict(current_app.config["BASE_URL"])) return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/hypertext/")
@is_package_page
@cors_allowed
def package_hypertext(package):
formspec_version = request.args["formspec_version"]
include_images = is_yes(request.args.get("include_images", "true"))
html = render_markdown(package.desc)
return jsonify(html_to_minetest(html, formspec_version, include_images))
@bp.route("/api/packages/<author>/<name>/", methods=["PUT"]) @bp.route("/api/packages/<author>/<name>/", methods=["PUT"])
@@ -125,12 +80,12 @@ def edit_package(token, package):
def resolve_package_deps(out, package, only_hard, depth=1): def resolve_package_deps(out, package, only_hard, depth=1):
id_ = package.get_id() id = package.getId()
if id_ in out: if id in out:
return return
ret = [] ret = []
out[id_] = ret out[id] = ret
if package.type != PackageType.MOD: if package.type != PackageType.MOD:
return return
@@ -141,16 +96,15 @@ def resolve_package_deps(out, package, only_hard, depth=1):
if dep.package: if dep.package:
name = dep.package.name name = dep.package.name
fulfilled_by = [ dep.package.get_id() ] fulfilled_by = [ dep.package.getId() ]
resolve_package_deps(out, dep.package, only_hard, depth) resolve_package_deps(out, dep.package, only_hard, depth)
elif dep.meta_package: elif dep.meta_package:
name = dep.meta_package.name name = dep.meta_package.name
fulfilled_by = [ pkg.get_id() for pkg in dep.meta_package.packages if pkg.state == PackageState.APPROVED] fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
if depth == 1 and not dep.optional: if depth == 1 and not dep.optional:
most_likely = next((pkg for pkg in dep.meta_package.packages \ most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), None)
if pkg.type == PackageType.MOD and pkg.state == PackageState.APPROVED), None)
if most_likely: if most_likely:
resolve_package_deps(out, most_likely, only_hard, depth + 1) resolve_package_deps(out, most_likely, only_hard, depth + 1)
@@ -179,9 +133,9 @@ def package_dependencies(package):
@bp.route("/api/topics/") @bp.route("/api/topics/")
@cors_allowed @cors_allowed
def topics(): def topics():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.build_topic_query(show_added=True) query = qb.buildTopicQuery(show_added=True)
return jsonify([t.as_dict() for t in query.all()]) return jsonify([t.getAsDictionary() for t in query.all()])
@bp.route("/api/topic_discard/", methods=["POST"]) @bp.route("/api/topic_discard/", methods=["POST"])
@@ -193,13 +147,13 @@ def topic_set_discard():
error(400, "Missing topic ID or discard bool") error(400, "Missing topic ID or discard bool")
topic = ForumTopic.query.get(tid) topic = ForumTopic.query.get(tid)
if not topic.check_perm(current_user, Permission.TOPIC_DISCARD): if not topic.checkPerm(current_user, Permission.TOPIC_DISCARD):
error(403, "Permission denied, need: TOPIC_DISCARD") error(403, "Permission denied, need: TOPIC_DISCARD")
topic.discarded = discard == "true" topic.discarded = discard == "true"
db.session.commit() db.session.commit()
return jsonify(topic.as_dict()) return jsonify(topic.getAsDictionary())
@bp.route("/api/whoami/") @bp.route("/api/whoami/")
@@ -212,20 +166,6 @@ def whoami(token):
return jsonify({ "is_authenticated": True, "username": token.owner.username }) return jsonify({ "is_authenticated": True, "username": token.owner.username })
@bp.route("/api/delete-token/", methods=["DELETE"])
@csrf.exempt
@is_api_authd
@cors_allowed
def api_delete_token(token):
if token is None:
error(404, "Token not found")
db.session.delete(token)
db.session.commit()
return jsonify({"success": True})
@bp.route("/api/markdown/", methods=["POST"]) @bp.route("/api/markdown/", methods=["POST"])
@csrf.exempt @csrf.exempt
def markdown(): def markdown():
@@ -250,16 +190,16 @@ def list_all_releases():
if maintainer is None: if maintainer is None:
error(404, "Maintainer not found") error(404, "Maintainer not found")
query = query.join(Package) query = query.join(Package)
query = query.filter(Package.maintainers.contains(maintainer)) query = query.filter(Package.maintainers.any(id=maintainer.id))
return jsonify([ rel.as_long_dict() for rel in query.limit(30).all() ]) return jsonify([ rel.getLongAsDictionary() for rel in query.limit(30).all() ])
@bp.route("/api/packages/<author>/<name>/releases/") @bp.route("/api/packages/<author>/<name>/releases/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def list_releases(package): def list_releases(package):
return jsonify([ rel.as_dict() for rel in package.releases.all() ]) return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
@bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"]) @bp.route("/api/packages/<author>/<name>/releases/new/", methods=["POST"])
@@ -271,14 +211,10 @@ def create_release(token, package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE): if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
error(403, "You do not have the permission to approve releases") error(403, "You do not have the permission to approve releases")
if request.headers.get("Content-Type") == "application/json": data = request.json or request.form
data = request.json
else:
data = request.form
if "title" not in data: if "title" not in data:
error(400, "Title is required in the POST data") error(400, "Title is required in the POST data")
@@ -305,12 +241,12 @@ def create_release(token, package):
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/") @bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def release_view(package: Package, id: int): def release(package: Package, id: int):
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:
error(404, "Release not found") error(404, "Release not found")
return jsonify(release.as_dict()) return jsonify(release.getAsDictionary())
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"]) @bp.route("/api/packages/<author>/<name>/releases/<int:id>/", methods=["DELETE"])
@@ -326,10 +262,10 @@ def delete_release(token: APIToken, package: Package, id: int):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not token.can_operate_on_package(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")
if not release.check_perm(token.owner, Permission.DELETE_RELEASE): if not release.checkPerm(token.owner, Permission.DELETE_RELEASE):
error(403, "Unable to delete the release, make sure there's a newer release available") error(403, "Unable to delete the release, make sure there's a newer release available")
db.session.delete(release) db.session.delete(release)
@@ -343,7 +279,7 @@ def delete_release(token: APIToken, package: Package, id: int):
@cors_allowed @cors_allowed
def list_screenshots(package): def list_screenshots(package):
screenshots = package.screenshots.all() screenshots = package.screenshots.all()
return jsonify([ss.as_dict(current_app.config["BASE_URL"]) for ss in screenshots]) return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
@bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"]) @bp.route("/api/packages/<author>/<name>/screenshots/new/", methods=["POST"])
@@ -355,7 +291,7 @@ def create_screenshot(token: APIToken, package: Package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS): if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to create screenshots") error(403, "You do not have the permission to create screenshots")
data = request.form data = request.form
@@ -366,7 +302,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, is_yes(data.get("is_cover_image"))) return api_create_screenshot(token, package, data["title"], file)
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/") @bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@@ -377,7 +313,7 @@ def screenshot(package, id):
if ss is None or ss.package != package: if ss is None or ss.package != package:
error(404, "Screenshot not found") error(404, "Screenshot not found")
return jsonify(ss.as_dict(current_app.config["BASE_URL"])) return jsonify(ss.getAsDictionary(current_app.config["BASE_URL"]))
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"]) @bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/", methods=["DELETE"])
@@ -393,10 +329,10 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not package.check_perm(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 delete screenshots")
if not token.can_operate_on_package(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")
if package.cover_image == ss: if package.cover_image == ss:
@@ -418,10 +354,10 @@ def order_screenshots(token: APIToken, package: Package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS): if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots") error(403, "You do not have the permission to delete screenshots")
if not token.can_operate_on_package(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")
json = request.json json = request.json
@@ -431,64 +367,34 @@ 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.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots")
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
json = request.json
if json is None or not isinstance(json, dict) or "cover_image" not in json:
error(400, "Expected body to be an object with cover_image as a key")
return api_set_cover_image(token, package, request.json["cover_image"])
@bp.route("/api/packages/<author>/<name>/reviews/") @bp.route("/api/packages/<author>/<name>/reviews/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def list_reviews(package): def list_reviews(package):
reviews = package.reviews reviews = package.reviews
return jsonify([review.as_dict() for review in reviews]) return jsonify([review.getAsDictionary() for review in reviews])
@bp.route("/api/reviews/") @bp.route("/api/reviews/")
@cors_allowed @cors_allowed
def list_all_reviews(): def list_all_reviews():
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 200) num = min(get_int_or_abort(request.args.get("n"), 100), 100)
query = PackageReview.query query = PackageReview.query
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package)) query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
if "for_user" in request.args: if request.args.get("author"):
query = query.filter(PackageReview.package.has(Package.author.has(username=request.args["for_user"])))
if "author" in request.args:
query = query.filter(PackageReview.author.has(User.username == request.args.get("author"))) query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
if "is_positive" in request.args: if request.args.get("is_positive"):
if is_yes(request.args.get("is_positive")): query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
query = query.filter(PackageReview.rating > 3)
else:
query = query.filter(PackageReview.rating <= 3)
q = request.args.get("q") q = request.args.get("q")
if q: if q:
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%"))) query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
query = query.order_by(db.desc(PackageReview.created_at)) pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num)
return jsonify({ return jsonify({
"page": pagination.page, "page": pagination.page,
"per_page": pagination.per_page, "per_page": pagination.per_page,
@@ -498,67 +404,48 @@ def list_all_reviews():
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None, "previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None, "next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
}, },
"items": [review.as_dict(True) for review in pagination.items], "items": [review.getAsDictionary(True) for review in pagination.items],
}) })
@bp.route("/api/packages/<author>/<name>/stats/")
@is_package_page
@cors_allowed
@cached(300)
def package_stats(package: Package):
start = get_request_date("start")
end = get_request_date("end")
return jsonify(get_package_stats(package, start, end))
@bp.route("/api/package_stats/")
@cors_allowed
@cached(900)
def all_package_stats():
return jsonify(get_all_package_stats())
@bp.route("/api/scores/") @bp.route("/api/scores/")
@cors_allowed @cors_allowed
@cached(300)
def package_scores(): def package_scores():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.build_package_query() query = qb.buildPackageQuery()
pkgs = [package.as_score_dict() for package in query.all()] pkgs = [package.getScoreDict() for package in query.all()]
return jsonify(pkgs) return jsonify(pkgs)
@bp.route("/api/tags/") @bp.route("/api/tags/")
@cors_allowed @cors_allowed
def tags(): def tags():
return jsonify([tag.as_dict() for tag in Tag.query.all() ]) return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ])
@bp.route("/api/content_warnings/") @bp.route("/api/content_warnings/")
@cors_allowed @cors_allowed
def content_warnings(): def content_warnings():
return jsonify([warning.as_dict() for warning in ContentWarning.query.all() ]) return jsonify([warning.getAsDictionary() for warning in ContentWarning.query.all() ])
@bp.route("/api/licenses/") @bp.route("/api/licenses/")
@cors_allowed @cors_allowed
def licenses(): def licenses():
all_licenses = License.query.order_by(db.asc(License.name)).all() return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses]) for license in License.query.order_by(db.asc(License.name)).all() ])
@bp.route("/api/homepage/") @bp.route("/api/homepage/")
@cors_allowed @cors_allowed
def homepage(): def homepage():
query = Package.query.filter_by(state=PackageState.APPROVED) query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count() count = query.count()
spotlight = query.filter( featured = query.filter(Package.tags.any(name="featured")).order_by(
Package.collections.any(and_(Collection.name == "spotlight", Collection.author.has(username="ContentDB")))) \ func.random()).limit(6).all()
.order_by(func.random()).limit(6).all() new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all() pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all() pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all() pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
@@ -574,37 +461,19 @@ def homepage():
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none() downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0] downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
def map_packages(packages: List[Package]): def mapPackages(packages: List[Package]):
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages] return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({ return jsonify({
"count": count, "count": count,
"downloads": downloads, "downloads": downloads,
"spotlight": map_packages(spotlight), "featured": mapPackages(featured),
"new": map_packages(new), "new": mapPackages(new),
"updated": map_packages(updated), "updated": mapPackages(updated),
"pop_mod": map_packages(pop_mod), "pop_mod": mapPackages(pop_mod),
"pop_txp": map_packages(pop_txp), "pop_txp": mapPackages(pop_txp),
"pop_game": map_packages(pop_gam), "pop_game": mapPackages(pop_gam),
"high_reviewed": map_packages(high_reviewed) "high_reviewed": mapPackages(high_reviewed)
})
@bp.route("/api/welcome/v1/")
@cors_allowed
def welcome_v1():
featured = Package.query \
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
Package.collections.any(
and_(Collection.name == "featured", Collection.author.has(username="ContentDB")))) \
.order_by(func.random()) \
.limit(5).all()
def map_packages(packages: List[Package]):
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({
"featured": map_packages(featured),
}) })
@@ -618,21 +487,21 @@ def versions():
if rel is None: if rel is None:
error(404, "No releases found") error(404, "No releases found")
return jsonify(rel.as_dict()) return jsonify(rel.getAsDictionary())
return jsonify([rel.as_dict() \ return jsonify([rel.getAsDictionary() \
for rel in MinetestRelease.query.all() if rel.get_actual() is not None]) for rel in MinetestRelease.query.all() if rel.getActual() is not None])
@bp.route("/api/dependencies/") @bp.route("/api/dependencies/")
@cors_allowed @cors_allowed
def all_deps(): def all_deps():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.build_package_query() query = qb.buildPackageQuery()
def format_pkg(pkg: Package): def format_pkg(pkg: Package):
return { return {
"type": pkg.type.to_name(), "type": pkg.type.toName(),
"author": pkg.author.username, "author": pkg.author.username,
"name": pkg.name, "name": pkg.name,
"provides": [x.name for x in pkg.provides], "provides": [x.name for x in pkg.provides],
@@ -642,7 +511,7 @@ def all_deps():
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 300) num = min(get_int_or_abort(request.args.get("n"), 100), 300)
pagination: flask_sqlalchemy.Pagination = query.paginate(page=page, per_page=num) pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
return jsonify({ return jsonify({
"page": pagination.page, "page": pagination.page,
"per_page": pagination.per_page, "per_page": pagination.per_page,
@@ -654,247 +523,3 @@ def all_deps():
}, },
"items": [format_pkg(pkg) for pkg in pagination.items], "items": [format_pkg(pkg) for pkg in pagination.items],
}) })
@bp.route("/api/users/<username>/")
@cors_allowed
def user_view(username: str):
user = User.query.filter_by(username=username).first()
if user is None:
error(404, "User not found")
return jsonify(user.get_dict())
@bp.route("/api/users/<username>/stats/")
@cors_allowed
def user_stats(username: str):
user = User.query.filter_by(username=username).first()
if user is None:
error(404, "User not found")
start = get_request_date("start")
end = get_request_date("end")
return jsonify(get_package_stats_for_user(user, start, end))
@bp.route("/api/cdb_schema/")
@cors_allowed
def json_schema():
tags = Tag.query.all()
warnings = ContentWarning.query.all()
licenses = License.query.order_by(db.asc(License.name)).all()
return jsonify({
"title": "CDB Config",
"description": "Package Configuration",
"type": "object",
"$defs": {
"license": {
"enum": [license.name for license in licenses],
"enumDescriptions": [license.is_foss and "FOSS" or "NON-FOSS" for license in licenses]
},
},
"properties": {
"type": {
"description": "Package Type",
"enum": ["MOD", "GAME", "TXP"],
"enumDescriptions": ["Mod", "Game", "Texture Pack"]
},
"title": {
"description": "Human-readable title",
"type": "string"
},
"name": {
"description": "Technical name (needs permission if already approved).",
"type": "string",
"pattern": "^[a-z_]+$"
},
"short_description": {
"description": "Package Short Description",
"type": ["string", "null"]
},
"dev_state": {
"description": "Development State",
"enum": [
"WIP",
"BETA",
"ACTIVELY_DEVELOPED",
"MAINTENANCE_ONLY",
"AS_IS",
"DEPRECATED",
"LOOKING_FOR_MAINTAINER"
]
},
"tags": {
"description": "Package Tags",
"type": "array",
"items": {
"enum": [tag.name for tag in tags],
"enumDescriptions": [tag.title for tag in tags]
},
"uniqueItems": True,
},
"content_warnings": {
"description": "Package Content Warnings",
"type": "array",
"items": {
"enum": [warning.name for warning in warnings],
"enumDescriptions": [warning.title for warning in warnings]
},
"uniqueItems": True,
},
"license": {
"description": "Package License",
"$ref": "#/$defs/license"
},
"media_license": {
"description": "Package Media License",
"$ref": "#/$defs/license"
},
"long_description": {
"description": "Package Long Description",
"type": ["string", "null"]
},
"repo": {
"description": "Git Repository URL",
"type": "string",
"format": "uri"
},
"website": {
"description": "Website URL",
"type": ["string", "null"],
"format": "uri"
},
"issue_tracker": {
"description": "Issue Tracker URL",
"type": ["string", "null"],
"format": "uri"
},
"forums": {
"description": "Forum Topic ID",
"type": ["integer", "null"],
"minimum": 0
},
"video_url": {
"description": "URL to a Video",
"type": ["string", "null"],
"format": "uri"
},
"donate_url": {
"description": "URL to a donation page",
"type": ["string", "null"],
"format": "uri"
},
},
})
@bp.route("/api/hypertext/", methods=["POST"])
@csrf.exempt
@cors_allowed
def hypertext():
formspec_version = request.args["formspec_version"]
include_images = is_yes(request.args.get("include_images", "true"))
html = request.data.decode("utf-8")
if request.content_type == "text/markdown":
html = render_markdown(html)
return jsonify(html_to_minetest(html, formspec_version, include_images))
@bp.route("/api/collections/")
@cors_allowed
def collection_list():
if "author" in request.args:
user = User.query.filter_by(username=request.args["author"]).one_or_404()
query = user.collections
else:
query = Collection.query.order_by(db.asc(Collection.title))
if "package" in request.args:
id_ = request.args["package"]
package = Package.get_by_key(id_)
if package is None:
error(404, f"Package {id_} not found")
query = query.filter(Collection.packages.contains(package))
collections = [x.as_short_dict() for x in query.all() if not x.private]
return jsonify(collections)
@bp.route("/api/collections/<author>/<name>/")
@cors_allowed
def collection_view(author, name):
collection = Collection.query \
.filter(Collection.name == name, Collection.author.has(username=author)) \
.one_or_404()
if not collection.check_perm(current_user, Permission.VIEW_COLLECTION):
error(404, "Collection not found")
items = collection.items
if collection.check_perm(current_user, Permission.EDIT_COLLECTION):
items = [x for x in items if x.package.check_perm(current_user, Permission.VIEW_PACKAGE)]
ret = collection.as_dict()
ret["items"] = [x.as_dict() for x in items]
return jsonify(ret)
@bp.route("/api/updates/")
def updates():
protocol_version = get_int_or_abort(request.args.get("protocol_version"))
minetest_version = request.args.get("engine_version")
if protocol_version or minetest_version:
version = MinetestRelease.get(minetest_version, protocol_version)
else:
version = None
# Subquery to get the latest release for each package
latest_release_query = (db.session.query(
PackageRelease.package_id,
func.max(PackageRelease.id).label('max_release_id'))
.select_from(PackageRelease)
.filter(PackageRelease.approved == True))
if version:
latest_release_query = (latest_release_query
.filter(or_(PackageRelease.min_rel_id == None,
PackageRelease.min_rel_id <= version.id))
.filter(or_(PackageRelease.max_rel_id == None,
PackageRelease.max_rel_id >= version.id)))
latest_release_subquery = (
latest_release_query
.group_by(PackageRelease.package_id)
.subquery()
)
# Get package id and latest release
query = (db.session.query(User.username, Package.name, latest_release_subquery.c.max_release_id)
.select_from(Package)
.join(User, Package.author)
.join(latest_release_subquery, Package.id == latest_release_subquery.c.package_id)
.filter(Package.state == PackageState.APPROVED)
.all())
ret = {}
for author_username, package_name, release_id in query:
ret[f"{author_username}/{package_name}"] = release_id
# Get aliases
aliases = (db.session.query(PackageAlias.author, PackageAlias.name, User.username, Package.name)
.select_from(PackageAlias)
.join(Package, PackageAlias.package)
.join(User, Package.author)
.filter(Package.state == PackageState.APPROVED)
.all())
for old_author, old_name, new_author, new_name in aliases:
new_release = ret.get(f"{new_author}/{new_name}")
if new_release is not None:
ret[f"{old_author}/{old_name}"] = new_release
return jsonify(ret)

View File

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

View File

@@ -19,12 +19,12 @@ from flask import render_template, redirect, request, session, url_for, abort
from flask_babel import lazy_gettext 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 StringField, SubmitField from wtforms import *
from wtforms.validators import InputRequired, Length from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms.validators import *
from app.models import db, User, APIToken, Permission from app.models import db, User, APIToken, Package, Permission
from app.utils import random_string from app.utils import randomString
from . import bp from . import bp
from ..users.settings import get_setting_tabs from ..users.settings import get_setting_tabs
@@ -49,7 +49,7 @@ def list_tokens(username):
if user is None: if user is None:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CREATE_TOKEN): if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403) abort(403)
return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens") return render_template("api/list_tokens.html", user=user, tabs=get_setting_tabs(user), current_tab="api_tokens")
@@ -63,7 +63,7 @@ def create_edit_token(username, id=None):
if user is None: if user is None:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CREATE_TOKEN): if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403) abort(403)
is_new = id is None is_new = id is None
@@ -72,8 +72,10 @@ def create_edit_token(username, id=None):
access_token = None access_token = None
if not is_new: if not is_new:
token = APIToken.query.get(id) token = APIToken.query.get(id)
if token is None or token.owner != user: if token is None:
abort(404) abort(404)
elif token.owner != user:
abort(403)
access_token = session.pop("token_" + str(token.id), None) access_token = session.pop("token_" + str(token.id), None)
@@ -83,12 +85,12 @@ def create_edit_token(username, id=None):
if form.validate_on_submit(): if form.validate_on_submit():
if is_new: if is_new:
token = APIToken() token = APIToken()
db.session.add(token)
token.owner = user token.owner = user
token.access_token = random_string(32) token.access_token = randomString(32)
form.populate_obj(token) form.populate_obj(token)
db.session.commit() db.session.add(token)
db.session.commit() # save
if is_new: if is_new:
# Store token so it can be shown in the edit page # Store token so it can be shown in the edit page
@@ -106,7 +108,7 @@ def reset_token(username, id):
if user is None: if user is None:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CREATE_TOKEN): if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403) abort(403)
token = APIToken.query.get(id) token = APIToken.query.get(id)
@@ -115,7 +117,7 @@ def reset_token(username, id):
elif token.owner != user: elif token.owner != user:
abort(403) abort(403)
token.access_token = random_string(32) token.access_token = randomString(32)
db.session.commit() # save db.session.commit() # save
@@ -132,9 +134,11 @@ def delete_token(username, id):
if user is None: if user is None:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CREATE_TOKEN): if not user.checkPerm(current_user, Permission.CREATE_TOKEN):
abort(403) abort(403)
is_new = id is None
token = APIToken.query.get(id) token = APIToken.query.get(id)
if token is None: if token is None:
abort(404) abort(404)

View File

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

View File

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

View File

@@ -14,7 +14,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/>.
from flask import Blueprint, abort from flask import Blueprint
from flask_babel import gettext from flask_babel import gettext
bp = Blueprint("github", __name__) bp = Blueprint("github", __name__)
@@ -23,20 +23,14 @@ from flask import redirect, url_for, request, flash, jsonify, current_app
from flask_login import current_user from flask_login import current_user
from sqlalchemy import func, or_, and_ from sqlalchemy import func, or_, and_
from app import github, csrf from app import github, csrf
from app.models import db, User, APIToken, Package, Permission, AuditSeverity, PackageState from app.models import db, User, APIToken, Package, Permission, AuditSeverity
from app.utils import abs_url_for, add_audit_log, login_user_set_active, is_safe_url from app.utils import abs_url_for, addAuditLog, login_user_set_active
from app.blueprints.api.support import error, api_create_vcs_release from app.blueprints.api.support import error, api_create_vcs_release
import hmac, requests import hmac, requests
@bp.route("/github/start/") @bp.route("/github/start/")
def start(): def start():
next = request.args.get("next") return github.authorize("", redirect_uri=abs_url_for("github.callback"))
if next and not is_safe_url(next):
abort(400)
return github.authorize("", redirect_uri=abs_url_for("github.callback", next=next))
@bp.route("/github/view/") @bp.route("/github/view/")
def view_permissions(): def view_permissions():
@@ -44,28 +38,20 @@ def view_permissions():
current_app.config["GITHUB_CLIENT_ID"] current_app.config["GITHUB_CLIENT_ID"]
return redirect(url) return redirect(url)
@bp.route("/github/callback/") @bp.route("/github/callback/")
@github.authorized_handler @github.authorized_handler
def callback(oauth_token): def callback(oauth_token):
next_url = request.args.get("next")
if oauth_token is None: if oauth_token is None:
flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger") flash(gettext("Authorization failed [err=gh-oauth-login-failed]"), "danger")
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
next = request.args.get("next") # Get Github username
if next and not is_safe_url(next):
abort(400)
redirect_to = next
if redirect_to is None:
redirect_to = url_for("homepage.home")
# Get GitGub username
url = "https://api.github.com/user" url = "https://api.github.com/user"
r = requests.get(url, headers={"Authorization": "token " + oauth_token}) r = requests.get(url, headers={"Authorization": "token " + oauth_token})
username = r.json()["login"] username = r.json()["login"]
# Get user by GitHub username # Get user by github username
userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first() userByGithub = User.query.filter(func.lower(User.github_username) == func.lower(username)).first()
# If logged in, connect # If logged in, connect
@@ -74,10 +60,10 @@ def callback(oauth_token):
current_user.github_username = username current_user.github_username = username
db.session.commit() db.session.commit()
flash(gettext("Linked GitHub to account"), "success") flash(gettext("Linked GitHub to account"), "success")
return redirect(redirect_to) return redirect(url_for("homepage.home"))
else: else:
flash(gettext("GitHub account is already associated with another user"), "danger") flash(gettext("GitHub account is already associated with another user"), "danger")
return redirect(redirect_to) return redirect(url_for("homepage.home"))
# If not logged in, log in # If not logged in, log in
else: else:
@@ -85,12 +71,12 @@ def callback(oauth_token):
flash(gettext("Unable to find an account for that GitHub user"), "danger") flash(gettext("Unable to find an account for that GitHub user"), "danger")
return redirect(url_for("users.claim_forums")) return redirect(url_for("users.claim_forums"))
ret = login_user_set_active(userByGithub, next, remember=True) ret = login_user_set_active(userByGithub, remember=True)
if ret is None: if ret is None:
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger") flash(gettext("Authorization failed [err=gh-login-failed]"), "danger")
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
add_audit_log(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth", addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username)) url_for("users.profile", username=userByGithub.username))
db.session.commit() db.session.commit()
return ret return ret
@@ -103,8 +89,7 @@ def webhook():
# Get package # Get package
github_url = "github.com/" + json["repository"]["full_name"] github_url = "github.com/" + json["repository"]["full_name"]
package = Package.query.filter( package = Package.query.filter(Package.repo.ilike("%{}%".format(github_url))).first()
Package.repo.ilike("%{}%".format(github_url)), Package.state != PackageState.DELETED).first()
if package is None: if package is None:
return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url)) return error(400, "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(github_url))
@@ -137,7 +122,7 @@ def webhook():
if actual_token is None: if actual_token is None:
return error(403, "Invalid authentication, couldn't validate API token") return error(403, "Invalid authentication, couldn't validate API token")
if not package.check_perm(actual_token.owner, Permission.APPROVE_RELEASE): if not package.checkPerm(actual_token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases") return error(403, "You do not have the permission to approve releases")
# #

View File

@@ -19,7 +19,7 @@ from flask import Blueprint, request, jsonify
bp = Blueprint("gitlab", __name__) bp = Blueprint("gitlab", __name__)
from app import csrf from app import csrf
from app.models import Package, APIToken, Permission, PackageState from app.models import Package, APIToken, Permission
from app.blueprints.api.support import error, api_create_vcs_release from app.blueprints.api.support import error, api_create_vcs_release
@@ -28,8 +28,7 @@ def webhook_impl():
# Get package # Get package
gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "") gitlab_url = json["project"]["web_url"].replace("https://", "").replace("http://", "")
package = Package.query.filter( package = Package.query.filter(Package.repo.ilike("%{}%".format(gitlab_url))).first()
Package.repo.ilike("%{}%".format(gitlab_url)), Package.state != PackageState.DELETED).first()
if package is None: if package is None:
return error(400, return error(400,
"Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url)) "Could not find package, did you set the VCS repo in CDB correctly? Expected {}".format(gitlab_url))
@@ -43,7 +42,7 @@ def webhook_impl():
if token is None: if token is None:
return error(403, "Invalid authentication") return error(403, "Invalid authentication")
if not package.check_perm(token.owner, Permission.APPROVE_RELEASE): if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
return error(403, "You do not have the permission to approve releases") return error(403, "You do not have the permission to approve releases")
# #
@@ -65,7 +64,7 @@ def webhook_impl():
title = ref.replace("refs/tags/", "") title = ref.replace("refs/tags/", "")
else: else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported." return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null")) .format(event or "null"))
# #
# Perform release # Perform release

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,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/>.
from flask import Blueprint, render_template, redirect, url_for from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import or_, desc from sqlalchemy import or_, desc

View File

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

View File

@@ -17,66 +17,52 @@
from flask import Blueprint from flask import Blueprint
from flask_babel import gettext from flask_babel import gettext
from app.models import User, Package, Permission, PackageType from app.models import User, Package, Permission
bp = Blueprint("packages", __name__) bp = Blueprint("packages", __name__)
def get_package_tabs(user: User, package: Package): def get_package_tabs(user: User, package: Package):
if package is None or not package.check_perm(user, Permission.EDIT_PACKAGE): if package is None or not package.checkPerm(user, Permission.EDIT_PACKAGE):
return [] return []
retval = [ return [
{ {
"id": "edit", "id": "edit",
"title": gettext("Edit Details"), "title": gettext("Edit Details"),
"url": package.get_url("packages.create_edit") "url": package.getURL("packages.create_edit")
}, },
{ {
"id": "releases", "id": "releases",
"title": gettext("Releases"), "title": gettext("Releases"),
"url": package.get_url("packages.list_releases") "url": package.getURL("packages.list_releases")
}, },
{ {
"id": "screenshots", "id": "screenshots",
"title": gettext("Screenshots"), "title": gettext("Screenshots"),
"url": package.get_url("packages.screenshots") "url": package.getURL("packages.screenshots")
}, },
{ {
"id": "maintainers", "id": "maintainers",
"title": gettext("Maintainers"), "title": gettext("Maintainers"),
"url": package.get_url("packages.edit_maintainers") "url": package.getURL("packages.edit_maintainers")
}, },
{ {
"id": "audit", "id": "audit",
"title": gettext("Audit Log"), "title": gettext("Audit Log"),
"url": package.get_url("packages.audit") "url": package.getURL("packages.audit")
},
{
"id": "stats",
"title": gettext("Statistics"),
"url": package.get_url("packages.statistics")
}, },
{ {
"id": "share", "id": "share",
"title": gettext("Share and Badges"), "title": gettext("Share and Badges"),
"url": package.get_url("packages.share") "url": package.getURL("packages.share")
}, },
{ {
"id": "remove", "id": "remove",
"title": gettext("Remove / Unpublish"), "title": gettext("Remove"),
"url": package.get_url("packages.remove") "url": package.getURL("packages.remove")
} }
] ]
if package.type == PackageType.MOD or package.type == PackageType.TXP:
retval.insert(1, {
"id": "game_support",
"title": gettext("Supported Games"),
"url": package.get_url("packages.game_support")
})
return retval from . import packages, screenshots, releases, reviews
from . import packages, screenshots, releases, reviews, game_hub

View File

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

View File

@@ -14,43 +14,34 @@
# 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 datetime
import typing
from urllib.parse import quote as urlescape from urllib.parse import quote as urlescape
from celery import uuid from flask import render_template
from flask import render_template, make_response, request, redirect, flash, url_for, abort from flask_babel import lazy_gettext, gettext
from flask_babel import gettext, lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from jinja2.utils import markupsafe from flask_login import login_required
from sqlalchemy import func, or_, and_ from sqlalchemy import or_, func
from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.orm import joinedload, subqueryload
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, BooleanField from wtforms import *
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, URL, NumberRange, ValidationError from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from wtforms.validators import *
from app.logic.LogicError import LogicError
from app.logic.packages import do_edit_package
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_key from app.rediscache import has_key, set_key
from app.tasks.importtasks import import_repo_screenshot, check_zip_release from app.tasks.importtasks import importRepoScreenshot
from app.tasks.webhooktasks import post_discord_webhook from app.utils import *
from app.logic.game_support import GameSupportResolver
from . import bp, get_package_tabs from . import bp, get_package_tabs
from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \ from app.logic.LogicError import LogicError
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \ from app.logic.packages import do_edit_package
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \ from app.models.packages import PackageProvides
PackageDailyStats, Collection from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \
add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
@bp.route("/packages/") @bp.route("/packages/")
def list_all(): def list_all():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.build_package_query() query = qb.buildPackageQuery()
title = qb.title title = qb.title
query = query.options( query = query.options(
@@ -76,15 +67,15 @@ def list_all():
if qb.lucky: if qb.lucky:
package = query.first() package = query.first()
if package: if package:
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
topic = qb.build_topic_query().first() topic = qb.buildTopicQuery().first()
if qb.search and topic: if qb.search and topic:
return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id)) return redirect("https://forum.minetest.net/viewtopic.php?t=" + str(topic.topic_id))
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100)) num = min(40, get_int_or_abort(request.args.get("n"), 100))
query = query.paginate(page=page, per_page=num) query = query.paginate(page, num, True)
search = request.args.get("q") search = request.args.get("q")
type_name = request.args.get("type") type_name = request.args.get("type")
@@ -100,48 +91,42 @@ def list_all():
topics = None topics = None
if qb.search and not query.has_next: if qb.search and not query.has_next:
qb.show_discarded = True qb.show_discarded = True
topics = qb.build_topic_query().all() topics = qb.buildTopicQuery().all()
tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \ tags_query = db.session.query(func.count(Tags.c.tag_id), Tag) \
.select_from(Tag).join(Tags).join(Package).filter(Package.state==PackageState.APPROVED) \ .select_from(Tag).join(Tags).join(Package).group_by(Tag.id).order_by(db.asc(Tag.title))
.group_by(Tag.id).order_by(db.asc(Tag.title)) tags = qb.filterPackageQuery(tags_query).all()
tags = qb.filter_package_query(tags_query).all()
selected_tags = set(qb.tags) selected_tags = set(qb.tags)
return render_template("packages/list.html", return render_template("packages/list.html",
query_hint=title, packages=query.items, pagination=query, query_hint=title, packages=query.items, pagination=query,
query=search, tags=tags, selected_tags=selected_tags, type=type_name, query=search, tags=tags, selected_tags=selected_tags, type=type_name,
authors=authors, packages_count=query.total, topics=topics, noindex=qb.noindex) authors=authors, packages_count=query.total, topics=topics)
def get_releases(package): def getReleases(package):
if package.check_perm(current_user, Permission.MAKE_RELEASE): if package.checkPerm(current_user, Permission.MAKE_RELEASE):
return package.releases.limit(5) return package.releases.limit(5)
else: else:
return package.releases.filter_by(approved=True).limit(5) return package.releases.filter_by(approved=True).limit(5)
@bp.route("/packages/<author>/")
def user_redirect(author):
return redirect(url_for("users.profile", username=author))
@bp.route("/packages/<author>/<name>/") @bp.route("/packages/<author>/<name>/")
@is_package_page @is_package_page
def view(package): def view(package):
if not package.check_perm(current_user, Permission.VIEW_PACKAGE): if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
return render_template("packages/gone.html", package=package), 403 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.check_perm(current_user, Permission.APPROVE_NEW)) package.checkPerm(current_user, Permission.APPROVE_NEW))
conflicting_modnames = None conflicting_modnames = None
if show_similar and package.type != PackageType.TXP: if show_similar and package.type != PackageType.TXP:
conflicting_modnames = db.session.query(MetaPackage.name) \ conflicting_modnames = db.session.query(MetaPackage.name) \
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \ .filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
.filter(MetaPackage.packages.any(and_(Package.id != package.id, Package.state == PackageState.APPROVED))) \ .filter(MetaPackage.packages.any(Package.id != package.id)) \
.all() .all()
conflicting_modnames += db.session.query(ForumTopic.name) \ conflicting_modnames += db.session.query(ForumTopic.name) \
@@ -163,10 +148,10 @@ def view(package):
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \ Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
.order_by(db.desc(Package.score)).limit(6).all() .order_by(db.desc(Package.score)).limit(6).all()
releases = get_releases(package) releases = getReleases(package)
review_thread = package.review_thread review_thread = package.review_thread
if review_thread is not None and not review_thread.check_perm(current_user, Permission.SEE_THREAD): if review_thread is not None and not review_thread.checkPerm(current_user, Permission.SEE_THREAD):
review_thread = None review_thread = None
topic_error = None topic_error = None
@@ -187,26 +172,20 @@ def view(package):
topic_error = "<br />".join(errors) topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id, review_id=None) threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated: if not current_user.is_authenticated:
threads = threads.filter_by(private=False) threads = threads.filter_by(private=False)
elif not current_user.rank.at_least(UserRank.APPROVER) and not current_user == package.author: elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author:
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user)) threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
has_review = current_user.is_authenticated and \ has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
is_favorited = current_user.is_authenticated and \
Collection.query.filter(
Collection.author == current_user,
Collection.packages.contains(package),
Collection.name == "favorites").count() > 0
return render_template("packages/view.html", return render_template("packages/view.html",
package=package, releases=releases, packages_uses=packages_uses, package=package, releases=releases, packages_uses=packages_uses,
conflicting_modnames=conflicting_modnames, conflicting_modnames=conflicting_modnames,
review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl, review_thread=review_thread, topic_error=topic_error, topic_error_lvl=topic_error_lvl,
threads=threads.all(), has_review=has_review, is_favorited=is_favorited) threads=threads.all(), has_review=has_review)
@bp.route("/packages/<author>/<name>/shields/<type>/") @bp.route("/packages/<author>/<name>/shields/<type>/")
@@ -216,11 +195,11 @@ def shield(package, type):
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \ url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
.format(urlescape(package.title), urlescape("#375a7f")) .format(urlescape(package.title), urlescape("#375a7f"))
elif type == "downloads": elif type == "downloads":
api_url = abs_url_for("api.package_view", author=package.author.username, name=package.name) #api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
api_url = "https://content.minetest.net" + url_for("api.package", author=package.author.username, name=package.name)
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \ url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
.format(urlescape("#375a7f"), urlescape(api_url)) .format(urlescape("#375a7f"), urlescape(api_url))
else: else:
from flask import abort
abort(404) abort(404)
return redirect(url) return redirect(url)
@@ -229,17 +208,20 @@ 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):
release = package.get_download_release() if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
release = package.getDownloadRelease()
if release is None: if release is None:
if "application/zip" in request.accept_mimetypes and \ if "application/zip" in request.accept_mimetypes and \
"text/html" not in request.accept_mimetypes: not "text/html" in request.accept_mimetypes:
return "", 204 return "", 204
else: else:
flash(gettext("No download available."), "danger") flash(gettext("No download available."), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
else: else:
return redirect(release.get_download_url()) return redirect(release.getDownloadURL())
def makeLabel(obj): def makeLabel(obj):
@@ -267,81 +249,10 @@ class PackageForm(FlaskForm):
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None]) repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None]) 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])
donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
def validate_name(self, field):
if field.data == "_game":
raise ValidationError(lazy_gettext("_game is not an allowed name"))
def handle_create_edit(package: typing.Optional[Package], form: PackageForm, author: User):
wasNew = False
if package is None:
package = Package.query.filter_by(name=form.name.data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.DELETED:
flash(
gettext("Package already exists, but is removed. Please contact ContentDB staff to restore the package"),
"danger")
else:
flash(markupsafe.Markup(
f"<a class='btn btn-sm btn-danger float-end' href='{package.get_url('packages.view')}'>View</a>" +
gettext("Package already exists")), "danger")
return None
if Collection.query \
.filter(Collection.name == form.name.data, Collection.author == author) \
.count() > 0:
flash(gettext("A collection with a similar name already exists"), "danger")
return
package = Package()
db.session.add(package)
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
"video_url": form.video_url.data,
"donate_url": form.donate_url.data,
})
if wasNew:
msg = f"Created package {author.username}/{form.name.data}"
add_audit_log(AuditSeverity.NORMAL, current_user, msg, package.get_url("packages.view"), package)
if wasNew and package.repo is not None:
import_repo_screenshot.delay(package.id)
next_url = package.get_url("packages.view")
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.get_url("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
@bp.route("/packages/new/", methods=["GET", "POST"]) @bp.route("/packages/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
@@ -359,16 +270,16 @@ def create_edit(author=None, name=None):
flash(gettext("Unable to find that user"), "danger") flash(gettext("Unable to find that user"), "danger")
return redirect(url_for("packages.create_edit")) return redirect(url_for("packages.create_edit"))
if not author.check_perm(current_user, Permission.CHANGE_AUTHOR): if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash(gettext("Permission denied"), "danger") flash(gettext("Permission denied"), "danger")
return redirect(url_for("packages.create_edit")) return redirect(url_for("packages.create_edit"))
else: else:
package = get_package_by_info(author, name) package = getPackageByInfo(author, name)
if package is None: if package is None:
abort(404) abort(404)
if not package.check_perm(current_user, Permission.EDIT_PACKAGE): if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
author = package.author author = package.author
@@ -377,23 +288,65 @@ 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 = package.tags form.tags.data = list(package.tags)
form.content_warnings.data = package.content_warnings form.content_warnings.data = list(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
if form.validate_on_submit(): if form.validate_on_submit():
ret = handle_create_edit(package, form, author) wasNew = False
if ret: if not package:
return ret package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.READY_FOR_REVIEW:
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else:
flash(gettext("Package already exists!"), "danger")
return redirect(url_for("packages.create_edit"))
package = Package()
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
})
if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id)
next_url = package.getURL("packages.view")
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.getURL("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
package_query = Package.query.filter_by(state=PackageState.APPROVED) package_query = Package.query.filter_by(state=PackageState.APPROVED)
if package is not None: if package is not None:
@@ -403,7 +356,7 @@ def create_edit(author=None, name=None):
return render_template("packages/create_edit.html", package=package, return render_template("packages/create_edit.html", package=package,
form=form, author=author, enable_wizard=enableWizard, form=form, author=author, enable_wizard=enableWizard,
packages=package_query.all(), packages=package_query.all(),
modnames=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(), mpackages=MetaPackage.query.order_by(db.asc(MetaPackage.name)).all(),
tabs=get_package_tabs(current_user, package), current_tab="edit") tabs=get_package_tabs(current_user, package), current_tab="edit")
@@ -415,18 +368,17 @@ def move_to_state(package):
if state is None: if state is None:
abort(400) abort(400)
if not package.can_move_to_state(current_user, state): if not package.canMoveToState(current_user, state):
flash(gettext("You don't have permission to do that"), "danger") flash(gettext("You don't have permission to do that"), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
package.state = state package.state = state
msg = "Marked {} as {}".format(package.title, state.value) msg = "Marked {} as {}".format(package.title, state.value)
if state == PackageState.APPROVED: if state == PackageState.APPROVED:
if not package.approved_at: if not package.approved_at:
post_discord_webhook.delay(package.author.display_name, post_discord_webhook.delay(package.author.username,
"New package {}".format(package.get_url("packages.view", absolute=True)), False, "New package {}".format(package.getURL("packages.view", absolute=True)), False)
package.title, package.short_desc, package.get_thumb_url(2, True))
package.approved_at = datetime.datetime.now() package.approved_at = datetime.datetime.now()
screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all() screenshots = PackageScreenshot.query.filter_by(package=package, approved=False).all()
@@ -435,24 +387,23 @@ def move_to_state(package):
msg = "Approved {}".format(package.title) msg = "Approved {}".format(package.title)
elif state == PackageState.READY_FOR_REVIEW: elif state == PackageState.READY_FOR_REVIEW:
post_discord_webhook.delay(package.author.display_name, post_discord_webhook.delay(package.author.username,
"Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True, "Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
package.title, package.short_desc, package.get_thumb_url(2, True))
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package) addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package) addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
if package.state == PackageState.CHANGES_NEEDED: if package.state == PackageState.CHANGES_NEEDED:
flash(gettext("Please comment what changes are needed in the approval thread"), "warning") flash(gettext("Please comment what changes are needed in the approval thread"), "warning")
if package.review_thread: if package.review_thread:
return redirect(package.review_thread.get_view_url()) return redirect(package.review_thread.getViewURL())
else: else:
return redirect(url_for('threads.new', pid=package.id, title='Package approval comments')) return redirect(url_for('threads.new', pid=package.id, title='Package approval comments'))
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@@ -466,45 +417,37 @@ def remove(package):
reason = request.form.get("reason") or "?" reason = request.form.get("reason") or "?"
if "delete" in request.form: if "delete" in request.form:
if not package.check_perm(current_user, Permission.DELETE_PACKAGE): if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
flash(gettext("You don't have permission to do that"), "danger") flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
package.state = PackageState.DELETED package.state = PackageState.DELETED
url = url_for("users.profile", username=package.author.username) url = url_for("users.profile", username=package.author.username)
msg = "Deleted {}, reason={}".format(package.title, reason) msg = "Deleted {}, reason={}".format(package.title, reason)
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package) addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
add_audit_log(AuditSeverity.EDITOR, current_user, msg, url, package) addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
db.session.commit() db.session.commit()
post_discord_webhook.delay(current_user.username,
f"Deleted package {package.author.username}/{package.name} with reason '{reason}'",
True, package.title, package.short_desc, package.get_thumb_url(2, True))
flash(gettext("Deleted package"), "success") flash(gettext("Deleted package"), "success")
return redirect(url) return redirect(url)
elif "unapprove" in request.form: elif "unapprove" in request.form:
if not package.check_perm(current_user, Permission.UNAPPROVE_PACKAGE): if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash(gettext("You don't have permission to do that"), "danger") flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
package.state = PackageState.WIP package.state = PackageState.WIP
msg = "Unapproved {}, reason={}".format(package.title, reason) msg = "Unapproved {}, reason={}".format(package.title, reason)
add_notification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.get_url("packages.view"), package) addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package)
add_audit_log(AuditSeverity.EDITOR, current_user, msg, package.get_url("packages.view"), package) addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
post_discord_webhook.delay(current_user.username,
"Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True,
package.title, package.short_desc, package.get_thumb_url(2, True))
flash(gettext("Unapproved package"), "success") flash(gettext("Unapproved package"), "success")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
else: else:
abort(400) abort(400)
@@ -519,9 +462,9 @@ class PackageMaintainersForm(FlaskForm):
@login_required @login_required
@is_package_page @is_package_page
def edit_maintainers(package): def edit_maintainers(package):
if not package.check_perm(current_user, Permission.EDIT_MAINTAINERS): if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
flash(gettext("You don't have permission to edit maintainers"), "danger") flash(gettext("You do not have permission to edit maintainers"), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
form = PackageMaintainersForm(formdata=request.form) form = PackageMaintainersForm(formdata=request.form)
if request.method == "GET": if request.method == "GET":
@@ -537,13 +480,13 @@ def edit_maintainers(package):
if not user in package.maintainers: if not user in package.maintainers:
if thread: if thread:
thread.watchers.append(user) thread.watchers.append(user)
add_notification(user, current_user, NotificationType.MAINTAINER, addNotification(user, current_user, NotificationType.MAINTAINER,
"Added you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package) "Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
for user in package.maintainers: for user in package.maintainers:
if user != package.author and not user in users: if user != package.author and not user in users:
add_notification(user, current_user, NotificationType.MAINTAINER, addNotification(user, current_user, NotificationType.MAINTAINER,
"Removed you as a maintainer of {}".format(package.title), package.get_url("packages.view"), package) "Removed you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
package.maintainers.clear() package.maintainers.clear()
package.maintainers.extend(users) package.maintainers.extend(users)
@@ -551,13 +494,13 @@ def edit_maintainers(package):
package.maintainers.append(package.author) package.maintainers.append(package.author)
msg = "Edited {} maintainers".format(package.title) msg = "Edited {} maintainers".format(package.title)
add_notification(package.author, current_user, NotificationType.MAINTAINER, msg, package.get_url("packages.view"), package) addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package)
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
add_audit_log(severity, current_user, msg, package.get_url("packages.view"), package) addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all() users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).order_by(db.asc(User.username)).all()
@@ -578,20 +521,20 @@ def remove_self_maintainers(package):
else: else:
package.maintainers.remove(current_user) package.maintainers.remove(current_user)
add_notification(package.author, current_user, NotificationType.MAINTAINER, addNotification(package.author, current_user, NotificationType.MAINTAINER,
"Removed themself as a maintainer of {}".format(package.title), package.get_url("packages.view"), package) "Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
@bp.route("/packages/<author>/<name>/audit/") @bp.route("/packages/<author>/<name>/audit/")
@login_required @login_required
@is_package_page @is_package_page
def audit(package): def audit(package):
if not (package.check_perm(current_user, Permission.EDIT_PACKAGE) or if not (package.checkPerm(current_user, Permission.EDIT_PACKAGE) or
package.check_perm(current_user, Permission.APPROVE_NEW)): package.checkPerm(current_user, Permission.APPROVE_NEW)):
abort(403) abort(403)
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
@@ -599,7 +542,7 @@ def audit(package):
query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at)) query = package.audit_log_entries.order_by(db.desc(AuditLogEntry.created_at))
pagination = query.paginate(page=page, per_page=num) pagination = query.paginate(page, num, True)
return render_template("packages/audit.html", log=pagination.items, pagination=pagination, return render_template("packages/audit.html", log=pagination.items, pagination=pagination,
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit") package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
@@ -612,7 +555,7 @@ class PackageAliasForm(FlaskForm):
@bp.route("/packages/<author>/<name>/aliases/") @bp.route("/packages/<author>/<name>/aliases/")
@rank_required(UserRank.ADMIN) @rank_required(UserRank.EDITOR)
@is_package_page @is_package_page
def alias_list(package: Package): def alias_list(package: Package):
return render_template("packages/alias_list.html", package=package) return render_template("packages/alias_list.html", package=package)
@@ -620,7 +563,7 @@ def alias_list(package: Package):
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
@rank_required(UserRank.ADMIN) @rank_required(UserRank.EDITOR)
@is_package_page @is_package_page
def alias_create_edit(package: Package, alias_id: int = None): def alias_create_edit(package: Package, alias_id: int = None):
alias = None alias = None
@@ -639,7 +582,7 @@ def alias_create_edit(package: Package, alias_id: int = None):
form.populate_obj(alias) form.populate_obj(alias)
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.alias_list")) return redirect(package.getURL("packages.alias_list"))
return render_template("packages/alias_create_edit.html", package=package, form=form) return render_template("packages/alias_create_edit.html", package=package, form=form)
@@ -648,6 +591,9 @@ 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")
@@ -655,11 +601,14 @@ 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 mname in package.provides: for metapackage in package.provides:
packages_modnames[mname] = Package.query.filter(Package.id != package.id, packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
Package.state != PackageState.DELETED) \ Package.state != PackageState.DELETED) \
.filter(Package.provides.any(PackageProvides.c.metapackage_id == mname.id)) \ .filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.id)) \
.order_by(db.desc(Package.score)) \ .order_by(db.desc(Package.score)) \
.all() .all()
@@ -672,151 +621,3 @@ def similar(package):
return render_template("packages/similar.html", package=package, return render_template("packages/similar.html", package=package,
packages_modnames=packages_modnames, similar_topics=similar_topics) packages_modnames=packages_modnames, similar_topics=similar_topics)
class GameSupportForm(FlaskForm):
enable_support_detection = BooleanField(lazy_gettext("Enable support detection based on dependencies (recommended)"), [Optional()])
supported = StringField(lazy_gettext("Supported games"), [Optional()])
unsupported = StringField(lazy_gettext("Unsupported games"), [Optional()])
supports_all_games = BooleanField(lazy_gettext("Supports all games (unless stated) / is game independent"), [Optional()])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/support/", methods=["GET", "POST"])
@login_required
@is_package_page
def game_support(package):
if package.type != PackageType.MOD and package.type != PackageType.TXP:
abort(404)
can_edit = package.check_perm(current_user, Permission.EDIT_PACKAGE)
if not (can_edit or package.check_perm(current_user, Permission.APPROVE_NEW)):
abort(403)
if package.releases.count() == 0:
flash(gettext("You need at least one release before you can edit game support"), "danger")
return redirect(package.get_url('packages.create_release' if package.update_config else 'packages.setup_releases'))
if package.type == PackageType.MOD and len(package.provides) == 0:
flash(gettext("Mod(pack) needs to contain at least one mod. Please create a new release"), "danger")
return redirect(package.get_url('packages.list_releases'))
force_game_detection = package.supported_games.filter(and_(
PackageGameSupport.confidence > 1, PackageGameSupport.supports == True)).count() == 0
can_support_all_games = package.type != PackageType.TXP and \
package.supported_games.filter(and_(
PackageGameSupport.confidence == 1, PackageGameSupport.supports == True)).count() == 0
can_override = can_edit
form = GameSupportForm() if can_edit else None
if form and request.method == "GET":
form.enable_support_detection.data = package.enable_game_support_detection
form.supports_all_games.data = package.supports_all_games and can_support_all_games
if can_override:
manual_supported_games = package.supported_games.filter_by(confidence=11).all()
form.supported.data = ", ".join([x.game.name for x in manual_supported_games if x.supports])
form.unsupported.data = ", ".join([x.game.name for x in manual_supported_games if not x.supports])
else:
form.supported = None
form.unsupported = None
if form and form.validate_on_submit():
detect_update_needed = False
if can_override:
try:
resolver = GameSupportResolver(db.session)
game_is_supported = {}
for game in get_games_from_csv(db.session, form.supported.data or ""):
game_is_supported[game.id] = True
for game in get_games_from_csv(db.session, form.unsupported.data or ""):
game_is_supported[game.id] = False
resolver.set_supported(package, game_is_supported, 11)
detect_update_needed = True
except LogicError as e:
flash(e.message, "danger")
next_url = package.get_url("packages.game_support")
enable_support_detection = form.enable_support_detection.data or force_game_detection
if enable_support_detection != package.enable_game_support_detection:
package.enable_game_support_detection = enable_support_detection
if package.enable_game_support_detection:
detect_update_needed = True
else:
package.supported_games.filter_by(confidence=1).delete()
if can_support_all_games:
package.supports_all_games = form.supports_all_games.data
add_audit_log(AuditSeverity.NORMAL, current_user, "Edited game support", package.get_url("packages.game_support"), package)
db.session.commit()
if detect_update_needed:
release = package.releases.first()
if release:
task_id = uuid()
check_zip_release.apply_async((release.id, release.file_path), task_id=task_id)
next_url = url_for("tasks.check", id=task_id, r=next_url)
return redirect(next_url)
all_game_support = package.supported_games.all()
all_game_support.sort(key=lambda x: -x.game.score)
supported_games_list: typing.List[str] = [x.game.name for x in all_game_support if x.supports]
if package.supports_all_games:
supported_games_list.insert(0, "*")
supported_games = ", ".join(supported_games_list)
unsupported_games = ", ".join([x.game.name for x in all_game_support if not x.supports])
mod_conf_lines = ""
if supported_games:
mod_conf_lines += f"supported_games = {supported_games}"
if unsupported_games:
mod_conf_lines += f"\nunsupported_games = {unsupported_games}"
return render_template("packages/game_support.html", package=package, form=form,
mod_conf_lines=mod_conf_lines, force_game_detection=force_game_detection,
can_support_all_games=can_support_all_games, tabs=get_package_tabs(current_user, package),
current_tab="game_support")
@bp.route("/packages/<author>/<name>/stats/")
@is_package_page
def statistics(package):
start = request.args.get("start")
end = request.args.get("end")
return render_template("packages/stats.html",
package=package, tabs=get_package_tabs(current_user, package), current_tab="stats",
start=start, end=end, options=get_daterange_options(), noindex=start or end)
@bp.route("/packages/<author>/<name>/stats.csv")
@is_package_page
def stats_csv(package):
stats: typing.List[PackageDailyStats] = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all()
columns = ["platform_minetest", "platform_other", "reason_new",
"reason_dependency", "reason_update"]
result = "Date, " + ", ".join(columns) + "\n"
for stat in stats:
stat: PackageDailyStats
result += stat.date.isoformat()
for i, key in enumerate(columns):
result += ", " + str(getattr(stat, key))
result += "\n"
date = datetime.datetime.utcnow().date()
res = make_response(result, 200)
res.headers["Content-Disposition"] = f"attachment; filename={package.author.username}_{package.name}_stats_{date.isoformat()}.csv"
res.headers["Content-type"] = "text/csv"
return res

View File

@@ -14,26 +14,28 @@
# 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 render_template, request, redirect, flash, url_for, abort
from flask_babel import lazy_gettext, gettext from flask import *
from flask_login import login_required, current_user from flask_babel import gettext, lazy_gettext
from flask_login import login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField from wtforms import *
from wtforms.validators import InputRequired, Length, Optional from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms.validators import *
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, MinetestRelease, \
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
from app.rediscache import has_key, set_key, make_download_key from app.rediscache import has_key, set_key, make_download_key
from app.tasks.importtasks import check_update_config from app.tasks.importtasks import check_update_config
from app.utils import is_user_bot, is_package_page, nonempty_or_none from app.utils import *
from . import bp, get_package_tabs 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")
@@ -50,16 +52,15 @@ 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)
file_upload = FileField(lazy_gettext("File Upload")) fileUpload = FileField(lazy_gettext("File Upload"))
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()], min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()],
query_factory=lambda: get_mt_releases(False), get_pk=lambda a: a.id, get_label=lambda a: a.name) 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)])
@@ -77,8 +78,8 @@ class EditPackageReleaseForm(FlaskForm):
@login_required @login_required
@is_package_page @is_package_page
def create_release(package): def create_release(package):
if not package.check_perm(current_user, Permission.MAKE_RELEASE): if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data # Initial form class from post data and default data
form = CreatePackageReleaseForm() form = CreatePackageReleaseForm()
@@ -95,39 +96,34 @@ def create_release(package):
try: try:
if form["uploadOpt"].data == "vcs": if form["uploadOpt"].data == "vcs":
rel = do_create_vcs_release(current_user, package, form.title.data, rel = do_create_vcs_release(current_user, package, form.title.data,
form.vcsLabel.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual()) form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
else: else:
rel = do_create_zip_release(current_user, package, form.title.data, rel = do_create_zip_release(current_user, package, form.title.data,
form.file_upload.data, form.min_rel.data.get_actual(), form.max_rel.data.get_actual()) form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.get_edit_url())) return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")
return render_template("packages/release_new.html", package=package, form=form) return render_template("packages/release_new.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<int: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)
ip = request.headers.get("X-Forwarded-For") or request.remote_addr ip = request.headers.get("X-Forwarded-For") or request.remote_addr
if ip is not None and not is_user_bot(): if ip is not None and not is_user_bot():
is_minetest = (request.headers.get("User-Agent") or "").startswith("Minetest")
reason = request.args.get("reason")
PackageDailyStats.update(package, is_minetest, reason)
key = make_download_key(ip, release.package) key = make_download_key(ip, release.package)
if not has_key(key): if not has_key(key):
set_key(key, "true") set_key(key, "true")
bonus = 0 bonus = 1
if reason == "new":
bonus = 1
elif reason == "dependency" or reason == "update":
bonus = 0.5
PackageRelease.query.filter_by(id=release.id).update({ PackageRelease.query.filter_by(id=release.id).update({
"downloads": PackageRelease.downloads + 1 "downloads": PackageRelease.downloads + 1
@@ -139,12 +135,12 @@ def download_release(package, id):
"score": Package.score + bonus "score": Package.score + bonus
}) })
db.session.commit() db.session.commit()
return redirect(release.url) return redirect(release.url)
@bp.route("/packages/<author>/<name>/releases/<int:id>/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/releases/<id>/", methods=["GET", "POST"])
@login_required @login_required
@is_package_page @is_package_page
def edit_release(package, id): def edit_release(package, id):
@@ -152,10 +148,10 @@ def edit_release(package, id):
if release is None or release.package != package: if release is None or release.package != package:
abort(404) abort(404)
canEdit = package.check_perm(current_user, Permission.MAKE_RELEASE) canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
canApprove = release.check_perm(current_user, Permission.APPROVE_RELEASE) canApprove = release.checkPerm(current_user, Permission.APPROVE_RELEASE)
if not (canEdit or canApprove): if not (canEdit or canApprove):
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data # Initial form class from post data and default data
form = EditPackageReleaseForm(formdata=request.form, obj=release) form = EditPackageReleaseForm(formdata=request.form, obj=release)
@@ -167,10 +163,10 @@ def edit_release(package, id):
if form.validate_on_submit(): if form.validate_on_submit():
if canEdit: if canEdit:
release.title = form["title"].data release.title = form["title"].data
release.min_rel = form["min_rel"].data.get_actual() release.min_rel = form["min_rel"].data.getActual()
release.max_rel = form["max_rel"].data.get_actual() release.max_rel = form["max_rel"].data.getActual()
if package.check_perm(current_user, Permission.CHANGE_RELEASE_URL): if package.checkPerm(current_user, Permission.CHANGE_RELEASE_URL):
release.url = form["url"].data release.url = form["url"].data
release.task_id = form["task_id"].data release.task_id = form["task_id"].data
if release.task_id is not None: if release.task_id is not None:
@@ -182,7 +178,7 @@ def edit_release(package, id):
release.approved = False release.approved = False
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.list_releases")) return redirect(package.getURL("packages.list_releases"))
return render_template("packages/release_edit.html", package=package, release=release, form=form) return render_template("packages/release_edit.html", package=package, release=release, form=form)
@@ -203,8 +199,8 @@ class BulkReleaseForm(FlaskForm):
@login_required @login_required
@is_package_page @is_package_page
def bulk_change_release(package): def bulk_change_release(package):
if not package.check_perm(current_user, Permission.MAKE_RELEASE): if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data # Initial form class from post data and default data
form = BulkReleaseForm() form = BulkReleaseForm()
@@ -216,18 +212,18 @@ def bulk_change_release(package):
for release in package.releases.all(): for release in package.releases.all():
if form["set_min"].data and (not only_change_none or release.min_rel is None): if form["set_min"].data and (not only_change_none or release.min_rel is None):
release.min_rel = form["min_rel"].data.get_actual() release.min_rel = form["min_rel"].data.getActual()
if form["set_max"].data and (not only_change_none or release.max_rel is None): if form["set_max"].data and (not only_change_none or release.max_rel is None):
release.max_rel = form["max_rel"].data.get_actual() release.max_rel = form["max_rel"].data.getActual()
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.list_releases")) return redirect(package.getURL("packages.list_releases"))
return render_template("packages/release_bulk_change.html", package=package, form=form) return render_template("packages/release_bulk_change.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/releases/<int:id>/delete/", methods=["POST"]) @bp.route("/packages/<author>/<name>/releases/<id>/delete/", methods=["POST"])
@login_required @login_required
@is_package_page @is_package_page
def delete_release(package, id): def delete_release(package, id):
@@ -235,13 +231,13 @@ def delete_release(package, id):
if release is None or release.package != package: if release is None or release.package != package:
abort(404) abort(404)
if not release.check_perm(current_user, Permission.DELETE_RELEASE): if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
return redirect(package.get_url("packages.list_releases")) return redirect(package.getURL("packages.list_releases"))
db.session.delete(release) db.session.delete(release)
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
class PackageUpdateConfigFrom(FlaskForm): class PackageUpdateConfigFrom(FlaskForm):
@@ -263,7 +259,7 @@ def set_update_config(package, form):
db.session.add(package.update_config) db.session.add(package.update_config)
form.populate_obj(package.update_config) form.populate_obj(package.update_config)
package.update_config.ref = nonempty_or_none(form.ref.data) package.update_config.ref = nonEmptyOrNone(form.ref.data)
package.update_config.make_release = form.action.data == "make_release" package.update_config.make_release = form.action.data == "make_release"
if package.update_config.trigger == PackageUpdateTrigger.COMMIT: if package.update_config.trigger == PackageUpdateTrigger.COMMIT:
@@ -289,12 +285,12 @@ def set_update_config(package, form):
@login_required @login_required
@is_package_page @is_package_page
def update_config(package): def update_config(package):
if not package.check_perm(current_user, Permission.MAKE_RELEASE): if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
abort(403) abort(403)
if not package.repo: if not package.repo:
flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger") flash(gettext("Please add a Git repository URL in order to set up automatic releases"), "danger")
return redirect(package.get_url("packages.create_edit")) return redirect(package.getURL("packages.create_edit"))
form = PackageUpdateConfigFrom(obj=package.update_config) form = PackageUpdateConfigFrom(obj=package.update_config)
if request.method == "GET": if request.method == "GET":
@@ -318,9 +314,9 @@ def update_config(package):
if not form.disable.data and package.releases.count() == 0: if not form.disable.data and package.releases.count() == 0:
flash(gettext("Now, please create an initial release"), "success") flash(gettext("Now, please create an initial release"), "success")
return redirect(package.get_url("packages.create_release")) return redirect(package.getURL("packages.create_release"))
return redirect(package.get_url("packages.list_releases")) return redirect(package.getURL("packages.list_releases"))
return render_template("packages/update_config.html", package=package, form=form) return render_template("packages/update_config.html", package=package, form=form)
@@ -329,11 +325,11 @@ def update_config(package):
@login_required @login_required
@is_package_page @is_package_page
def setup_releases(package): def setup_releases(package):
if not package.check_perm(current_user, Permission.MAKE_RELEASE): if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
abort(403) abort(403)
if package.update_config: if package.update_config:
return redirect(package.get_url("packages.update_config")) return redirect(package.getURL("packages.update_config"))
return render_template("packages/release_wizard.html", package=package) return render_template("packages/release_wizard.html", package=package)
@@ -349,7 +345,7 @@ def bulk_update_config(username=None):
if not user: if not user:
abort(404) abort(404)
if current_user != user and not current_user.rank.at_least(UserRank.EDITOR): if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
abort(403) abort(403)
form = PackageUpdateConfigFrom() form = PackageUpdateConfigFrom()

View File

@@ -13,23 +13,21 @@
# #
# 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 collections import namedtuple from collections import namedtuple
import typing
from flask import render_template, request, redirect, flash, url_for, abort, jsonify
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext
from . import bp
from flask import *
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 StringField, TextAreaField, SubmitField, RadioField from wtforms import *
from wtforms.validators import InputRequired, Length 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, AuditSeverity, PackageState Permission
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_package_page, add_notification, get_int_or_abort, is_yes, is_safe_url, rank_required, \
add_audit_log, has_blocked_domains, should_return_json
from . import bp
@bp.route("/reviews/") @bp.route("/reviews/")
@@ -37,17 +35,16 @@ def list_reviews():
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100)) num = min(40, get_int_or_abort(request.args.get("n"), 100))
pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page=page, per_page=num) pagination = PackageReview.query.order_by(db.desc(PackageReview.created_at)).paginate(page, num, True)
return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items) return render_template("packages/reviews_list.html", pagination=pagination, reviews=pagination.items)
class ReviewForm(FlaskForm): class ReviewForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3, 100)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)]) comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
rating = RadioField(lazy_gettext("Rating"), [InputRequired()], recommends = RadioField(lazy_gettext("Private"), [InputRequired()],
choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))]) choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))])
btn_submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/review/", methods=["GET", "POST"])
@login_required @login_required
@@ -55,142 +52,124 @@ class ReviewForm(FlaskForm):
def review(package): def review(package):
if current_user in package.maintainers: if current_user in package.maintainers:
flash(gettext("You can't review your own package!"), "danger") flash(gettext("You can't review your own package!"), "danger")
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
if package.state != PackageState.APPROVED: if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404) abort(404)
review = PackageReview.query.filter_by(package=package, author=current_user).first() review = PackageReview.query.filter_by(package=package, author=current_user).first()
can_review = review is not None or current_user.can_review_ratelimit()
if not can_review:
flash(gettext("You've reviewed too many packages recently. Please wait before trying again, and consider making your reviews more detailed"), "danger")
form = ReviewForm(formdata=request.form, obj=review) form = ReviewForm(formdata=request.form, obj=review)
# Set default values # Set default values
if request.method == "GET" and review: if request.method == "GET" and review:
form.title.data = review.thread.title form.title.data = review.thread.title
form.rating.data = str(review.rating) form.recommends.data = "yes" if review.recommends else "no"
form.comment.data = review.thread.first_reply.comment form.comment.data = review.thread.replies[0].comment
# Validate and submit # Validate and submit
elif can_review and form.validate_on_submit(): elif form.validate_on_submit():
if has_blocked_domains(form.comment.data, current_user.username, f"review of {package.get_id()}"): was_new = False
flash(gettext("Linking to blocked sites is not allowed"), "danger") if not review:
was_new = True
review = PackageReview()
review.package = package
review.author = current_user
db.session.add(review)
review.recommends = form.recommends.data == "yes"
thread = review.thread
if not thread:
thread = Thread()
thread.author = current_user
thread.private = False
thread.package = package
thread.review = review
db.session.add(thread)
thread.watchers.append(current_user)
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = form.comment.data
db.session.add(reply)
thread.replies.append(reply)
else: else:
was_new = False reply = thread.replies[0]
if not review: reply.comment = form.comment.data
was_new = True
review = PackageReview()
review.package = package
review.author = current_user
db.session.add(review)
review.rating = int(form.rating.data) thread.title = form.title.data
thread = review.thread db.session.commit()
if not thread:
thread = Thread()
thread.author = current_user
thread.private = False
thread.package = package
thread.review = review
db.session.add(thread)
thread.watchers.append(current_user) package.recalcScore()
reply = ThreadReply() if was_new:
reply.thread = thread notif_msg = "New review '{}'".format(form.title.data)
reply.author = current_user type = NotificationType.NEW_REVIEW
reply.comment = form.comment.data else:
db.session.add(reply) notif_msg = "Updated review '{}'".format(form.title.data)
type = NotificationType.OTHER
thread.replies.append(reply) addNotification(package.maintainers, current_user, type, notif_msg,
else: url_for("threads.view", id=thread.id), package)
reply = thread.first_reply
reply.comment = form.comment.data
thread.title = form.title.data if was_new:
post_discord_webhook.delay(thread.author.username,
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
db.session.commit() db.session.commit()
package.recalculate_score() return redirect(package.getURL("packages.view"))
if was_new:
notif_msg = "New review '{}'".format(form.title.data)
type = NotificationType.NEW_REVIEW
else:
notif_msg = "Updated review '{}'".format(form.title.data)
type = NotificationType.OTHER
add_notification(package.maintainers, current_user, type, notif_msg,
url_for("threads.view", id=thread.id), package)
if was_new:
post_discord_webhook.delay(thread.author.display_name,
"Reviewed {}: {}".format(package.title, thread.get_view_url(absolute=True)), False)
db.session.commit()
return redirect(package.get_url("packages.view"))
return render_template("packages/review_create_edit.html", return render_template("packages/review_create_edit.html",
form=form, package=package, review=review) form=form, package=package, review=review)
@bp.route("/packages/<author>/<name>/reviews/<reviewer>/delete/", methods=["POST"]) @bp.route("/packages/<author>/<name>/review/delete/", methods=["POST"])
@login_required @login_required
@is_package_page @is_package_page
def delete_review(package, reviewer): def delete_review(package):
review = PackageReview.query \ review = PackageReview.query.filter_by(package=package, author=current_user).first()
.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.check_perm(current_user, Permission.DELETE_REVIEW):
abort(403)
thread = review.thread thread = review.thread
reply = ThreadReply() reply = ThreadReply()
reply.thread = thread reply.thread = thread
reply.author = current_user reply.author = current_user
reply.comment = "_converted review into a thread_" reply.comment = "_converted review into a thread_"
reply.is_status_update = True
db.session.add(reply) db.session.add(reply)
thread.review = None thread.review = None
msg = "Converted review by {} to thread".format(review.author.display_name)
add_audit_log(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
current_user, msg, thread.get_view_url(), thread.package)
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title) notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
add_notification(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.recalculate_score()
db.session.commit() db.session.commit()
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
def handle_review_vote(package: Package, review_id: int) -> typing.Optional[str]: def handle_review_vote(package: Package, review_id: int):
if current_user in package.maintainers: if current_user in package.maintainers:
return gettext("You can't vote on the reviews on your own package!") flash(gettext("You can't vote on the reviews on your own package!"), "danger")
return
review: PackageReview = PackageReview.query.get(review_id) review: PackageReview = PackageReview.query.get(review_id)
if review is None or review.package != package: if review is None or review.package != package:
abort(404) abort(404)
if review.author == current_user: if review.author == current_user:
return gettext("You can't vote on your own reviews!") flash(gettext("You can't vote on your own reviews!"), "danger")
return
is_positive = is_yes(request.form["is_positive"]) is_positive = isYes(request.form["is_positive"])
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first() vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
if vote is None: if vote is None:
@@ -212,21 +191,14 @@ def handle_review_vote(package: Package, review_id: int) -> typing.Optional[str]
@login_required @login_required
@is_package_page @is_package_page
def review_vote(package, review_id): def review_vote(package, review_id):
msg = handle_review_vote(package, review_id) handle_review_vote(package, review_id)
if should_return_json():
if msg:
return jsonify({"success": False, "error": msg}), 403
else:
return jsonify({"success": True})
if msg:
flash(msg, "danger")
next_url = request.args.get("r") next_url = request.args.get("r")
if next_url and is_safe_url(next_url): if next_url and is_safe_url(next_url):
return redirect(next_url) return redirect(next_url)
else: else:
return redirect(review.thread.get_view_url()) return redirect(review.thread.getViewURL())
@bp.route("/packages/<author>/<name>/review-votes/") @bp.route("/packages/<author>/<name>/review-votes/")
@@ -235,7 +207,7 @@ def review_vote(package, review_id):
def review_votes(package): def review_votes(package):
user_biases = {} user_biases = {}
for review in package.reviews: for review in package.reviews:
review_sign = review.as_weight() review_sign = 1 if review.recommends else -1
for vote in review.votes: for vote in review.votes:
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0]) user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
vote_sign = 1 if vote.is_positive else -1 vote_sign = 1 if vote.is_positive else -1
@@ -255,5 +227,5 @@ 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", 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

@@ -14,24 +14,24 @@
# 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 render_template, request, redirect, flash, url_for, abort
from flask_babel import lazy_gettext, gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, BooleanField, FileField
from wtforms.validators import InputRequired, Length, DataRequired, Optional
from wtforms_sqlalchemy.fields import QuerySelectField
from flask import *
from flask_babel import gettext, lazy_gettext
from flask_wtf import FlaskForm
from flask_login import login_required
from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.utils import *
from . import bp, get_package_tabs
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.logic.screenshots import do_create_screenshot, do_order_screenshots from app.logic.screenshots import do_create_screenshot, do_order_screenshots
from . import bp, get_package_tabs
from app.models import Permission, db, PackageScreenshot
from app.utils import is_package_page
class CreateScreenshotForm(FlaskForm): class CreateScreenshotForm(FlaskForm):
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)]) title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)])
file_upload = FileField(lazy_gettext("File Upload"), [InputRequired()]) fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@@ -50,8 +50,11 @@ class EditPackageScreenshotsForm(FlaskForm):
@login_required @login_required
@is_package_page @is_package_page
def screenshots(package): def screenshots(package):
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS): if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
if package.screenshots.count() == 0:
return redirect(package.getURL("packages.create_screenshot"))
form = EditPackageScreenshotsForm(obj=package) form = EditPackageScreenshotsForm(obj=package)
form.cover_image.query = package.screenshots form.cover_image.query = package.screenshots
@@ -61,7 +64,7 @@ def screenshots(package):
if order: if order:
try: try:
do_order_screenshots(current_user, package, order.split(",")) do_order_screenshots(current_user, package, order.split(","))
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")
@@ -77,22 +80,22 @@ def screenshots(package):
@login_required @login_required
@is_package_page @is_package_page
def create_screenshot(package): def create_screenshot(package):
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS): if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
# Initial form class from post data and default data # Initial form class from post data and default data
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.file_upload.data, False) do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
return redirect(package.get_url("packages.screenshots")) return redirect(package.getURL("packages.screenshots"))
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")
return render_template("packages/screenshot_new.html", package=package, form=form) return render_template("packages/screenshot_new.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/edit/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/screenshots/<id>/edit/", methods=["GET", "POST"])
@login_required @login_required
@is_package_page @is_package_page
def edit_screenshot(package, id): def edit_screenshot(package, id):
@@ -100,31 +103,31 @@ def edit_screenshot(package, id):
if screenshot is None or screenshot.package != package: if screenshot is None or screenshot.package != package:
abort(404) abort(404)
can_edit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS) canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
can_approve = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT) canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
if not (can_edit or can_approve): if not (canEdit or canApprove):
return redirect(package.get_url("packages.screenshots")) return redirect(package.getURL("packages.screenshots"))
# Initial form class from post data and default data # Initial form class from post data and default data
form = EditScreenshotForm(obj=screenshot) form = EditScreenshotForm(obj=screenshot)
if form.validate_on_submit(): if form.validate_on_submit():
was_approved = screenshot.approved wasApproved = screenshot.approved
if can_edit: if canEdit:
screenshot.title = form["title"].data or "Untitled" screenshot.title = form["title"].data or "Untitled"
if can_approve: if canApprove:
screenshot.approved = form["approved"].data screenshot.approved = form["approved"].data
else: else:
screenshot.approved = was_approved screenshot.approved = wasApproved
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.screenshots")) return redirect(package.getURL("packages.screenshots"))
return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form) return render_template("packages/screenshot_edit.html", package=package, screenshot=screenshot, form=form)
@bp.route("/packages/<author>/<name>/screenshots/<int:id>/delete/", methods=["POST"]) @bp.route("/packages/<author>/<name>/screenshots/<id>/delete/", methods=["POST"])
@login_required @login_required
@is_package_page @is_package_page
def delete_screenshot(package, id): def delete_screenshot(package, id):
@@ -132,7 +135,7 @@ def delete_screenshot(package, id):
if screenshot is None or screenshot.package != package: if screenshot is None or screenshot.package != package:
abort(404) abort(404)
if not package.check_perm(current_user, Permission.ADD_SCREENSHOTS): if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
flash(gettext("Permission denied"), "danger") flash(gettext("Permission denied"), "danger")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
@@ -143,4 +146,4 @@ def delete_screenshot(package, id):
db.session.delete(screenshot) db.session.delete(screenshot)
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.screenshots")) return redirect(package.getURL("packages.screenshots"))

View File

@@ -14,7 +14,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/>.
from flask import Blueprint, request, render_template, url_for, abort from flask import Blueprint, request, render_template, url_for
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
from flask_login import current_user from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@@ -25,7 +25,7 @@ from wtforms.validators import InputRequired, Length
from app.models import User, UserRank from app.models import User, UserRank
from app.tasks.emails import send_user_email from app.tasks.emails import send_user_email
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_no, abs_url_samesite from app.utils import isNo, abs_url_samesite
bp = Blueprint("report", __name__) bp = Blueprint("report", __name__)
@@ -37,17 +37,14 @@ class ReportForm(FlaskForm):
@bp.route("/report/", methods=["GET", "POST"]) @bp.route("/report/", methods=["GET", "POST"])
def report(): def report():
is_anon = not current_user.is_authenticated or not is_no(request.args.get("anon")) is_anon = not current_user.is_authenticated or not isNo(request.args.get("anon"))
url = request.args.get("url") url = request.args.get("url")
if url: if url:
if url.startswith("/report/"):
abort(404)
url = abs_url_samesite(url) url = abs_url_samesite(url)
form = ReportForm(formdata=request.form) if current_user.is_authenticated else None form = ReportForm(formdata=request.form)
if form and form.validate_on_submit(): if form.validate_on_submit():
if current_user.is_authenticated: if current_user.is_authenticated:
user_info = f"{current_user.username}" user_info = f"{current_user.username}"
else: else:
@@ -57,11 +54,10 @@ 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, admin.locale or "en", task = send_user_email.delay(admin.email, f"User report from {user_info}", text)
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)
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home"))) return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None) return render_template("report/index.html", form=form, url=url, is_anon=is_anon)

View File

@@ -14,31 +14,28 @@
# 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 Blueprint, jsonify, url_for, request, redirect, render_template
from flask_login import login_required, current_user from flask import *
from flask_login import login_required
from app import csrf from app import csrf
from app.models import UserRank
from app.tasks import celery from app.tasks import celery
from app.tasks.importtasks import get_meta from app.tasks.importtasks import getMeta
from app.utils import should_return_json from app.utils import *
bp = Blueprint("tasks", __name__) bp = Blueprint("tasks", __name__)
@csrf.exempt @csrf.exempt
@bp.route("/tasks/getmeta/new/", methods=["POST"]) @bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required @login_required
def start_getmeta(): def start_getmeta():
from flask import request
author = request.args.get("author") author = request.args.get("author")
author = current_user.forums_username if author is None else author author = current_user.forums_username if author is None else author
aresult = get_meta.delay(request.args.get("url"), author) aresult = getMeta.delay(request.args.get("url"), author)
return jsonify({ return jsonify({
"poll_url": url_for("tasks.check", id=aresult.id), "poll_url": url_for("tasks.check", id=aresult.id),
}) })
@bp.route("/tasks/<id>/") @bp.route("/tasks/<id>/")
def check(id): def check(id):
result = celery.AsyncResult(id) result = celery.AsyncResult(id)
@@ -46,13 +43,14 @@ def check(id):
traceback = result.traceback traceback = result.traceback
result = result.result result = result.result
None
if isinstance(result, Exception): if isinstance(result, Exception):
info = { info = {
'id': id, 'id': id,
'status': status, 'status': status,
} }
if current_user.is_authenticated and current_user.rank.at_least(UserRank.ADMIN): if current_user.is_authenticated and current_user.rank.atLeast(UserRank.ADMIN):
info["error"] = str(traceback) info["error"] = str(traceback)
elif str(result)[1:12] == "TaskError: ": elif str(result)[1:12] == "TaskError: ":
info["error"] = str(result)[12:-1] info["error"] = str(result)[12:-1]
@@ -65,7 +63,7 @@ def check(id):
'result': result, 'result': result,
} }
if should_return_json(): if shouldReturnJson():
return jsonify(info) return jsonify(info)
else: else:
r = request.args.get("r") r = request.args.get("r")

View File

@@ -13,11 +13,8 @@
# #
# 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 Blueprint, request, render_template, abort, flash, redirect, url_for
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext
from sqlalchemy import or_
from sqlalchemy.orm import selectinload, joinedload
from app.markdown import get_user_mentions, render_markdown from app.markdown import get_user_mentions, render_markdown
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
@@ -25,12 +22,11 @@ from app.tasks.webhooktasks import post_discord_webhook
bp = Blueprint("threads", __name__) bp = Blueprint("threads", __name__)
from flask_login import current_user, login_required from flask_login import current_user, login_required
from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \ from app.models import *
NotificationType, ThreadReply from app.utils import addNotification, isYes, addAuditLog, get_system_user
from app.utils import add_notification, is_yes, add_audit_log, get_system_user, has_blocked_domains
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField, BooleanField from wtforms import *
from wtforms.validators import InputRequired, Length from wtforms.validators import *
from app.utils import get_int_or_abort from app.utils import get_int_or_abort
@@ -40,12 +36,10 @@ def list_all():
if not Permission.SEE_THREAD.check(current_user): if not Permission.SEE_THREAD.check(current_user):
query = query.filter_by(private=False) query = query.filter_by(private=False)
package = None
pid = request.args.get("pid") pid = request.args.get("pid")
if pid: if pid:
pid = get_int_or_abort(pid) pid = get_int_or_abort(pid)
package = Package.query.get_or_404(pid) query = query.filter_by(package_id=pid)
query = query.filter_by(package=package)
query = query.filter_by(review_id=None) query = query.filter_by(review_id=None)
@@ -54,17 +48,16 @@ def list_all():
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
num = min(40, get_int_or_abort(request.args.get("n"), 100)) num = min(40, get_int_or_abort(request.args.get("n"), 100))
pagination = query.paginate(page=page, per_page=num) pagination = query.paginate(page, num, True)
return render_template("threads/list.html", pagination=pagination, threads=pagination.items, return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
package=package, noindex=pid)
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"]) @bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@login_required @login_required
def subscribe(id): def subscribe(id):
thread = Thread.query.get(id) thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD): if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404) abort(404)
if current_user in thread.watchers: if current_user in thread.watchers:
@@ -74,14 +67,14 @@ def subscribe(id):
thread.watchers.append(current_user) thread.watchers.append(current_user)
db.session.commit() db.session.commit()
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"]) @bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@login_required @login_required
def unsubscribe(id): def unsubscribe(id):
thread = Thread.query.get(id) thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD): if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404) abort(404)
if current_user in thread.watchers: if current_user in thread.watchers:
@@ -91,20 +84,21 @@ def unsubscribe(id):
else: else:
flash(gettext("Already not subscribed!"), "success") flash(gettext("Already not subscribed!"), "success")
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"]) @bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
@login_required @login_required
def set_lock(id): def set_lock(id):
thread = Thread.query.get(id) thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD): if thread is None or not thread.checkPerm(current_user, Permission.LOCK_THREAD):
abort(404) abort(404)
thread.locked = is_yes(request.args.get("lock")) thread.locked = isYes(request.args.get("lock"))
if thread.locked is None: if thread.locked is None:
abort(400) abort(400)
msg = None
if thread.locked: if thread.locked:
msg = "Locked thread '{}'".format(thread.title) msg = "Locked thread '{}'".format(thread.title)
flash(gettext("Locked thread"), "success") flash(gettext("Locked thread"), "success")
@@ -112,19 +106,19 @@ def set_lock(id):
msg = "Unlocked thread '{}'".format(thread.title) msg = "Unlocked thread '{}'".format(thread.title)
flash(gettext("Unlocked thread"), "success") flash(gettext("Unlocked thread"), "success")
add_notification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package) addNotification(thread.watchers, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package) addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package)
db.session.commit() db.session.commit()
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
@login_required @login_required
def delete_thread(id): def delete_thread(id):
thread = Thread.query.get(id) thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD): if thread is None or not thread.checkPerm(current_user, Permission.DELETE_THREAD):
abort(404) abort(404)
if request.method == "GET": if request.method == "GET":
@@ -136,7 +130,7 @@ def delete_thread(id):
db.session.delete(thread) db.session.delete(thread)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary) addAuditLog(AuditSeverity.MODERATION, current_user, msg, None, thread.package, summary)
db.session.commit() db.session.commit()
@@ -158,28 +152,28 @@ def delete_reply(id):
if reply is None or reply.thread != thread: if reply is None or reply.thread != thread:
abort(404) abort(404)
if thread.first_reply == reply: if thread.replies[0] == reply:
flash(gettext("Cannot delete thread opening post!"), "danger") flash(gettext("Cannot delete thread opening post!"), "danger")
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
if not reply.check_perm(current_user, Permission.DELETE_REPLY): if not reply.checkPerm(current_user, Permission.DELETE_REPLY):
abort(403) abort(403)
if request.method == "GET": if request.method == "GET":
return render_template("threads/delete_reply.html", thread=thread, reply=reply) return render_template("threads/delete_reply.html", thread=thread, reply=reply)
msg = "Deleted reply by {}".format(reply.author.display_name) msg = "Deleted reply by {}".format(reply.author.display_name)
add_audit_log(AuditSeverity.MODERATION, current_user, msg, thread.get_view_url(), thread.package, reply.comment) addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
db.session.delete(reply) db.session.delete(reply)
db.session.commit() db.session.commit()
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
class CommentForm(FlaskForm): class CommentForm(FlaskForm):
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(2, 2000)]) comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
btn_submit = SubmitField(lazy_gettext("Comment")) submit = SubmitField(lazy_gettext("Comment"))
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@@ -193,29 +187,27 @@ def edit_reply(id):
if reply_id is None: if reply_id is None:
abort(404) abort(404)
reply: ThreadReply = ThreadReply.query.get(reply_id) reply = ThreadReply.query.get(reply_id)
if reply is None or reply.thread != thread: if reply is None or reply.thread != thread:
abort(404) abort(404)
if not reply.check_perm(current_user, Permission.EDIT_REPLY): if not reply.checkPerm(current_user, Permission.EDIT_REPLY):
abort(403) abort(403)
form = CommentForm(formdata=request.form, obj=reply) form = CommentForm(formdata=request.form, obj=reply)
if form.validate_on_submit(): if form.validate_on_submit():
comment = form.comment.data comment = form.comment.data
if has_blocked_domains(comment, current_user.username, f"edit to reply {reply.get_url(True)}"):
flash(gettext("Linking to blocked sites is not allowed"), "danger")
else:
msg = "Edited reply by {}".format(reply.author.display_name)
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
add_notification(reply.author, current_user, NotificationType.OTHER, msg, thread.get_view_url(), thread.package)
add_audit_log(severity, current_user, msg, thread.get_view_url(), thread.package, reply.comment)
reply.comment = comment msg = "Edited reply by {}".format(reply.author.display_name)
severity = AuditSeverity.NORMAL if current_user == reply.author else AuditSeverity.MODERATION
addNotification(reply.author, current_user, NotificationType.OTHER, msg, thread.getViewURL(), thread.package)
addAuditLog(severity, current_user, msg, thread.getViewURL(), thread.package, reply.comment)
db.session.commit() reply.comment = comment
return redirect(thread.get_view_url()) db.session.commit()
return redirect(thread.getViewURL())
return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form) return render_template("threads/edit_reply.html", thread=thread, reply=reply, form=form)
@@ -223,65 +215,64 @@ def edit_reply(id):
@bp.route("/threads/<int:id>/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id): def view(id):
thread: Thread = Thread.query.get(id) thread: Thread = Thread.query.get(id)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD): if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404) abort(404)
form = CommentForm(formdata=request.form) if thread.check_perm(current_user, Permission.COMMENT_THREAD) else None if current_user.is_authenticated and request.method == "POST":
comment = request.form["comment"]
# Check that title is none to load comments into textarea if redirected from new thread page if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
if form and form.validate_on_submit() and request.form.get("title") is None: flash(gettext("You cannot comment on this thread"), "danger")
comment = form.comment.data return redirect(thread.getViewURL())
if not current_user.can_comment_ratelimit(): if not current_user.canCommentRL():
flash(gettext("Please wait before commenting again"), "danger") flash(gettext("Please wait before commenting again"), "danger")
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
if has_blocked_domains(comment, current_user.username, f"reply to {thread.get_view_url(True)}"): if 2000 >= len(comment) > 3:
flash(gettext("Linking to blocked sites is not allowed"), "danger") reply = ThreadReply()
return render_template("threads/view.html", thread=thread, form=form) reply.author = current_user
reply.comment = comment
db.session.add(reply)
reply = ThreadReply() thread.replies.append(reply)
reply.author = current_user if not current_user in thread.watchers:
reply.comment = comment thread.watchers.append(current_user)
db.session.add(reply)
thread.replies.append(reply) for mentioned_username in get_user_mentions(render_markdown(comment)):
if current_user not in thread.watchers: mentioned = User.query.filter_by(username=mentioned_username)
thread.watchers.append(current_user) if mentioned is None:
continue
for mentioned_username in get_user_mentions(render_markdown(comment)): msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
mentioned = User.query.filter_by(username=mentioned_username).first() addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
if mentioned is None: msg, thread.getViewURL(), thread.package)
continue
msg = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title) msg = "New comment on '{}'".format(thread.title)
add_notification(mentioned, current_user, NotificationType.THREAD_REPLY, addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
msg, thread.get_view_url(), thread.package)
thread.watchers.append(mentioned) if thread.author == get_system_user():
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
thread.getViewURL(), thread.package)
post_discord_webhook.delay(current_user.username,
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
msg = "New comment on '{}'".format(thread.title) db.session.commit()
add_notification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.get_view_url(), thread.package)
if thread.author == get_system_user(): return redirect(thread.getViewURL())
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
thread.get_view_url(), thread.package)
post_discord_webhook.delay(current_user.display_name,
"Replied to bot messages: {}".format(thread.get_view_url(absolute=True)), True)
db.session.commit() else:
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
return redirect(thread.get_view_url()) return render_template("threads/view.html", thread=thread)
return render_template("threads/view.html", thread=thread, form=form)
class ThreadForm(FlaskForm): class ThreadForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)]) comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
private = BooleanField(lazy_gettext("Private")) private = BooleanField(lazy_gettext("Private"))
btn_submit = SubmitField(lazy_gettext("Open Thread")) submit = SubmitField(lazy_gettext("Open Thread"))
@bp.route("/threads/new/", methods=["GET", "POST"]) @bp.route("/threads/new/", methods=["GET", "POST"])
@@ -293,33 +284,33 @@ def new():
if "pid" in request.args: if "pid" in request.args:
package = Package.query.get(int(request.args.get("pid"))) package = Package.query.get(int(request.args.get("pid")))
if package is None: if package is None:
abort(404) flash(gettext("Unable to find that package!"), "danger")
def_is_private = request.args.get("private") or False # Don't allow making orphan threads on approved packages for now
if package is None and not current_user.rank.at_least(UserRank.APPROVER): if package is None:
abort(404) abort(403)
is_review_thread = package and not package.approved def_is_private = request.args.get("private") or False
allow_private_change = not is_review_thread if package is None:
if is_review_thread:
def_is_private = True def_is_private = True
allow_change = package and package.approved
is_review_thread = package and not package.approved
# Check that user can make the thread # Check that user can make the thread
if package and not package.check_perm(current_user, Permission.CREATE_THREAD): if not package.checkPerm(current_user, Permission.CREATE_THREAD):
flash(gettext("Unable to create thread!"), "danger") flash(gettext("Unable to create thread!"), "danger")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
# Only allow creating one thread when not approved # Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None: elif is_review_thread and package.review_thread is not None:
# Redirect submit to `view` page, which checks for `title` in the form data and so won't commit the reply flash(gettext("An approval thread already exists!"), "danger")
flash(gettext("An approval thread already exists! Consider replying there instead"), "danger") return redirect(package.review_thread.getViewURL())
return redirect(package.review_thread.get_view_url(), code=307)
elif not current_user.can_open_thread_ratelimit(): elif not current_user.canOpenThreadRL():
flash(gettext("Please wait before opening another thread"), "danger") flash(gettext("Please wait before opening another thread"), "danger")
if package: if package:
return redirect(package.get_url("packages.view")) return redirect(package.getURL("packages.view"))
else: else:
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
@@ -330,60 +321,57 @@ def new():
# Validate and submit # Validate and submit
elif form.validate_on_submit(): elif form.validate_on_submit():
if has_blocked_domains(form.comment.data, current_user.username, f"new thread"): thread = Thread()
flash(gettext("Linking to blocked sites is not allowed"), "danger") thread.author = current_user
else: thread.title = form.title.data
thread = Thread() thread.private = form.private.data if allow_change else def_is_private
thread.author = current_user thread.package = package
thread.title = form.title.data db.session.add(thread)
thread.private = form.private.data if allow_private_change else def_is_private
thread.package = package
db.session.add(thread)
thread.watchers.append(current_user) thread.watchers.append(current_user)
if package and package.author != current_user: if package is not None and package.author != current_user:
thread.watchers.append(package.author) thread.watchers.append(package.author)
reply = ThreadReply() reply = ThreadReply()
reply.thread = thread reply.thread = thread
reply.author = current_user reply.author = current_user
reply.comment = form.comment.data reply.comment = form.comment.data
db.session.add(reply) db.session.add(reply)
thread.replies.append(reply) thread.replies.append(reply)
db.session.commit() db.session.commit()
if is_review_thread: if is_review_thread:
package.review_thread = thread package.review_thread = thread
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)): for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
mentioned = User.query.filter_by(username=mentioned_username).first() mentioned = User.query.filter_by(username=mentioned_username)
if mentioned is None: if mentioned is None:
continue continue
msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title) msg = "Mentioned by {} in new thread '{}'".format(current_user.display_name, thread.title)
add_notification(mentioned, current_user, NotificationType.NEW_THREAD, addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
msg, thread.get_view_url(), thread.package) msg, thread.getViewURL(), thread.package)
thread.watchers.append(mentioned) notif_msg = "New thread '{}'".format(thread.title)
if package is not None:
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
notif_msg = "New thread '{}'".format(thread.title) approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
if package is not None: addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
add_notification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.get_view_url(), package)
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
add_notification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.get_view_url(), package)
if is_review_thread: if is_review_thread:
post_discord_webhook.delay(current_user.display_name, post_discord_webhook.delay(current_user.username,
"Opened approval thread: {}".format(thread.get_view_url(absolute=True)), True) "Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
db.session.commit() db.session.commit()
return redirect(thread.get_view_url()) return redirect(thread.getViewURL())
return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
@bp.route("/users/<username>/comments/") @bp.route("/users/<username>/comments/")
@@ -392,16 +380,4 @@ def user_comments(username):
if user is None: if user is None:
abort(404) abort(404)
page = get_int_or_abort(request.args.get("page"), 1) return render_template("threads/user_comments.html", user=user, replies=user.replies)
num = min(40, get_int_or_abort(request.args.get("n"), 40))
# Filter replies the current user can see
query = ThreadReply.query.options(selectinload(ThreadReply.thread)).filter_by(author=user)
only_public = False
if current_user != user and not (current_user.is_authenticated and current_user.rank.at_least(UserRank.APPROVER)):
query = query.filter(ThreadReply.thread.has(private=False))
only_public = True
pagination = query.order_by(db.desc(ThreadReply.created_at)).paginate(page=page, per_page=num)
return render_template("threads/user_comments.html", user=user, pagination=pagination, only_public=only_public)

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,22 +14,23 @@
# 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 datetime
from flask import redirect, abort, render_template, flash, request, url_for
from flask_babel import gettext, get_locale, lazy_gettext from flask import *
from flask_babel import gettext, lazy_gettext
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_
from wtforms import StringField, SubmitField, BooleanField, PasswordField, validators from wtforms import *
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, Email, EqualTo from wtforms.validators import *
from app.models import *
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
from app.utils import random_string, make_flask_login_password, is_safe_url, check_password_hash, add_audit_log, \ from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
nonempty_or_none, post_login, is_username_valid nonEmptyOrNone, post_login, is_username_valid
from passlib.pwd import genphrase
from . import bp from . import bp
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
@@ -46,6 +47,7 @@ def handle_login(form):
else: else:
flash(err, "danger") flash(err, "danger")
username = form.username.data.strip() username = form.username.data.strip()
user = User.query.filter(or_(User.username == username, User.email == username)).first() user = User.query.filter(or_(User.username == username, User.email == username)).first()
if user is None: if user is None:
@@ -58,8 +60,8 @@ def handle_login(form):
flash(gettext("You need to confirm the registration email"), "danger") flash(gettext("You need to confirm the registration email"), "danger")
return return
add_audit_log(AuditSeverity.USER, user, "Logged in using password", addAuditLog(AuditSeverity.USER, user, "Logged in using password",
url_for("users.profile", username=user.username)) url_for("users.profile", username=user.username))
db.session.commit() db.session.commit()
if not login_user(user, remember=form.remember_me.data): if not login_user(user, remember=form.remember_me.data):
@@ -71,11 +73,11 @@ def handle_login(form):
@bp.route("/user/login/", methods=["GET", "POST"]) @bp.route("/user/login/", methods=["GET", "POST"])
def login(): def login():
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
if current_user.is_authenticated: if current_user.is_authenticated:
next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return redirect(next or url_for("homepage.home")) return redirect(next or url_for("homepage.home"))
form = LoginForm(request.form) form = LoginForm(request.form)
@@ -87,7 +89,8 @@ def login():
if request.method == "GET": if request.method == "GET":
form.remember_me.data = True form.remember_me.data = True
return render_template("users/login.html", form=form, next=next)
return render_template("users/login.html", form=form)
@bp.route("/user/logout/", methods=["GET", "POST"]) @bp.route("/user/logout/", methods=["GET", "POST"])
@@ -97,12 +100,11 @@ def logout():
class RegisterForm(FlaskForm): class RegisterForm(FlaskForm):
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonempty_or_none]) display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone])
username = StringField(lazy_gettext("Username"), [InputRequired(), username = StringField(lazy_gettext("Username"), [InputRequired(),
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext( Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed"))])
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()]) email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(12, 100)]) password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()]) question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()]) agree = BooleanField(lazy_gettext("I agree"), [DataRequired()])
submit = SubmitField(lazy_gettext("Register")) submit = SubmitField(lazy_gettext("Register"))
@@ -140,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, get_locale().language, gettext("Email already in use"), send_anon_email.delay(form.email.data, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.", 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"))
@@ -154,10 +156,10 @@ def handle_register(form):
user.display_name = form.display_name.data user.display_name = form.display_name.data
db.session.add(user) db.session.add(user)
add_audit_log(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name, addAuditLog(AuditSeverity.USER, user, "Registered with email, display name=" + user.display_name,
url_for("users.profile", username=user.username)) url_for("users.profile", username=user.username))
token = random_string(32) token = randomString(32)
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = user ver.user = user
@@ -166,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, get_locale().language) send_verify_email.delay(form.email.data, token)
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
@@ -179,14 +181,14 @@ def register():
if ret: if ret:
return ret return ret
return render_template("users/register.html", form=form) return render_template("users/register.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
class ForgotPasswordForm(FlaskForm): class ForgotPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()]) email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password")) submit = SubmitField(lazy_gettext("Reset Password"))
@bp.route("/user/forgot-password/", methods=["GET", "POST"]) @bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password(): def forgot_password():
form = ForgotPasswordForm(request.form) form = ForgotPasswordForm(request.form)
@@ -194,10 +196,10 @@ def forgot_password():
email = form.email.data email = form.email.data
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user: if user:
token = random_string(32) token = randomString(32)
add_audit_log(AuditSeverity.USER, user, "(Anonymous) requested a password reset", addAuditLog(AuditSeverity.USER, user, "(Anonymous) requested a password reset",
url_for("users.profile", username=user.username), None) url_for("users.profile", username=user.username), None)
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = user ver.user = user
@@ -207,11 +209,25 @@ 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, get_locale().language) send_verify_email.delay(form.email.data, token)
else: else:
html = render_template("emails/unable_to_find_account.html") send_anon_email.delay(email, "Unable to find account", """
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"), <p>
html, html) 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>
""")
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
@@ -220,16 +236,15 @@ def forgot_password():
class SetPasswordForm(FlaskForm): class SetPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [Optional(), Email()]) email = StringField(lazy_gettext("Email"), [Optional(), Email()])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)]) password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100), password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
EqualTo('password', message=lazy_gettext('Passwords must match'))]) validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
class ChangePasswordForm(FlaskForm): class ChangePasswordForm(FlaskForm):
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(6, 100)]) old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(12, 100)]) password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(12, 100), password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))]) validators.EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
@@ -241,33 +256,33 @@ def handle_set_password(form):
flash(gettext("Passwords do not match"), "danger") flash(gettext("Passwords do not match"), "danger")
return return
add_audit_log(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username)) addAuditLog(AuditSeverity.USER, current_user, "Changed their password", url_for("users.profile", username=current_user.username))
current_user.password = make_flask_login_password(form.password.data) current_user.password = make_flask_login_password(form.password.data)
if hasattr(form, "email"): if hasattr(form, "email"):
new_email = nonempty_or_none(form.email.data) newEmail = nonEmptyOrNone(form.email.data)
if new_email and new_email != current_user.email: if newEmail and newEmail != current_user.email:
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0: if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger") flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return return
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, get_locale().language, gettext("Email already in use"), send_anon_email.delay(form.email.data, gettext("Email already in use"),
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.", 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:
token = random_string(32) token = randomString(32)
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = current_user ver.user = current_user
ver.token = token ver.token = token
ver.email = new_email ver.email = newEmail
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language) send_verify_email.delay(form.email.data, token)
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"))
@@ -290,7 +305,8 @@ def change_password():
else: else:
flash(gettext("Old password is incorrect"), "danger") flash(gettext("Old password is incorrect"), "danger")
return render_template("users/change_set_password.html", form=form) return render_template("users/change_set_password.html", form=form,
suggested_password=genphrase(entropy=52, wordset="bip39"))
@bp.route("/user/set-password/", methods=["GET", "POST"]) @bp.route("/user/set-password/", methods=["GET", "POST"])
@@ -308,7 +324,8 @@ def set_password():
if ret: if ret:
return ret return ret
return render_template("users/change_set_password.html", form=form) return render_template("users/change_set_password.html", form=form, optional=request.args.get("optional"),
suggested_password=genphrase(entropy=52, wordset="bip39"))
@bp.route("/user/verify/") @bp.route("/user/verify/")
@@ -329,8 +346,8 @@ def verify_email():
user = ver.user user = ver.user
add_audit_log(AuditSeverity.USER, user, "Confirmed their email", addAuditLog(AuditSeverity.USER, user, "Confirmed their email",
url_for("users.profile", username=user.username)) url_for("users.profile", username=user.username))
was_activating = not user.is_active was_activating = not user.is_active
@@ -343,7 +360,6 @@ 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."))
@@ -383,9 +399,9 @@ def unsubscribe_verify():
sub = EmailSubscription(email) sub = EmailSubscription(email)
db.session.add(sub) db.session.add(sub)
sub.token = random_string(32) sub.token = randomString(32)
db.session.commit() db.session.commit()
send_unsubscribe_verify.delay(form.email.data, get_locale().language) send_unsubscribe_verify.delay(form.email.data)
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))

View File

@@ -13,15 +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/>.
from flask_babel import gettext from flask_babel import gettext
from . import bp from . import bp
from flask import redirect, render_template, session, request, flash, url_for from flask import redirect, render_template, session, request, flash, url_for
from app.models import db, User, UserRank from app.models import db, User, UserRank
from app.utils import random_string, login_user_set_active, is_username_valid from app.utils import randomString, login_user_set_active, is_username_valid
from app.tasks.forumtasks import check_forum_account from app.tasks.forumtasks import checkForumAccount
from app.utils.phpbbparser import get_profile from app.utils.phpbbparser import getProfile
@bp.route("/user/claim/", methods=["GET", "POST"]) @bp.route("/user/claim/", methods=["GET", "POST"])
@@ -38,11 +37,11 @@ def claim_forums():
method = request.args.get("method") method = request.args.get("method")
if not is_username_valid(username): if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger") flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
return redirect(url_for("users.claim_forums")) return redirect(url_for("users.claim_forums"))
user = User.query.filter_by(forums_username=username).first() user = User.query.filter_by(forums_username=username).first()
if user and user.rank.at_least(UserRank.NEW_MEMBER): if user and user.rank.atLeast(UserRank.NEW_MEMBER):
flash(gettext("User has already been claimed"), "danger") flash(gettext("User has already been claimed"), "danger")
return redirect(url_for("users.claim_forums")) return redirect(url_for("users.claim_forums"))
elif method == "github": elif method == "github":
@@ -55,27 +54,28 @@ def claim_forums():
if "forum_token" in session: if "forum_token" in session:
token = session["forum_token"] token = session["forum_token"]
else: else:
token = random_string(12) token = randomString(12)
session["forum_token"] = token session["forum_token"] = token
if request.method == "POST": if request.method == "POST":
ctype = request.form.get("claim_type") ctype = request.form.get("claim_type")
username = request.form.get("username") username = request.form.get("username")
if not is_username_valid(username): if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger") flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
elif ctype == "github": elif ctype == "github":
task = check_forum_account.delay(username) task = checkForumAccount.delay(username)
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github"))) return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))
elif ctype == "forum": elif ctype == "forum":
user = User.query.filter_by(forums_username=username).first() user = User.query.filter_by(forums_username=username).first()
if user is not None and user.rank.at_least(UserRank.NEW_MEMBER): if user is not None and user.rank.atLeast(UserRank.NEW_MEMBER):
flash(gettext("That user has already been claimed!"), "danger") flash(gettext("That user has already been claimed!"), "danger")
return redirect(url_for("users.claim_forums")) return redirect(url_for("users.claim_forums"))
# Get signature # Get signature
sig = None
try: try:
profile = get_profile("https://forum.minetest.net", username) profile = getProfile("https://forum.minetest.net", username)
sig = profile.signature if profile else None sig = profile.signature if profile else None
except IOError as e: except IOError as e:
if hasattr(e, 'message'): if hasattr(e, 'message'):

View File

@@ -15,17 +15,15 @@
# 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 Optional, Tuple, List from typing import Optional
from flask import redirect, url_for, abort, render_template, request from flask import *
from flask_babel import gettext from flask_babel import gettext
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import func, text from sqlalchemy import func
from app.models import User, db, Package, PackageReview, PackageState, PackageType, UserRank
from app.utils import get_daterange_options
from app.tasks.forumtasks import check_forum_account
from app.models import *
from app.tasks.forumtasks import checkForumAccount
from . import bp from . import bp
@@ -68,9 +66,6 @@ class Medal:
@classmethod @classmethod
def make_locked(cls, description: str, progress: Tuple[int, int]): def make_locked(cls, description: str, progress: Tuple[int, int]):
if progress[0] is None or progress[1] is None:
raise Exception("Invalid progress")
return Medal(description=description, progress=progress) return Medal(description=description, progress=progress)
@@ -132,7 +127,7 @@ def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
unlocked.append(Medal.make_unlocked( unlocked.append(Medal.make_unlocked(
place_to_color(review_idx + 1), "fa-star-half-alt", title, description)) place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
elif review_boundary is not None: else:
description = gettext(u"Consider writing more helpful reviews to get a medal.") description = gettext(u"Consider writing more helpful reviews to get a medal.")
if review_idx: if review_idx:
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1) description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)
@@ -218,7 +213,7 @@ def profile(username):
if not user: if not user:
abort(404) abort(404)
if not current_user.is_authenticated or (user != current_user and not current_user.can_access_todo_list()): if not current_user.is_authenticated or (user != current_user and not current_user.canAccessTodoList()):
packages = user.packages.filter_by(state=PackageState.APPROVED) packages = user.packages.filter_by(state=PackageState.APPROVED)
maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED) maintained_packages = user.maintained_packages.filter_by(state=PackageState.APPROVED)
else: else:
@@ -237,40 +232,20 @@ def profile(username):
medals_unlocked=unlocked, medals_locked=locked) medals_unlocked=unlocked, medals_locked=locked)
@bp.route("/users/<username>/check-forums/", methods=["POST"]) @bp.route("/users/<username>/check/", methods=["POST"])
@login_required @login_required
def user_check_forums(username): def user_check(username):
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if user is None: if user is None:
abort(404) abort(404)
if current_user != user and not current_user.rank.at_least(UserRank.MODERATOR): if current_user != user and not current_user.rank.atLeast(UserRank.MODERATOR):
abort(403) abort(403)
if user.forums_username is None: if user.forums_username is None:
abort(404) abort(404)
task = check_forum_account.delay(user.forums_username, force_replace_pic=True) task = checkForumAccount.delay(user.forums_username)
next_url = url_for("users.profile", username=username) next_url = url_for("users.profile", username=username)
return redirect(url_for("tasks.check", id=task.id, r=next_url)) return redirect(url_for("tasks.check", id=task.id, r=next_url))
@bp.route("/user/stats/")
@login_required
def statistics_redirect():
return redirect(url_for("users.statistics", username=current_user.username))
@bp.route("/users/<username>/stats/")
def statistics(username):
user = User.query.filter_by(username=username).first()
if user is None:
abort(404)
downloads = db.session.query(func.sum(Package.downloads)).filter(Package.author==user).one()
start = request.args.get("start")
end = request.args.get("end")
return render_template("users/stats.html", user=user, downloads=downloads[0],
start=start, end=end, options=get_daterange_options(), noindex=start or end)

View File

@@ -1,31 +1,14 @@
# ContentDB from flask import *
# Copyright (C) 2023 rubenwardy from flask_babel import gettext, lazy_gettext
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, abort, render_template, request, flash, url_for
from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user from flask_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_
from wtforms import StringField, SubmitField, BooleanField, SelectField from wtforms import *
from wtforms.validators import Length, Optional, Email, URL from wtforms.validators import *
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \ from app.models import *
UserEmailVerification, Permission, NotificationType, UserBan from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required
from app.tasks.emails import send_verify_email from app.tasks.emails import send_verify_email
from app.utils import nonempty_or_none, add_audit_log, random_string, rank_required, has_blocked_domains
from . import bp from . import bp
@@ -53,14 +36,7 @@ def get_setting_tabs(user):
}, },
] ]
if user.check_perm(current_user, Permission.CREATE_OAUTH_CLIENT): if current_user.rank.atLeast(UserRank.MODERATOR):
ret.append({
"id": "oauth_clients",
"title": gettext("OAuth2 Applications"),
"url": url_for("oauth.list_clients", username=user.username)
})
if current_user.rank.at_least(UserRank.MODERATOR):
ret.append({ ret.append({
"id": "modtools", "id": "modtools",
"title": gettext("Moderator Tools"), "title": gettext("Moderator Tools"),
@@ -71,49 +47,41 @@ def get_setting_tabs(user):
class UserProfileForm(FlaskForm): class UserProfileForm(FlaskForm):
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonempty_or_none(x.strip())]) display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None]) website_url = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None]) donate_url = StringField(lazy_gettext("Donation URL"), [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
def handle_profile_edit(form: UserProfileForm, user: User, username: str): def handle_profile_edit(form, user, username):
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
add_audit_log(severity, current_user, "Edited {}'s profile".format(user.display_name), addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name),
url_for("users.profile", username=username)) url_for("users.profile", username=username))
display_name = form.display_name.data or user.username
if user.check_perm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
user.display_name != display_name:
if user.checkPerm(current_user, Permission.CHANGE_DISPLAY_NAME) and \
user.display_name != form.display_name.data:
if User.query.filter(User.id != user.id, if User.query.filter(User.id != user.id,
or_(User.username == display_name, or_(User.username == form.display_name.data,
User.display_name.ilike(display_name))).count() > 0: User.display_name.ilike(form.display_name.data))).count() > 0:
flash(gettext("A user already has that name"), "danger") flash(gettext("A user already has that name"), "danger")
return None return None
alias_by_name = PackageAlias.query.filter(or_( alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author == display_name)).first() PackageAlias.author == form.display_name.data)).first()
if alias_by_name: if alias_by_name:
flash(gettext("A user already has that name"), "danger") flash(gettext("A user already has that name"), "danger")
return return
user.display_name = display_name user.display_name = form.display_name.data
severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION severity = AuditSeverity.USER if current_user == user else AuditSeverity.MODERATION
add_audit_log(severity, current_user, "Changed display name of {} to {}" addAuditLog(severity, current_user, "Changed display name of {} to {}"
.format(user.username, user.display_name), .format(user.username, user.display_name),
url_for("users.profile", username=username)) url_for("users.profile", username=username))
if user.check_perm(current_user, Permission.CHANGE_PROFILE_URLS): if user.checkPerm(current_user, Permission.CHANGE_PROFILE_URLS):
if has_blocked_domains(form.website_url.data, current_user.username, f"{user.username}'s website_url") or \ user.website_url = form["website_url"].data
has_blocked_domains(form.donate_url.data, current_user.username, f"{user.username}'s donate_url"): user.donate_url = form["donate_url"].data
flash(gettext("Linking to blocked sites is not allowed"), "danger")
return
user.website_url = form.website_url.data
user.donate_url = form.donate_url.data
db.session.commit() db.session.commit()
@@ -148,7 +116,7 @@ def make_settings_form():
} }
for notificationType in NotificationType: for notificationType in NotificationType:
key = "pref_" + notificationType.to_name() key = "pref_" + notificationType.toName()
attrs[key] = BooleanField("") attrs[key] = BooleanField("")
attrs[key + "_digest"] = BooleanField("") attrs[key + "_digest"] = BooleanField("")
@@ -159,27 +127,27 @@ SettingsForm = make_settings_form()
def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form): def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new, form):
for notificationType in NotificationType: for notificationType in NotificationType:
field_email = getattr(form, "pref_" + notificationType.to_name()).data field_email = getattr(form, "pref_" + notificationType.toName()).data
field_digest = getattr(form, "pref_" + notificationType.to_name() + "_digest").data or field_email field_digest = getattr(form, "pref_" + notificationType.toName() + "_digest").data or field_email
prefs.set_can_email(notificationType, field_email) prefs.set_can_email(notificationType, field_email)
prefs.set_can_digest(notificationType, field_digest) prefs.set_can_digest(notificationType, field_digest)
if is_new: if is_new:
db.session.add(prefs) db.session.add(prefs)
if user.check_perm(current_user, Permission.CHANGE_EMAIL): if user.checkPerm(current_user, Permission.CHANGE_EMAIL):
newEmail = form.email.data newEmail = form.email.data
if newEmail and newEmail != user.email and newEmail.strip() != "": if newEmail and newEmail != user.email and newEmail.strip() != "":
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0: if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger") flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return return
token = random_string(32) token = randomString(32)
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
msg = "Changed email of {}".format(user.display_name) msg = "Changed email of {}".format(user.display_name)
add_audit_log(severity, current_user, msg, url_for("users.profile", username=user.username)) addAuditLog(severity, current_user, msg, url_for("users.profile", username=user.username))
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = user ver.user = user
@@ -188,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, get_locale().language) send_verify_email.delay(newEmail, token)
return redirect(url_for("users.email_sent")) return redirect(url_for("users.email_sent"))
db.session.commit() db.session.commit()
@@ -206,7 +174,7 @@ def email_notifications(username=None):
if not user: if not user:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CHANGE_EMAIL): if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
abort(403) abort(403)
is_new = False is_new = False
@@ -219,8 +187,8 @@ def email_notifications(username=None):
types = [] types = []
for notificationType in NotificationType: for notificationType in NotificationType:
types.append(notificationType) types.append(notificationType)
data["pref_" + notificationType.to_name()] = prefs.get_can_email(notificationType) data["pref_" + notificationType.toName()] = prefs.get_can_email(notificationType)
data["pref_" + notificationType.to_name() + "_digest"] = prefs.get_can_digest(notificationType) data["pref_" + notificationType.toName() + "_digest"] = prefs.get_can_digest(notificationType)
data["email"] = user.email data["email"] = user.email
@@ -252,37 +220,34 @@ def delete(username):
if not user: if not user:
abort(404) abort(404)
if user.rank.at_least(UserRank.MODERATOR): if user.rank.atLeast(UserRank.MODERATOR):
flash(gettext("Users with moderator rank or above cannot be deleted"), "danger") flash(gettext("Users with moderator rank or above cannot be deleted"), "danger")
return redirect(url_for("users.account", username=username)) return redirect(url_for("users.account", username=username))
if request.method == "GET": if request.method == "GET":
return render_template("users/delete.html", user=user, can_delete=user.can_delete()) return render_template("users/delete.html", user=user, can_delete=user.can_delete())
if "delete" in request.form and (user.can_delete() or current_user.rank.at_least(UserRank.ADMIN)): if "delete" in request.form and (user.can_delete() or current_user.rank.atLeast(UserRank.ADMIN)):
msg = "Deleted user {}".format(user.username) msg = "Deleted user {}".format(user.username)
flash(msg, "success") flash(msg, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None) addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
if current_user.rank.at_least(UserRank.ADMIN): if current_user.rank.atLeast(UserRank.ADMIN):
for pkg in user.packages.all(): for pkg in user.packages.all():
pkg.review_thread = None pkg.review_thread = None
db.session.delete(pkg) db.session.delete(pkg)
db.session.delete(user) db.session.delete(user)
elif "deactivate" in request.form: elif "deactivate" in request.form:
for reply in user.replies.all(): user.replies.delete()
db.session.delete(reply)
for thread in user.threads.all(): for thread in user.threads.all():
db.session.delete(thread) db.session.delete(thread)
user.email = None user.email = None
user.rank = UserRank.NOT_JOINED
if user.rank != UserRank.BANNED:
user.rank = UserRank.NOT_JOINED
msg = "Deactivated user {}".format(user.username) msg = "Deactivated user {}".format(user.username)
flash(msg, "success") flash(msg, "success")
add_audit_log(AuditSeverity.MODERATION, current_user, msg, None) addAuditLog(AuditSeverity.MODERATION, current_user, msg, None)
else: else:
assert False assert False
@@ -311,17 +276,17 @@ def modtools(username):
if not user: if not user:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CHANGE_EMAIL): if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
abort(403) abort(403)
form = ModToolsForm(obj=user) form = ModToolsForm(obj=user)
if form.validate_on_submit(): if form.validate_on_submit():
severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION
add_audit_log(severity, current_user, "Edited {}'s account".format(user.display_name), addAuditLog(severity, current_user, "Edited {}'s account".format(user.display_name),
url_for("users.profile", username=username)) url_for("users.profile", username=username))
# Copy form fields to user_profile fields # Copy form fields to user_profile fields
if user.check_perm(current_user, Permission.CHANGE_USERNAMES): if user.checkPerm(current_user, Permission.CHANGE_USERNAMES):
if user.username != form.username.data: if user.username != form.username.data:
for package in user.packages: for package in user.packages:
alias = PackageAlias(user.username, package.name) alias = PackageAlias(user.username, package.name)
@@ -331,17 +296,17 @@ def modtools(username):
user.username = form.username.data user.username = form.username.data
user.display_name = form.display_name.data user.display_name = form.display_name.data
user.forums_username = nonempty_or_none(form.forums_username.data) user.forums_username = nonEmptyOrNone(form.forums_username.data)
user.github_username = nonempty_or_none(form.github_username.data) user.github_username = nonEmptyOrNone(form.github_username.data)
if user.check_perm(current_user, Permission.CHANGE_RANK): if user.checkPerm(current_user, Permission.CHANGE_RANK):
new_rank = form["rank"].data newRank = form["rank"].data
if current_user.rank.at_least(new_rank): if current_user.rank.atLeast(newRank):
if new_rank != user.rank: if newRank != user.rank:
user.rank = form["rank"].data user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title()) msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle())
add_audit_log(AuditSeverity.MODERATION, current_user, msg, addAuditLog(AuditSeverity.MODERATION, current_user, msg,
url_for("users.profile", username=username)) url_for("users.profile", username=username))
else: else:
flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger") flash(gettext("Can't promote a user to a rank higher than yourself!"), "danger")
@@ -359,15 +324,15 @@ def modtools_set_email(username):
if not user: if not user:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CHANGE_EMAIL): if not user.checkPerm(current_user, Permission.CHANGE_EMAIL):
abort(403) abort(403)
user.email = request.form["email"] user.email = request.form["email"]
user.is_active = False user.is_active = False
token = random_string(32) token = randomString(32)
add_audit_log(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}", addAuditLog(AuditSeverity.MODERATION, current_user, f"Set email and sent a password reset on {user.username}",
url_for("users.profile", username=user.username), None) url_for("users.profile", username=user.username), None)
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = user ver.user = user
@@ -377,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, user.locale or "en") send_verify_email.delay(user.email, token)
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))
@@ -390,48 +355,14 @@ def modtools_ban(username):
if not user: if not user:
abort(404) abort(404)
if not user.check_perm(current_user, Permission.CHANGE_RANK): if not user.checkPerm(current_user, Permission.CHANGE_RANK):
abort(403) abort(403)
message = request.form["message"] user.rank = UserRank.BANNED
expires_at = request.form.get("expires_at")
user.ban = UserBan() addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}",
user.ban.banned_by = current_user url_for("users.profile", username=user.username), None)
user.ban.message = message
if expires_at and expires_at != "":
user.ban.expires_at = expires_at
else:
user.rank = UserRank.BANNED
add_audit_log(AuditSeverity.MODERATION, current_user, f"Banned {user.username}, expires {user.ban.expires_at or '-'}, message: {message}",
url_for("users.profile", username=user.username), None)
db.session.commit() db.session.commit()
flash(f"Banned {user.username}", "success") flash(f"Banned {user.username}", "success")
return redirect(url_for("users.modtools", username=username)) return redirect(url_for("users.modtools", username=username))
@bp.route("/users/<username>/modtools/unban/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_unban(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.check_perm(current_user, Permission.CHANGE_RANK):
abort(403)
if user.ban:
db.session.delete(user.ban)
if user.rank == UserRank.BANNED:
user.rank = UserRank.MEMBER
add_audit_log(AuditSeverity.MODERATION, current_user, f"Unbanned {user.username}",
url_for("users.profile", username=user.username), None)
db.session.commit()
flash(f"Unbanned {user.username}", "success")
return redirect(url_for("users.modtools", username=username))

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,13 +64,6 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `is_authenticated`: True on successful API authentication * `is_authenticated`: True on successful API authentication
* `username`: Username of the user authenticated as, null otherwise. * `username`: Username of the user authenticated as, null otherwise.
* 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors. * 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors.
* DELETE `/api/delete-token/`: Deletes the currently used token.
```bash
# Logout
curl -X DELETE https://content.minetest.net/api/delete-token/ \
-H "Authorization: Bearer YOURTOKEN"
```
## Packages ## Packages
@@ -96,21 +89,6 @@ curl -X DELETE https://content.minetest.net/api/delete-token/ \
* `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.
* `donate_url`: URL to a donation page.
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
* GET `/api/packages/<author>/<name>/hypertext/`
* Converts the long description to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
to be used in a `hypertext` formspec element.
* Query arguments:
* `formspec_version`: Required, maximum supported formspec version.
* `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description.
* `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text.
* GET `/api/packages/<username>/<name>/dependencies/` * 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.
@@ -124,31 +102,10 @@ curl -X DELETE https://content.minetest.net/api/delete-token/ \
* `name`: Package name. * `name`: Package name.
* `provides`: List of technical mod names inside the package. * `provides`: List of technical mod names inside the package.
* `depends`: List of hard dependencies. * `depends`: List of hard dependencies.
* Each dep will either be a modname dependency (`name`), or a * Each dep will either be a metapackage dependency (`name`), or a
package dependency (`author/name`). package dependency (`author/name`).
* `optional_depends`: list of optional dependencies * `optional_depends`: list of optional dependencies
* Same as above. * Same as above.
* GET `/api/packages/<username>/<name>/stats/`
* Returns daily stats for package, or null if there is no data.
* Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning.
* Query args:
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
* `end`: end date, inclusive. Optional. Default: today. UTC.
* An object with the following keys:
* `start`: start date, inclusive. Ex: 2022-10-22. M
* `end`: end date, inclusive. Ex: 2022-11-05.
* `platform_minetest`: list of integers per day.
* `platform_other`: list of integers per day.
* `reason_new`: list of integers per day.
* `reason_dependency`: list of integers per day.
* `reason_update`: list of integers per day.
* GET `/api/package_stats/`
* Returns last 30 days of daily stats for _all_ packages.
* An object with the following keys:
* `start`: start date, inclusive. Ex: 2022-10-22.
* `end`: end date, inclusive. Ex: 2022-11-05.
* `package_downloads`: map from package key to list of download integers.
You can download a package by building one of the two URLs: You can download a package by building one of the two URLs:
@@ -183,7 +140,6 @@ Supported query parameters:
* `q`: Query string. * `q`: Query string.
* `author`: Filter by author. * `author`: Filter by author.
* `tag`: Filter by tags. * `tag`: Filter by tags.
* `game`: Filter by [Game Support](/help/game_support/), ex: `Wuzzy/mineclone2`. (experimental, doesn't show items that support every game currently).
* `random`: When present, enable random ordering and ignore `sort`. * `random`: When present, enable random ordering and ignore `sort`.
* `limit`: Return at most `limit` packages. * `limit`: Return at most `limit` packages.
* `hide`: Hide content based on [Content Flags](/help/content_flags/). * `hide`: Hide content based on [Content Flags](/help/content_flags/).
@@ -191,12 +147,12 @@ Supported query parameters:
* `order`: Sort ascending (`asc`) or descending (`desc`). * `order`: Sort ascending (`asc`) or descending (`desc`).
* `protocol_version`: Only show packages supported by this Minetest protocol version. * `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`. * `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* `fmt`: How the response is formatted. * `fmt`: How the response is formated.
* `keys`: author/name only. * `keys`: author/name only.
* `short`: stuff needed for the Minetest client. * `short`: stuff needed for the Minetest client.
### Releases ## Releases
* GET `/api/releases/` (List) * GET `/api/releases/` (List)
* Limited to 30 most recent releases. * Limited to 30 most recent releases.
@@ -216,11 +172,6 @@ Supported query parameters:
* `author`: author username * `author`: author username
* `name`: technical name * `name`: technical name
* `type`: `mod`, `game`, or `txp` * `type`: `mod`, `game`, or `txp`
* GET `/api/updates/` (Look-up table)
* Returns a look-up table from package key (`author/name`) to latest release id
* Query arguments
* `protocol_version`: Only show packages supported by this Minetest protocol version.
* `engine_version`: Only show packages supported by this Minetest engine version, eg: `5.3.0`.
* GET `/api/packages/<username>/<name>/releases/` (List) * GET `/api/packages/<username>/<name>/releases/` (List)
* Returns array of release dictionaries, see above, but without package info. * Returns array of release dictionaries, see above, but without package info.
* GET `/api/packages/<username>/<name>/releases/<id>/` (Read) * GET `/api/packages/<username>/<name>/releases/<id>/` (Read)
@@ -263,7 +214,7 @@ curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/
``` ```
### Screenshots ## Screenshots
* GET `/api/packages/<username>/<name>/screenshots/` (List) * GET `/api/packages/<username>/<name>/screenshots/` (List)
* Returns array of screenshot dictionaries with keys: * Returns array of screenshot dictionaries with keys:
@@ -273,7 +224,6 @@ 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)
@@ -281,16 +231,12 @@ 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.
@@ -303,11 +249,6 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
-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/ \
-H "Authorization: Bearer YOURTOKEN" -H "Authorization: Bearer YOURTOKEN"
@@ -316,22 +257,16 @@ 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 }"
``` ```
### Reviews ## Reviews
* GET `/api/packages/<username>/<name>/reviews/` (List) * GET `/api/packages/<username>/<name>/reviews/` (List)
* Returns array of review dictionaries with keys: * Returns array of review dictionaries with keys:
* `user`: dictionary with `display_name` and `username`. * `user`: dictionary with `display_name` and `username`.
* `title`: review title * `title`: review title
* `comment`: the text * `comment`: the text
* `rating`: 1 for negative, 3 for neutral, 5 for positive
* `is_positive`: boolean * `is_positive`: boolean
* `created_at`: iso timestamp * `created_at`: iso timestamp
* `votes`: dictionary with `helpful` and `unhelpful`, * `votes`: dictionary with `helpful` and `unhelpful`,
@@ -340,15 +275,12 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
* [Paginated result](#paginated-results) * [Paginated result](#paginated-results)
* `items`: array of review dictionaries, like above * `items`: array of review dictionaries, like above
* Each review also has a `package` dictionary with `type`, `author` and `name` * Each review also has a `package` dictionary with `type`, `author` and `name`
* Ordered by created at, newest to oldest.
* Query arguments: * Query arguments:
* `page`: page number, integer from 1 to max * `page`: page number, integer from 1 to max
* `n`: number of results per page, max 200 * `n`: number of results per page, max 100
* `author`: filter by review author username * `author`: filter by review author username
* `for_user`: filter by package author
* `rating`: 1 for negative, 3 for neutral, 5 for positive
* `is_positive`: true or false. Default: null * `is_positive`: true or false. Default: null
* `q`: filter by title (case-insensitive, no fulltext search) * `q`: filter by title (case insensitive, no fulltext search)
Example: Example:
@@ -372,39 +304,6 @@ Example:
``` ```
## Users
* GET `/api/users/<username>/`
* `username`
* `display_name`: human-readable name to be displayed in GUIs.
* `rank`: ContentDB [rank](/help/ranks_permissions/).
* `profile_pic_url`: URL to profile picture, or null.
* `website_url`: URL to website, or null.
* `donate_url`: URL to donate page, or null.
* `connections`: object
* `github`: GitHub username, or null.
* `forums`: forums username, or null.
* `links`: object
* `api_packages`: URL to API to list this user's packages.
* `profile`: URL to the HTML profile page.
* GET `/api/users/<username>/stats/`
* Returns daily stats for the user's packages, or null if there is no data.
* Daily date is done based on the UTC timezone.
* EXPERIMENTAL. This API may change without warning.
* Query args:
* `start`: start date, inclusive. Optional. Default: 2022-10-01. UTC.
* `end`: end date, inclusive. Optional. Default: today. UTC.
* A table with the following keys:
* `from`: start date, inclusive. Ex: 2022-10-22.
* `end`: end date, inclusive. Ex: 2022-11-05.
* `package_downloads`: map of package title to list of integers per day.
* `platform_minetest`: list of integers per day.
* `platform_other`: list of integers per day.
* `reason_new`: list of integers per day.
* `reason_dependency`: list of integers per day.
* `reason_update`: list of integers per day.
## Topics ## Topics
* GET `/api/topics/` ([View](/api/topics/)) * GET `/api/topics/` ([View](/api/topics/))
@@ -425,45 +324,14 @@ Supported query parameters:
* `show_discarded`: Show topics marked as discarded. * `show_discarded`: Show topics marked as discarded.
* `limit`: Return at most `limit` topics. * `limit`: Return at most `limit` topics.
## Collections
* GET `/api/collections/`
* Query args:
* `author`: collection author username.
* `package`: collections that contain the package.
* Returns JSON array of collection entries:
* `author`: author username.
* `name`: collection name.
* `title`
* `short_description`
* `created_at`: creation time in iso format.
* `private`: whether collection is private, boolean.
* `package_count`: number of packages, integer.
* GET `/api/collections/<username>/<name>/`
* Returns JSON object for collection:
* `author`: author username.
* `name`: collection name.
* `title`
* `short_description`
* `long_description`
* `created_at`: creation time in iso format.
* `private`: whether collection is private, boolean.
* `items`: array of item objects:
* `package`: short info about the package.
* `description`: custom short description.
* `created_at`: when the package was added to the collection.
* `order`: integer.
## Types ## Types
### 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
* `views`: number of views of this tag.
### Content Warnings ### Content Warnings
@@ -507,22 +375,3 @@ 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
* GET `/api/cdb_schema/` ([View](/api/cdb_schema/))
* Get JSON Schema of `.cdb.json`, including licenses, tags and content warnings.
* See [JSON Schema Reference](https://json-schema.org/).
* POST `/api/hypertext/`
* Converts HTML or Markdown to [Minetest Markup Language](https://github.com/minetest/minetest/blob/master/doc/lua_api.md#markup-language)
to be used in a `hypertext` formspec element.
* Post data: HTML or Markdown as plain text.
* Content-Type: `text/html` or `text/markdown`.
* Query arguments:
* `formspec_version`: Required, maximum supported formspec version. Ie: 6
* `include_images`: Optional, defaults to true.
* Returns JSON dictionary with following key:
* `head`: markup for suggested styling and custom tags, prepend to the body before displaying.
* `body`: markup for long description.
* `links`: dictionary of anchor name to link URL.
* `images`: dictionary of img name to image URL
* `image_tooltips`: dictionary of img name to tooltip text.

View File

@@ -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 and deprecated packages * `android_default`: currently same as `*, deprecated`. Hides all content warnings, WIP packages, and deprecated packages
* `desktop_default`: currently same as `deprecated`. Hides deprecated packages * `desktop_default`: currently same as `deprecated`. Hides all WIP and deprecated packages
## Content Warnings ## Content Warnings

View File

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

View File

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

View File

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

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 1280x720 pixels). * MUST: Have a high resolution cover image on ContentDB (at least 1280x768 pixels).
It may be shown cropped to 16:9 aspect ratio, or shorter. 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

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

View File

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

View File

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

View File

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

View File

@@ -19,26 +19,15 @@ The filename of the `.conf` file depends on the content type:
* `game.conf` for games. * `game.conf` for games.
* `texture_pack.conf` for texture packs. * `texture_pack.conf` for texture packs.
The `.conf` uses a key-value format, separated using equals. The `.conf` uses a key-value format, separated using equals. Here's a simple example:
Here's a simple example of `mod.conf`, `modpack.conf`, or `texture_pack.conf`:
name = mymod name = mymod
title = My Mod
description = A short description to show in the client. description = A short description to show in the client.
Here's a simple example of `game.conf`:
title = My Game
description = A short description to show in the client.
Note that you should not specify `name` in game.conf.
### Understood values ### Understood values
ContentDB understands the following information: ContentDB understands the following information:
* `title` - A human-readable title.
* `description` - A short description to show in the client. * `description` - A short description to show in the client.
* `depends` - Comma-separated hard dependencies. * `depends` - Comma-separated hard dependencies.
* `optional_depends` - Comma-separated soft dependencies. * `optional_depends` - Comma-separated soft dependencies.
@@ -48,8 +37,6 @@ ContentDB understands the following information:
and for mods only: and for mods only:
* `name` - the mod technical name. * `name` - the mod technical name.
* `supported_games` - List of supported game technical names.
* `unsupported_games` - List of not supported game technical names. Useful to override game support detection.
## .cdb.json ## .cdb.json
@@ -74,10 +61,8 @@ 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.
* `donate_url`: URL to a donation page.
Use `null` or `[]` to unset fields where relevant. Use `null` to unset fields where relevant.
Example: Example:

View File

@@ -2,8 +2,8 @@ title: Ranks and Permissions
## Overview ## Overview
* **New Members** - mostly untrusted, cannot change package metadata or publish releases without approval. * **New Members** - mostly untrusted, cannot change package meta data or publish releases without approval.
* **Members** - Trusted to change the metadata of their own packages', but cannot approve their own packages. * **Members** - Trusted to change the meta data of their own packages', but cannot approve their own packages.
* **Trusted Members** - Same as above, but can approve their own releases. * **Trusted Members** - Same as above, but can approve their own releases.
* **Approvers** - Responsible for approving new packages, screenshots, and releases. * **Approvers** - Responsible for approving new packages, screenshots, and releases.
* **Editors** - Same as above, and can edit any package or release. * **Editors** - Same as above, and can edit any package or release.
@@ -266,7 +266,7 @@ title: Ranks and Permissions
</tr> </tr>
<tr> <tr>
<td>Create Token</td> <td>Create Token</td>
<td></td> <!-- new --> <td></td> <!-- new -->
<td></td> <td></td>
<td></td> <!-- member --> <td></td> <!-- member -->
<td></td> <td></td>

View File

@@ -6,7 +6,7 @@ A webhook is a notification from one service to another. Put simply, a webhook
is used to notify ContentDB that the git repository has changed. is used to notify ContentDB that the git repository has changed.
ContentDB offers the ability to automatically create releases using webhooks ContentDB offers the ability to automatically create releases using webhooks
from either GitHub or GitLab. If you're not using either of those services, from either Github or Gitlab. If you're not using either of those services,
you can also use the [API](../api) to create releases. you can also use the [API](../api) to create releases.
ContentDB also offers the ability to poll a Git repo and check for updates ContentDB also offers the ability to poll a Git repo and check for updates

View File

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

View File

@@ -11,8 +11,6 @@ the listings and to combat abuse.
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup> * **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup> * **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup> * **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
* **Don't manipulate package placement using reviews or downloads.** <sup>6</sup>
* **Screenshots must not be misleading.** <sup>7</sup>
* **The ContentDB admin reserves the right to remove packages for any reason**, * **The ContentDB admin reserves the right to remove packages for any reason**,
including ones not covered by this document, and to ban users who abuse including ones not covered by this document, and to ban users who abuse
this service. <sup>1</sup> this service. <sup>1</sup>
@@ -34,6 +32,10 @@ If in doubt at what this means, [contact us by raising a report](/report/).
Mature content is permitted providing that it is labelled correctly. Mature content is permitted providing that it is labelled correctly.
See [Content Flags](/help/content_flags/). See [Content Flags](/help/content_flags/).
The submission of malware is strictly prohibited. This includes software that
does not do as it advertises, for example, if it posts telemetry without stating
clearly that it does in the package meta.
### 2.2. State of Completion ### 2.2. State of Completion
ContentDB should only currently contain playable content - content which is ContentDB should only currently contain playable content - content which is
@@ -81,14 +83,30 @@ should be possible to use the new mod as a drop-in replacement.
We reserve the right to decide whether a mod counts as a fork or We reserve the right to decide whether a mod counts as a fork or
reimplementation of the mod that owns the name. reimplementation of the mod that owns the name.
### 3.3. Reserved namespaces
A reserved namespace is a prefix to a package name like `abc_`, where any package names that begin with that prefix
need permission from the namespace owner to be used.
You can request a namespace be reserved by opening a thread on a package that uses it. A namespace must be in active
use and must be specific enough to have low risk of conflict - for example, `mobs` cannot be a reserved namespace.
The package must also be deemed "serious" enough to get a reservation - larger and more longterm projects are more
likely to qualify.
Mod names used in packages posted on CDB or the forums before 2022-01-21 are exempt.
List of reserved namespaces:
* `nc_` is reserved by NodeCore.
* `ikea_` is reserved by IKEA.
## 4. Licenses ## 4. Licenses
### 4.1. Allowed Licenses ### 4.1. Allowed Licenses
Please ensure that you correctly credit any resources (code, assets, or otherwise) Please ensure that you correctly credit any resources (code, assets, or otherwise)
that you have used in your package. For help on doing copyright correctly, see that you have used in your package.
the [Copyright help page](/help/copyright/).
**The use of licenses that do not allow derivatives or redistribution is not **The use of licenses that do not allow derivatives or redistribution is not
permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses. permitted. This includes CC-ND (No-Derivatives) and lots of closed source licenses.
@@ -98,8 +116,7 @@ of the content on servers or singleplayer is also not permitted.**
However, closed sourced licenses are allowed if they allow the above. However, closed sourced licenses are allowed if they allow the above.
If the license you use is not on the list then please select "Other", and we'll If the license you use is not on the list then please select "Other", and we'll
get around to adding it. We tend to reject custom/untested licenses, and get around to adding it.
reserve the right to decide whether a license should be included.
Please note that the definitions of "free" and "non-free" is the same as that Please note that the definitions of "free" and "non-free" is the same as that
of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html). of the [Free Software Foundation](https://www.gnu.org/philosophy/free-sw.en.html).
@@ -127,7 +144,7 @@ Public domain is not a valid license in many countries, please use CC0 or MIT in
## 5. Promotions and Advertisements (inc. asking for donations) ## 5. Promotions and Advertisements (inc. asking for donations)
You may not place any promotions or advertisements in any metadata including You may not place any promotions or advertisements in any meta data including
screenshots. This includes asking for donations, promoting online shops, screenshots. This includes asking for donations, promoting online shops,
or linking to personal websites and social media. Please instead use the or linking to personal websites and social media. Please instead use the
fields provided on your user profile page to place links to websites and fields provided on your user profile page to place links to websites and
@@ -151,52 +168,6 @@ You must not attempt to unfairly manipulate your package's ranking, whether by r
Doing so may result in temporary or permanent suspension from ContentDB. Doing so may result in temporary or permanent suspension from ContentDB.
## 7. Screenshots ## 7. Reporting Violations
1. **Screenshots must not violate copyright.** You should have the rights to the
screenshot.
2. **Screenshots must depict the actual content of the package in some way, and
not be misleading.**
Do not use idealized mockups or blender concept renders if they do not
accurately reflect in-game appearance.
Content in screenshots that is prominently displayed or "focal" should be
either present in, or interact with, the package in some way. These can
include things in other packages if they have a dependency relationship
(either way), or if the submitted package in some way enhances, extends, or
alters that content.
Unrelated package content can be allowed to show what the package content
will look like in a typical/realistic game scene, but should be "in the
background" only as far as possible.
3. **Screenshots must only contain content appropriate for the Content Warnings of
the package.**
4. **Screenshots should be MOSTLY in-game screenshots, if applicable.** Some
alterations on in-game screenshots are okay, such as collages, added text,
some reasonable compositing.
Don't just use one of the textures from the package; show it in-situ as it
actually looks in the game.
5. **Packages should have a screenshot when reasonably applicable.**
6. **Screenshots should be of reasonable dimensions.** We recommend using 1920x1080.
## 8. Security
The submission of malware is strictly prohibited. This includes software that
does not do as it advertises, for example, if it posts telemetry without stating
clearly that it does in the package meta.
Packages must not ask that users disable mod security (`secure.enable_security`).
Instead, they should use the insecure environment API.
## 9. Reporting Violations
Please click "Report" on the package page. Please click "Report" on the package page.

View File

@@ -1,8 +1,5 @@
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:**
@@ -12,14 +9,13 @@ Last Updated: 2022-01-23
* 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, threads, and reviews * Comments and threads
* 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,
@@ -34,12 +30,10 @@ 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 and privacy policy updates. * Provide essential system messages, such as password resets.
* 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
@@ -49,7 +43,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.
* Email addresses are visible to moderators and the admin. * Emails 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.
@@ -58,12 +52,11 @@ 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 Germany. The ContentDB production server is currently located in Canada.
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.
@@ -79,7 +72,7 @@ requested. See below.
## Removal Requests ## Removal Requests
Please [raise a report](/report/?anon=0) if you Please [raise a report](https://content.minetest.net/report/?anon=0) if you
wish to remove your personal information. wish to remove your personal information.
ContentDB keeps a record of each username and forum topic on the forums, ContentDB keeps a record of each username and forum topic on the forums,

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,21 +14,18 @@
# 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 re
import typing
import re
import validators import validators
from flask_babel import lazy_gettext, LazyString from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError 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 add_audit_log, has_blocked_domains, diff_dictionaries, describe_difference from app.utils import addAuditLog
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: typing.Union[str, LazyString]): def check(cond: bool, msg: str):
if not cond: if not cond:
raise LogicError(400, msg) raise LogicError(400, msg)
@@ -64,8 +61,6 @@ ALLOWED_FIELDS = {
"issue_tracker": str, "issue_tracker": str,
"issueTracker": str, "issueTracker": str,
"forums": int, "forums": int,
"video_url": str,
"donate_url": str,
} }
ALIASES = { ALIASES = {
@@ -108,16 +103,12 @@ def validate(data: dict):
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict, def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
reason: str = None): reason: str = None):
if not package.check_perm(user, Permission.EDIT_PACKAGE): if not package.checkPerm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, lazy_gettext("You don't have permission to edit this package")) raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
if "name" in data and package.name != data["name"] and \ if "name" in data and package.name != data["name"] and \
not package.check_perm(user, Permission.CHANGE_NAME): not package.checkPerm(user, Permission.CHANGE_NAME):
raise LogicError(403, lazy_gettext("You don't have permission to change the package name")) raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
before_dict = None
if not was_new:
before_dict = package.as_dict("/")
for alias, to in ALIASES.items(): for alias, to in ALIASES.items():
if alias in data: if alias in data:
@@ -125,11 +116,6 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
validate(data) validate(data)
for field in ["short_desc", "desc", "website", "issueTracker", "repo", "video_url", "donate_url"]:
if field in data and has_blocked_domains(data[field], user.username,
f"{field} of {package.get_id()}"):
raise LogicError(403, lazy_gettext("Linking to blocked sites is not allowed"))
if "type" in data: if "type" in data:
data["type"] = PackageType.coerce(data["type"]) data["type"] = PackageType.coerce(data["type"])
@@ -142,13 +128,8 @@ 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", "video_url", "donate_url"]: "repo", "website", "issueTracker", "forums"]:
if key in data: if key in data:
setattr(package, key, data[key]) setattr(package, key, data[key])
@@ -162,7 +143,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if "tags" in data: if "tags" in data:
old_tags = list(package.tags) old_tags = list(package.tags)
package.tags.clear() package.tags.clear()
for tag_id in (data["tags"] or []): for tag_id in data["tags"]:
if is_int(tag_id): if is_int(tag_id):
tag = Tag.query.get(tag_id) tag = Tag.query.get(tag_id)
else: else:
@@ -170,11 +151,22 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if tag is None: if tag is None:
raise LogicError(400, "Unknown tag: " + tag_id) raise LogicError(400, "Unknown tag: " + tag_id)
if not was_web and tag.is_protected:
break
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
package.tags.append(tag) package.tags.append(tag)
if not was_web:
for tag in old_tags:
if tag.is_protected:
package.tags.append(tag)
if "content_warnings" in data: if "content_warnings" in data:
package.content_warnings.clear() package.content_warnings.clear()
for warning_id in (data["content_warnings"] or []): for warning_id in data["content_warnings"]:
if is_int(warning_id): if is_int(warning_id):
package.content_warnings.append(ContentWarning.query.get(warning_id)) package.content_warnings.append(ContentWarning.query.get(warning_id))
else: else:
@@ -184,20 +176,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
package.content_warnings.append(warning) package.content_warnings.append(warning)
if not was_new: if not was_new:
after_dict = package.as_dict("/")
diff = diff_dictionaries(before_dict, after_dict)
if reason is None: if reason is None:
msg = "Edited {}".format(package.title) msg = "Edited {}".format(package.title)
else: else:
msg = "Edited {} ({})".format(package.title, reason) msg = "Edited {} ({})".format(package.title, reason)
diff_desc = describe_difference(diff, 100 - len(msg) - 3) if diff else None
if diff_desc:
msg += " [" + diff_desc + "]"
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
add_audit_log(severity, user, msg, package.get_url("packages.view"), package, json.dumps(diff, indent=4)) addAuditLog(severity, user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()

View File

@@ -14,8 +14,8 @@
# 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 datetime
import re import datetime, re
from celery import uuid from celery import uuid
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
@@ -23,13 +23,13 @@ from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file from app.logic.uploads import upload_file
from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease
from app.tasks.importtasks import make_vcs_release, check_zip_release from app.tasks.importtasks import makeVCSRelease, checkZipRelease
from app.utils import AuditSeverity, add_audit_log, nonempty_or_none from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
def check_can_create_release(user: User, package: Package): def check_can_create_release(user: User, package: Package):
if not package.check_perm(user, Permission.MAKE_RELEASE): if not package.checkPerm(user, Permission.MAKE_RELEASE):
raise LogicError(403, lazy_gettext("You don't have permission to make releases")) raise LogicError(403, lazy_gettext("You do not have permission to make releases"))
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count() count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()
@@ -54,11 +54,11 @@ def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
msg = "Created release {}".format(rel.title) msg = "Created release {}".format(rel.title)
else: else:
msg = "Created release {} ({})".format(rel.title, reason) msg = "Created release {} ({})".format(rel.title, reason)
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
make_vcs_release.apply_async((rel.id, nonempty_or_none(ref)), task_id=rel.task_id) makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id)
return rel return rel
@@ -89,10 +89,10 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
msg = "Created release {}".format(rel.title) msg = "Created release {}".format(rel.title)
else: else:
msg = "Created release {} ({})".format(rel.title, reason) msg = "Created release {} ({})".format(rel.title, reason)
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
check_zip_release.apply_async((rel.id, uploaded_path), task_id=rel.task_id) checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id)
return rel return rel

View File

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

View File

@@ -1,19 +1,3 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime, json import datetime, json
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
@@ -21,11 +5,10 @@ from flask_babel import lazy_gettext
from app.logic.LogicError import LogicError 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 add_notification, add_audit_log from app.utils import addNotification, addAuditLog
from app.utils.image import get_image_size
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None): def do_create_screenshot(user: User, package: Package, title: str, file, 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:
@@ -42,15 +25,8 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
ss.package = package ss.package = package
ss.title = title or "Untitled" ss.title = title or "Untitled"
ss.url = uploaded_url ss.url = uploaded_url
ss.approved = package.check_perm(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:
@@ -58,15 +34,11 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
else: else:
msg = "Created screenshot {} ({})".format(ss.title, reason) msg = "Created screenshot {} ({})".format(ss.title, reason)
add_notification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.get_url("packages.view"), package) addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package)
add_audit_log(AuditSeverity.NORMAL, user, msg, package.get_url("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package)
db.session.commit() db.session.commit()
if is_cover_image:
package.cover_image = ss
db.session.commit()
return ss return ss
@@ -80,24 +52,9 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
try: try:
lookup[int(ss_id)].order = counter lookup[int(ss_id)].order = counter
counter += 1 counter += 1
except KeyError: except KeyError as e:
raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id)) raise LogicError(400, "Unable to find screenshot with id={}".format(ss_id))
except (ValueError, TypeError): except (ValueError, TypeError) as e:
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):
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
for screenshot in package.screenshots.all():
if screenshot.id == cover_image:
package.cover_image = screenshot
db.session.commit()
return
raise LogicError(400, "Unable to find screenshot")

View File

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

View File

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

View File

@@ -1,19 +1,3 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from functools import partial from functools import partial
import bleach import bleach
@@ -21,12 +5,10 @@ from bleach import Cleaner
from bleach.linkifier import LinkifyFilter from bleach.linkifier import LinkifyFilter
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from markdown import Markdown from markdown import Markdown
from flask import url_for from flask import Markup, url_for
from jinja2.utils import markupsafe
from markdown.extensions import Extension from markdown.extensions import Extension
from markdown.inlinepatterns import SimpleTagInlineProcessor from markdown.inlinepatterns import SimpleTagInlineProcessor
from markdown.inlinepatterns import Pattern from markdown.inlinepatterns import Pattern
from markdown.extensions.codehilite import CodeHiliteExtension
from xml.etree import ElementTree from xml.etree import ElementTree
# Based on # Based on
@@ -34,7 +16,7 @@ from xml.etree import ElementTree
# #
# License: MIT # License: MIT
ALLOWED_TAGS = { ALLOWED_TAGS = [
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "hr",
"ul", "ol", "li", "ul", "ol", "li",
"p", "p",
@@ -48,7 +30,7 @@ ALLOWED_TAGS = {
"img", "img",
"table", "thead", "tbody", "tr", "th", "td", "table", "thead", "tbody", "tr", "th", "td",
"div", "span", "del", "s", "div", "span", "del", "s",
} ]
ALLOWED_CSS = [ ALLOWED_CSS = [
"highlight", "codehilite", "highlight", "codehilite",
@@ -76,19 +58,11 @@ ALLOWED_ATTRIBUTES = {
"span": allow_class, "span": allow_class,
} }
ALLOWED_PROTOCOLS = {"http", "https", "mailto"} ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
md = None md = None
def linker_callback(attrs, new=False):
if new:
text = attrs.get("_text")
if not (text.startswith("http://") or text.startswith("https://")):
return None
return attrs
def render_markdown(source): def render_markdown(source):
html = md.convert(source) html = md.convert(source)
@@ -96,9 +70,7 @@ def render_markdown(source):
tags=ALLOWED_TAGS, tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES, attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_PROTOCOLS, protocols=ALLOWED_PROTOCOLS,
filters=[partial(LinkifyFilter, filters=[partial(LinkifyFilter, callbacks=bleach.linkifier.DEFAULT_CALLBACKS)])
callbacks=[linker_callback] + bleach.linkifier.DEFAULT_CALLBACKS,
skip_tags={"pre", "code"})])
return cleaner.clean(html) return cleaner.clean(html)
@@ -156,10 +128,13 @@ class MentionExtension(Extension):
md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20) md.inlinePatterns.register(MentionPattern(self.getConfigs(), md), "mention", 20)
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", CodeHiliteExtension(guess_lang=False), "toc", DelInsExtension(), MentionExtension()] MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "codehilite", "toc", DelInsExtension(), MentionExtension()]
MARKDOWN_EXTENSION_CONFIG = { MARKDOWN_EXTENSION_CONFIG = {
"fenced_code": {}, "fenced_code": {},
"tables": {} "tables": {},
"codehilite": {
"guess_lang": False,
}
} }
@@ -168,11 +143,11 @@ def init_markdown(app):
md = Markdown(extensions=MARKDOWN_EXTENSIONS, md = Markdown(extensions=MARKDOWN_EXTENSIONS,
extension_configs=MARKDOWN_EXTENSION_CONFIG, extension_configs=MARKDOWN_EXTENSION_CONFIG,
output_format="html") output_format="html5")
@app.template_filter() @app.template_filter()
def markdown(source): def markdown(source):
return markupsafe.Markup(render_markdown(source)) return Markup(render_markdown(source))
def get_headings(html: str): def get_headings(html: str):

View File

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

View File

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

View File

@@ -21,14 +21,11 @@ import enum
from flask import url_for from flask import url_for
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
from flask_sqlalchemy import BaseQuery from flask_sqlalchemy import BaseQuery
from sqlalchemy import or_
from sqlalchemy_searchable import SearchQueryMixin from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType from sqlalchemy_utils.types import TSVectorType
from sqlalchemy.dialects.postgresql import insert
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank, User
from app import app
class PackageQuery(BaseQuery, SearchQueryMixin): class PackageQuery(BaseQuery, SearchQueryMixin):
@@ -55,7 +52,7 @@ class PackageType(enum.Enum):
GAME = "Game" GAME = "Game"
TXP = "Texture Pack" TXP = "Texture Pack"
def to_name(self): def toName(self):
return self.name.lower() return self.name.lower()
def __str__(self): def __str__(self):
@@ -104,7 +101,7 @@ class PackageDevState(enum.Enum):
DEPRECATED = "Deprecated" DEPRECATED = "Deprecated"
LOOKING_FOR_MAINTAINER = "Looking for Maintainer" LOOKING_FOR_MAINTAINER = "Looking for Maintainer"
def to_name(self): def toName(self):
return self.name.lower() return self.name.lower()
def __str__(self): def __str__(self):
@@ -161,7 +158,7 @@ class PackageState(enum.Enum):
APPROVED = "Approved" APPROVED = "Approved"
DELETED = "Deleted" DELETED = "Deleted"
def to_name(self): def toName(self):
return self.name.lower() return self.name.lower()
def verb(self): def verb(self):
@@ -215,6 +212,30 @@ PACKAGE_STATE_FLOW = {
} }
class PackagePropertyKey(enum.Enum):
name = "Name"
title = "Title"
short_desc = "Short Description"
desc = "Description"
type = "Type"
license = "License"
media_license = "Media License"
tags = "Tags"
provides = "Provides"
repo = "Repository"
website = "Website"
issueTracker = "Issue Tracker"
forums = "Forum Topic ID"
def convert(self, value):
if self == PackagePropertyKey.tags:
return ",".join([t.title for t in value])
elif self == PackagePropertyKey.provides:
return ",".join([t.name for t in value])
else:
return str(value)
PackageProvides = db.Table("provides", PackageProvides = db.Table("provides",
db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True), db.Column("package_id", db.Integer, db.ForeignKey("package.id"), primary_key=True),
db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True) db.Column("metapackage_id", db.Integer, db.ForeignKey("meta_package.id"), primary_key=True)
@@ -272,7 +293,7 @@ class Dependency(db.Model):
else: else:
raise Exception("Either meta or package must be given, but not both!") raise Exception("Either meta or package must be given, but not both!")
def get_name(self): def getName(self):
if self.meta_package: if self.meta_package:
return self.meta_package.name return self.meta_package.name
elif self.package: elif self.package:
@@ -322,27 +343,6 @@ 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, confidence, supports):
self.package = package
self.game = game
self.confidence = confidence
self.supports = supports
class Package(db.Model): class Package(db.Model):
query_class = PackageQuery query_class = PackageQuery
@@ -360,20 +360,16 @@ class Package(db.Model):
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
approved_at = db.Column(db.DateTime, nullable=True, default=None) approved_at = db.Column(db.DateTime, nullable=True, default=None)
name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$' AND name != '_game'") name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
search_vector = db.Column(TSVectorType("name", "title", "short_desc", "desc", search_vector = db.Column(TSVectorType("name", "title", "short_desc", "desc",
weights={ "name": "A", "title": "B", "short_desc": "C" })) weights={ "name": "A", "title": "B", "short_desc": "C", "desc": "D" }))
__table_args__ = (db.UniqueConstraint("author_id", "name", name="_package_uc"),)
license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1) license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
license = db.relationship("License", foreign_keys=[license_id]) license = db.relationship("License", foreign_keys=[license_id])
media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1) media_license_id = db.Column(db.Integer, db.ForeignKey("license.id"), nullable=False, default=1)
media_license = db.relationship("License", foreign_keys=[media_license_id]) media_license = db.relationship("License", foreign_keys=[media_license_id])
ck_license_txp = db.CheckConstraint("type != 'TXP' OR license_id = media_license_id")
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP) state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None) dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None)
@@ -386,36 +382,18 @@ class Package(db.Model):
downloads = db.Column(db.Integer, nullable=False, default=0) downloads = db.Column(db.Integer, nullable=False, default=0)
review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None) review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None)
review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id], review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id], back_populates="is_review_thread")
back_populates="is_review_thread", post_update=True)
# Supports all games by default, may have unsupported games
supports_all_games = db.Column(db.Boolean, nullable=False, default=False)
# Downloads # Downloads
repo = db.Column(db.String(200), nullable=True) repo = db.Column(db.String(200), nullable=True)
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)
donate_url = db.Column(db.String(200), nullable=True, default=None)
@property
def donate_url_actual(self):
return self.donate_url or self.author.donate_url
enable_game_support_detection = db.Column(db.Boolean, nullable=False, default=True)
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")
@@ -427,7 +405,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"), viewonly=True, lazy=True, order_by=db.asc("package_screenshot_order"),
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)
@@ -457,9 +435,6 @@ class Package(db.Model):
aliases = db.relationship("PackageAlias", foreign_keys="PackageAlias.package_id", aliases = db.relationship("PackageAlias", foreign_keys="PackageAlias.package_id",
back_populates="package", cascade="all, delete, delete-orphan") back_populates="package", cascade="all, delete, delete-orphan")
daily_stats = db.relationship("PackageDailyStats", foreign_keys="PackageDailyStats.package_id",
back_populates="package", cascade="all, delete, delete-orphan", lazy="dynamic")
def __init__(self, package=None): def __init__(self, package=None):
if package is None: if package is None:
return return
@@ -470,68 +445,42 @@ class Package(db.Model):
self.maintainers.append(self.author) self.maintainers.append(self.author)
@classmethod for e in PackagePropertyKey:
def get_by_key(cls, key): setattr(self, e.name, getattr(package, e.name))
parts = key.split("/")
if len(parts) != 2:
return None
name = parts[1] def getId(self):
if name.endswith("_game"):
name = name[:-5]
return Package.query.filter(
or_(Package.name == name, Package.name == name + "_game"),
Package.author.has(username=parts[0])).first()
def get_id(self):
return "{}/{}".format(self.author.username, self.name) return "{}/{}".format(self.author.username, self.name)
def get_sorted_dependencies(self, is_hard=None): def getIsFOSS(self):
return self.license.is_foss and self.media_license.is_foss
def getSortedDependencies(self, is_hard=None):
query = self.dependencies query = self.dependencies
if is_hard is not None: if is_hard is not None:
query = query.filter_by(optional=not is_hard) query = query.filter_by(optional=not is_hard)
deps = query.all() deps = query.all()
deps.sort(key=lambda x: x.get_name()) deps.sort(key = lambda x: x.getName())
return deps return deps
def get_sorted_hard_dependencies(self): def getSortedHardDependencies(self):
return self.get_sorted_dependencies(True) return self.getSortedDependencies(True)
def get_sorted_optional_dependencies(self): def getSortedOptionalDependencies(self):
return self.get_sorted_dependencies(False) return self.getSortedDependencies(False)
def get_sorted_game_support(self): def getAsDictionaryKey(self):
query = self.supported_games.filter(PackageGameSupport.game.has(state=PackageState.APPROVED))
supported = query.all()
supported.sort(key=lambda x: -(x.game.score + 100000*x.confidence))
return supported
def get_sorted_game_support_pair(self):
supported = self.get_sorted_game_support()
return [
[x for x in supported if x.supports],
[x for x in supported if not x.supports],
]
def has_game_support_confirmed(self):
return self.supports_all_games or \
self.supported_games.filter(PackageGameSupport.confidence > 1).count() > 0
def as_key_dict(self):
return { return {
"name": self.name, "name": self.name,
"author": self.author.username, "author": self.author.username,
"type": self.type.to_name(), "type": self.type.toName(),
} }
def as_short_dict(self, base_url, version=None, release_id=None, no_load=False): def getAsDictionaryShort(self, base_url, version=None, release_id=None, no_load=False):
tnurl = self.get_thumb_url(1) tnurl = self.getThumbnailURL(1)
if release_id is None and no_load == False: if release_id is None and no_load == False:
release = self.get_download_release(version=version) release = self.getDownloadRelease(version=version)
release_id = release and release.id release_id = release and release.id
short_desc = self.short_desc short_desc = self.short_desc
@@ -543,10 +492,10 @@ class Package(db.Model):
"title": self.title, "title": self.title,
"author": self.author.username, "author": self.author.username,
"short_description": short_desc, "short_description": short_desc,
"type": self.type.to_name(), "type": self.type.toName(),
"release": release_id, "release": release_id,
"thumbnail": (base_url + tnurl) if tnurl is not None else None, "thumbnail": (base_url + tnurl) if tnurl is not None else None,
"aliases": [alias.as_dict() for alias in self.aliases], "aliases": [ alias.getAsDictionary() for alias in self.aliases ],
} }
if not ret["aliases"]: if not ret["aliases"]:
@@ -554,9 +503,9 @@ class Package(db.Model):
return ret return ret
def as_dict(self, base_url, version=None): def getAsDictionary(self, base_url, version=None):
tnurl = self.get_thumb_url(1) tnurl = self.getThumbnailURL(1)
release = self.get_download_release(version=version) release = self.getDownloadRelease(version=version)
return { return {
"author": self.author.username, "author": self.author.username,
"maintainers": [x.username for x in self.maintainers], "maintainers": [x.username for x in self.maintainers],
@@ -568,7 +517,7 @@ class Package(db.Model):
"title": self.title, "title": self.title,
"short_description": self.short_desc, "short_description": self.short_desc,
"long_description": self.desc, "long_description": self.desc,
"type": self.type.to_name(), "type": self.type.toName(),
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"license": self.license.name, "license": self.license.name,
@@ -578,192 +527,184 @@ 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,
"donate_url": self.donate_url_actual,
"tags": sorted([x.name for x in self.tags]), "tags": [x.name for x in self.tags],
"content_warnings": sorted([x.name for x in self.content_warnings]), "content_warnings": [x.name for x in self.content_warnings],
"provides": sorted([x.name for x in self.provides]), "provides": [x.name for x in self.provides],
"thumbnail": (base_url + tnurl) if tnurl is not None else None, "thumbnail": (base_url + tnurl) if tnurl is not None else None,
"screenshots": [base_url + ss.url for ss in self.screenshots], "screenshots": [base_url + ss.url for ss in self.screenshots],
"url": base_url + self.get_url("packages.download"), "url": base_url + self.getURL("packages.download"),
"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.as_short_dict(base_url, version)
} for support in self.supported_games.all()
]
} }
def get_thumb_or_placeholder(self, level=2): def getThumbnailOrPlaceholder(self, level=2):
return self.get_thumb_url(level) or "/static/placeholder.png" return self.getThumbnailURL(level) or "/static/placeholder.png"
def get_thumb_url(self, level=2, abs=False): def getThumbnailURL(self, level=2):
screenshot = self.main_screenshot screenshot = self.main_screenshot
url = screenshot.get_thumb_url(level) if screenshot is not None else None return screenshot.getThumbnailURL(level) if screenshot is not None else None
if abs:
def getMainScreenshotURL(self, absolute=False):
screenshot = self.main_screenshot
if screenshot is None:
return None
if absolute:
from app.utils import abs_url from app.utils import abs_url
return abs_url(url) return abs_url(screenshot.url)
else: else:
return url return screenshot.url
def get_cover_image_url(self): def getURL(self, endpoint, absolute=False, **kwargs):
screenshot = self.cover_image or self.main_screenshot
return screenshot and screenshot.get_thumb_url(4)
def get_url(self, endpoint, absolute=False, **kwargs):
if absolute: if absolute:
from app.utils import abs_url_for from app.utils import abs_url_for
return abs_url_for(endpoint, author=self.author.username, name=self.name, **kwargs) return abs_url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
else: else:
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs) return url_for(endpoint, author=self.author.username, name=self.name, **kwargs)
def get_shield_url(self, type): def getShieldURL(self, type):
from app.utils import abs_url_for from app.utils import abs_url_for
return abs_url_for("packages.shield", return abs_url_for("packages.shield",
author=self.author.username, name=self.name, type=type) author=self.author.username, name=self.name, type=type)
def make_shield(self, type): def makeShield(self, type):
return "[![ContentDB]({})]({})" \ return "[![ContentDB]({})]({})" \
.format(self.get_shield_url(type), self.get_url("packages.view", True)) .format(self.getShieldURL(type), self.getURL("packages.view", True))
def get_set_state_url(self, state): def getSetStateURL(self, state):
if type(state) == str: if type(state) == str:
state = PackageState[state] state = PackageState[state]
elif type(state) != PackageState: elif type(state) != PackageState:
raise Exception("Unknown state given to Package.can_move_to_state()") raise Exception("Unknown state given to Package.canMoveToState()")
return url_for("packages.move_to_state", return url_for("packages.move_to_state",
author=self.author.username, name=self.name, state=state.name.lower()) author=self.author.username, name=self.name, state=state.name.lower())
def get_download_release(self, version=None): def getDownloadRelease(self, version=None):
for rel in self.releases: for rel in self.releases:
if rel.approved and (version is None or if rel.approved and (version is None or
((rel.min_rel is None or rel.min_rel_id <= version.id) and ((rel.min_rel is None or rel.min_rel_id <= version.id) and
(rel.max_rel is None or rel.max_rel_id >= version.id))): (rel.max_rel is None or rel.max_rel_id >= version.id))):
return rel return rel
return None return None
def check_perm(self, user, perm): def checkPerm(self, user, perm):
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to Package.check_perm()")
if perm == Permission.VIEW_PACKAGE:
return self.state == PackageState.APPROVED or self.check_perm(user, Permission.EDIT_PACKAGE)
if not user.is_authenticated: if not user.is_authenticated:
return False return False
is_owner = user == self.author if type(perm) == str:
is_maintainer = is_owner or user.rank.at_least(UserRank.EDITOR) or user in self.maintainers perm = Permission[perm]
is_approver = user.rank.at_least(UserRank.APPROVER) elif type(perm) != Permission:
raise Exception("Unknown permission given to Package.checkPerm()")
if perm == Permission.CREATE_THREAD: isOwner = user == self.author
return user.rank.at_least(UserRank.NEW_MEMBER) isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
isApprover = user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.SEE_PACKAGE:
return self.state == PackageState.APPROVED or isMaintainer or isApprover
elif perm == Permission.CREATE_THREAD:
return user.rank.atLeast(UserRank.MEMBER)
# Members can edit their own packages, and editors can edit any packages # Members can edit their own packages, and editors can edit any packages
elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS: elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
return is_maintainer return isMaintainer
elif perm == Permission.EDIT_PACKAGE: elif perm == Permission.EDIT_PACKAGE:
return is_maintainer and user.rank.at_least(UserRank.NEW_MEMBER) return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
elif perm == Permission.APPROVE_RELEASE: elif perm == Permission.APPROVE_RELEASE:
return (is_maintainer or is_approver) and user.rank.at_least(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER) return (isMaintainer or isApprover) and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
# Anyone can change the package name when not approved # Anyone can change the package name when not approved, but only editors when approved
elif perm == Permission.CHANGE_NAME: elif perm == Permission.CHANGE_NAME:
return not self.approved return not self.approved or user.rank.atLeast(UserRank.EDITOR)
# Editors can change authors and approve new packages # Editors can change authors and approve new packages
elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR: elif perm == Permission.APPROVE_NEW or perm == Permission.CHANGE_AUTHOR:
return is_approver return isApprover
elif perm == Permission.APPROVE_SCREENSHOT: elif perm == Permission.APPROVE_SCREENSHOT:
return (is_maintainer or is_approver) and \ return (isMaintainer or isApprover) and \
user.rank.at_least(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER) user.rank.atLeast(UserRank.TRUSTED_MEMBER if self.approved else UserRank.NEW_MEMBER)
elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE: elif perm == Permission.EDIT_MAINTAINERS or perm == Permission.DELETE_PACKAGE:
return is_owner or user.rank.at_least(UserRank.EDITOR) return isOwner or user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.UNAPPROVE_PACKAGE: elif perm == Permission.UNAPPROVE_PACKAGE:
return is_owner or user.rank.at_least(UserRank.APPROVER) return isOwner or user.rank.atLeast(UserRank.APPROVER)
elif perm == Permission.CHANGE_RELEASE_URL: elif perm == Permission.CHANGE_RELEASE_URL:
return user.rank.at_least(UserRank.MODERATOR) return user.rank.atLeast(UserRank.MODERATOR)
else: else:
raise Exception("Permission {} is not related to packages".format(perm.name)) raise Exception("Permission {} is not related to packages".format(perm.name))
def get_missing_hard_dependencies_query(self): def getMissingHardDependenciesQuery(self):
return MetaPackage.query \ return MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \ .filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(optional=False, depender=self)) \ .filter(MetaPackage.dependencies.any(optional=False, depender=self)) \
.order_by(db.asc(MetaPackage.name)) .order_by(db.asc(MetaPackage.name))
def get_missing_hard_dependencies(self): def getMissingHardDependencies(self):
return [mp.name for mp in self.get_missing_hard_dependencies_query().all()] return [mp.name for mp in self.getMissingHardDependenciesQuery().all()]
def can_move_to_state(self, user, state): def canMoveToState(self, user, state):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if type(state) == str: if type(state) == str:
state = PackageState[state] state = PackageState[state]
elif type(state) != PackageState: elif type(state) != PackageState:
raise Exception("Unknown state given to Package.can_move_to_state()") raise Exception("Unknown state given to Package.canMoveToState()")
if state not in PACKAGE_STATE_FLOW[self.state]: if state not in PACKAGE_STATE_FLOW[self.state]:
return False return False
if state == PackageState.READY_FOR_REVIEW or state == PackageState.APPROVED: if state == PackageState.READY_FOR_REVIEW or state == PackageState.APPROVED:
if state == PackageState.APPROVED and not self.check_perm(user, Permission.APPROVE_NEW): if state == PackageState.APPROVED and not self.checkPerm(user, Permission.APPROVE_NEW):
return False return False
if not (self.check_perm(user, Permission.APPROVE_NEW) or self.check_perm(user, Permission.EDIT_PACKAGE)): if not (self.checkPerm(user, Permission.APPROVE_NEW) or self.checkPerm(user, Permission.EDIT_PACKAGE)):
return False return False
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name): if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
return False return False
if self.get_missing_hard_dependencies_query().count() > 0: if self.getMissingHardDependenciesQuery().count() > 0:
return False return False
needs_screenshot = \ needsScreenshot = \
(self.type == self.type.GAME or self.type == self.type.TXP) and self.screenshots.count() == 0 (self.type == self.type.GAME or self.type == self.type.TXP) and \
self.screenshots.count() == 0
return self.releases.filter(PackageRelease.task_id==None).count() > 0 and not needs_screenshot return self.releases.count() > 0 and not needsScreenshot
elif state == PackageState.CHANGES_NEEDED: elif state == PackageState.CHANGES_NEEDED:
return self.check_perm(user, Permission.APPROVE_NEW) return self.checkPerm(user, Permission.APPROVE_NEW)
elif state == PackageState.WIP: elif state == PackageState.WIP:
return self.check_perm(user, Permission.EDIT_PACKAGE) and \ return self.checkPerm(user, Permission.EDIT_PACKAGE) and \
(user in self.maintainers or user.rank.at_least(UserRank.ADMIN)) (user in self.maintainers or user.rank.atLeast(UserRank.ADMIN))
return True return True
def get_next_states(self, user): def getNextStates(self, user):
states = [] states = []
for state in PackageState: for state in PackageState:
if self.can_move_to_state(user, state): if self.canMoveToState(user, state):
states.append(state) states.append(state)
return states return states
def as_score_dict(self): def getScoreDict(self):
return { return {
"author": self.author.username, "author": self.author.username,
"name": self.name, "name": self.name,
@@ -773,24 +714,16 @@ class Package(db.Model):
"downloads": self.downloads "downloads": self.downloads
} }
def recalculate_score(self): def recalcScore(self):
review_scores = [ 100 * r.as_weight() for r in self.reviews ] review_scores = [ 100 * r.asSign() for r in self.reviews ]
self.score = self.score_downloads + sum(review_scores) self.score = self.score_downloads + sum(review_scores)
def get_conf_file_name(self):
if self.type == PackageType.MOD:
return "mod.conf"
elif self.type == PackageType.TXP:
return "texture_pack.conf"
elif self.type == PackageType.GAME:
return "game.conf"
class MetaPackage(db.Model): class MetaPackage(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False) name = db.Column(db.String(100), unique=True, nullable=False)
dependencies = db.relationship("Dependency", back_populates="meta_package", lazy="dynamic") dependencies = db.relationship("Dependency", back_populates="meta_package", lazy="dynamic")
packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=PackageProvides) packages = db.relationship("Package", lazy="dynamic", back_populates="provides", secondary=PackageProvides)
mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'") mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
@@ -854,7 +787,7 @@ class ContentWarning(db.Model):
regex = re.compile("[^a-z_]") regex = re.compile("[^a-z_]")
self.name = regex.sub("", self.title.lower().replace(" ", "_")) self.name = regex.sub("", self.title.lower().replace(" ", "_"))
def as_dict(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 }
@@ -867,6 +800,7 @@ class Tag(db.Model):
backgroundColor = db.Column(db.String(6), nullable=False) backgroundColor = db.Column(db.String(6), nullable=False)
textColor = db.Column(db.String(6), nullable=False) textColor = db.Column(db.String(6), nullable=False)
views = db.Column(db.Integer, nullable=False, default=0) views = db.Column(db.Integer, nullable=False, default=0)
is_protected = db.Column(db.Boolean, nullable=False, default=False)
packages = db.relationship("Package", back_populates="tags", secondary=Tags) packages = db.relationship("Package", back_populates="tags", secondary=Tags)
@@ -879,14 +813,9 @@ class Tag(db.Model):
regex = re.compile("[^a-z_]") regex = re.compile("[^a-z_]")
self.name = regex.sub("", self.title.lower().replace(" ", "_")) self.name = regex.sub("", self.title.lower().replace(" ", "_"))
def as_dict(self): def getAsDictionary(self):
description = self.description if self.description != "" else None description = self.description if self.description != "" else None
return { return { "name": self.name, "title": self.title, "description": description }
"name": self.name,
"title": self.title,
"description": description,
"views": self.views,
}
class MinetestRelease(db.Model): class MinetestRelease(db.Model):
@@ -898,10 +827,10 @@ class MinetestRelease(db.Model):
self.name = name self.name = name
self.protocol = protocol self.protocol = protocol
def get_actual(self): def getActual(self):
return None if self.name == "None" else self return None if self.name == "None" else self
def as_dict(self): def getAsDictionary(self):
return { return {
"name": self.name, "name": self.name,
"protocol_version": self.protocol, "protocol_version": self.protocol,
@@ -954,11 +883,7 @@ 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 getAsDictionary(self):
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
def as_dict(self):
return { return {
"id": self.id, "id": self.id,
"title": self.title, "title": self.title,
@@ -966,11 +891,11 @@ class PackageRelease(db.Model):
"release_date": self.releaseDate.isoformat(), "release_date": self.releaseDate.isoformat(),
"commit": self.commit_hash, "commit": self.commit_hash,
"downloads": self.downloads, "downloads": self.downloads,
"min_minetest_version": self.min_rel and self.min_rel.as_dict(), "min_minetest_version": self.min_rel and self.min_rel.getAsDictionary(),
"max_minetest_version": self.max_rel and self.max_rel.as_dict(), "max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(),
} }
def as_long_dict(self): def getLongAsDictionary(self):
return { return {
"id": self.id, "id": self.id,
"title": self.title, "title": self.title,
@@ -978,24 +903,24 @@ class PackageRelease(db.Model):
"release_date": self.releaseDate.isoformat(), "release_date": self.releaseDate.isoformat(),
"commit": self.commit_hash, "commit": self.commit_hash,
"downloads": self.downloads, "downloads": self.downloads,
"min_minetest_version": self.min_rel and self.min_rel.as_dict(), "min_minetest_version": self.min_rel and self.min_rel.getAsDictionary(),
"max_minetest_version": self.max_rel and self.max_rel.as_dict(), "max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(),
"package": self.package.as_key_dict() "package": self.package.getAsDictionaryKey()
} }
def get_edit_url(self): def getEditURL(self):
return url_for("packages.edit_release", return url_for("packages.edit_release",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name, name=self.package.name,
id=self.id) id=self.id)
def get_delete_url(self): def getDeleteURL(self):
return url_for("packages.delete_release", return url_for("packages.delete_release",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name, name=self.package.name,
id=self.id) id=self.id)
def get_download_url(self): def getDownloadURL(self):
return url_for("packages.download_release", return url_for("packages.download_release",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name, name=self.package.name,
@@ -1004,11 +929,11 @@ class PackageRelease(db.Model):
def __init__(self): def __init__(self):
self.releaseDate = datetime.datetime.now() self.releaseDate = datetime.datetime.now()
def get_download_filename(self): def getDownloadFileName(self):
return f"{self.package.name}_{self.id}.zip" return f"{self.package.name}_{self.id}.zip"
def approve(self, user): def approve(self, user):
if not self.check_perm(user, Permission.APPROVE_RELEASE): if not self.checkPerm(user, Permission.APPROVE_RELEASE):
return False return False
if self.approved: if self.approved:
@@ -1024,22 +949,22 @@ class PackageRelease(db.Model):
return True return True
def check_perm(self, user, perm): def checkPerm(self, user, perm):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if type(perm) == str: if type(perm) == str:
perm = Permission[perm] perm = Permission[perm]
elif type(perm) != Permission: elif type(perm) != Permission:
raise Exception("Unknown permission given to PackageRelease.check_perm()") raise Exception("Unknown permission given to PackageRelease.checkPerm()")
is_maintainer = user == self.package.author or user in self.package.maintainers isMaintainer = user == self.package.author or user in self.package.maintainers
if perm == Permission.DELETE_RELEASE: if perm == Permission.DELETE_RELEASE:
if user.rank.at_least(UserRank.ADMIN): if user.rank.atLeast(UserRank.ADMIN):
return True return True
if not (is_maintainer or user.rank.at_least(UserRank.EDITOR)): if not (isMaintainer or user.rank.atLeast(UserRank.EDITOR)):
return False return False
if not self.package.approved or self.task_id is not None: if not self.package.approved or self.task_id is not None:
@@ -1051,17 +976,14 @@ class PackageRelease(db.Model):
return count > 0 return count > 0
elif perm == Permission.APPROVE_RELEASE: elif perm == Permission.APPROVE_RELEASE:
return user.rank.at_least(UserRank.APPROVER) or \ return user.rank.atLeast(UserRank.APPROVER) or \
(is_maintainer and user.rank.at_least( (isMaintainer and user.rank.atLeast(
UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)) UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER))
else: else:
raise Exception("Permission {} is not related to releases".format(perm.name)) raise Exception("Permission {} is not related to releases".format(perm.name))
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)
@@ -1073,48 +995,29 @@ 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) def getEditURL(self):
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 get_edit_url(self):
return url_for("packages.edit_screenshot", return url_for("packages.edit_screenshot",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name, name=self.package.name,
id=self.id) id=self.id)
def get_delete_url(self): def getDeleteURL(self):
return url_for("packages.delete_screenshot", return url_for("packages.delete_screenshot",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name, name=self.package.name,
id=self.id) id=self.id)
def get_thumb_url(self, level=2): def getThumbnailURL(self, level=2):
return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level)) return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level))
def as_dict(self, base_url=""): def getAsDictionary(self, base_url=""):
return { return {
"id": self.id, "id": self.id,
"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,
} }
@@ -1122,7 +1025,7 @@ class PackageUpdateTrigger(enum.Enum):
COMMIT = "New Commit" COMMIT = "New Commit"
TAG = "New Tag" TAG = "New Tag"
def to_name(self): def toName(self):
return self.name.lower() return self.name.lower()
def __str__(self): def __str__(self):
@@ -1185,7 +1088,7 @@ class PackageUpdateConfig(db.Model):
return self.last_tag or self.last_commit return self.last_tag or self.last_commit
def get_create_release_url(self): def get_create_release_url(self):
return self.package.get_url("packages.create_release", title=self.get_title(), ref=self.get_ref()) return self.package.getURL("packages.create_release", title=self.get_title(), ref=self.get_ref())
class PackageAlias(db.Model): class PackageAlias(db.Model):
@@ -1201,56 +1104,9 @@ class PackageAlias(db.Model):
self.author = author self.author = author
self.name = name self.name = name
def get_edit_url(self): def getEditURL(self):
return url_for("packages.alias_create_edit", author=self.package.author.username, return url_for("packages.alias_create_edit", author=self.package.author.username,
name=self.package.name, alias_id=self.id) name=self.package.name, alias_id=self.id)
def as_dict(self): def getAsDictionary(self):
return f"{self.author}/{self.name}" return f"{self.author}/{self.name}"
class PackageDailyStats(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), primary_key=True)
package = db.relationship("Package", back_populates="daily_stats", foreign_keys=[package_id])
date = db.Column(db.Date, primary_key=True)
platform_minetest = db.Column(db.Integer, nullable=False, default=0)
platform_other = db.Column(db.Integer, nullable=False, default=0)
reason_new = db.Column(db.Integer, nullable=False, default=0)
reason_dependency = db.Column(db.Integer, nullable=False, default=0)
reason_update = db.Column(db.Integer, nullable=False, default=0)
@staticmethod
def update(package: Package, is_minetest: bool, reason: str):
date = datetime.datetime.utcnow().date()
to_update = dict()
kwargs = {
"package_id": package.id, "date": date
}
field_platform = "platform_minetest" if is_minetest else "platform_other"
to_update[field_platform] = getattr(PackageDailyStats, field_platform) + 1
kwargs[field_platform] = 1
field_reason = None
if reason == "new":
field_reason = "reason_new"
elif reason == "dependency":
field_reason = "reason_dependency"
elif reason == "update":
field_reason = "reason_update"
if field_reason:
to_update[field_reason] = getattr(PackageDailyStats, field_reason) + 1
kwargs[field_reason] = 1
stmt = insert(PackageDailyStats).values(**kwargs)
stmt = stmt.on_conflict_do_update(
index_elements=[PackageDailyStats.package_id, PackageDailyStats.date],
set_=to_update
)
conn = db.session.connection()
conn.execute(stmt)

View File

@@ -20,7 +20,7 @@ from typing import Tuple, List
from flask import url_for from flask import url_for
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank
from .packages import Package from .packages import Package
watchers = db.Table("watchers", watchers = db.Table("watchers",
@@ -55,76 +55,58 @@ class Thread(db.Model):
watchers = db.relationship("User", secondary=watchers, backref="watching") watchers = db.relationship("User", secondary=watchers, backref="watching")
first_reply = db.relationship("ThreadReply", uselist=False, foreign_keys="ThreadReply.thread_id",
lazy=True, order_by=db.asc("id"), viewonly=True,
primaryjoin="Thread.id==ThreadReply.thread_id")
def get_description(self): def get_description(self):
comment = self.first_reply.comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ") comment = self.replies[0].comment.replace("\r\n", " ").replace("\n", " ").replace(" ", " ")
if len(comment) > 100: if len(comment) > 100:
return comment[:97] + "..." return comment[:97] + "..."
else: else:
return comment return comment
def get_view_url(self, absolute=False): def getViewURL(self, absolute=False):
if absolute: if absolute:
from app.utils import abs_url_for from ..utils import abs_url_for
return abs_url_for("threads.view", id=self.id) return abs_url_for("threads.view", id=self.id)
else: else:
return url_for("threads.view", id=self.id, _external=False) return url_for("threads.view", id=self.id, _external=False)
def get_subscribe_url(self): def getSubscribeURL(self):
return url_for("threads.subscribe", id=self.id) return url_for("threads.subscribe", id=self.id)
def get_unsubscribe_url(self): def getUnsubscribeURL(self):
return url_for("threads.unsubscribe", id=self.id) return url_for("threads.unsubscribe", id=self.id)
def check_perm(self, user, perm): def checkPerm(self, user, perm):
if not user.is_authenticated:
return perm == Permission.SEE_THREAD and not self.private
if type(perm) == str: if type(perm) == str:
perm = Permission[perm] perm = Permission[perm]
elif type(perm) != Permission: elif type(perm) != Permission:
raise Exception("Unknown permission given to Thread.check_perm()") raise Exception("Unknown permission given to Thread.checkPerm()")
if not user.is_authenticated:
return perm == Permission.SEE_THREAD and not self.private
isMaintainer = user == self.author or (self.package is not None and self.package.author == user) isMaintainer = user == self.author or (self.package is not None and self.package.author == user)
if self.package: if self.package:
isMaintainer = isMaintainer or user in self.package.maintainers isMaintainer = isMaintainer or user in self.package.maintainers
canSee = not self.private or isMaintainer or user.rank.at_least(UserRank.APPROVER) or user in self.watchers canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.SEE_THREAD: if perm == Permission.SEE_THREAD:
return canSee return canSee
elif perm == Permission.COMMENT_THREAD: elif perm == Permission.COMMENT_THREAD:
return canSee and (not self.locked or user.rank.at_least(UserRank.MODERATOR)) return canSee and (not self.locked or user.rank.atLeast(UserRank.MODERATOR))
elif perm == Permission.LOCK_THREAD: elif perm == Permission.LOCK_THREAD:
return user.rank.at_least(UserRank.MODERATOR) return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.DELETE_THREAD: elif perm == Permission.DELETE_THREAD:
from app.utils.models import get_system_user from app.utils.models import get_system_user
return (self.author == get_system_user() and self.package and return (self.author == get_system_user() and self.package and
user in self.package.maintainers) or user.rank.at_least(UserRank.MODERATOR) user in self.package.maintainers) or user.rank.atLeast(UserRank.MODERATOR)
else: else:
raise Exception("Permission {} is not related to threads".format(perm.name)) raise Exception("Permission {} is not related to threads".format(perm.name))
def get_visible_to(self) -> list[User]:
retval = {
self.author.username: self.author
}
for user in self.watchers:
retval[user.username] = user
if self.package:
for user in self.package.maintainers:
retval[user.username] = user
return list(retval.values())
def get_latest_reply(self): def get_latest_reply(self):
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first() return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
@@ -140,27 +122,25 @@ class ThreadReply(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="replies", foreign_keys=[author_id]) author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
is_status_update = db.Column(db.Boolean, server_default="0", nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def get_url(self, absolute=False): def get_url(self):
return self.thread.get_view_url(absolute) + "#reply-" + str(self.id) return url_for('threads.view', id=self.thread.id) + "#reply-" + str(self.id)
def check_perm(self, user, perm): def checkPerm(self, user, perm):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if type(perm) == str: if type(perm) == str:
perm = Permission[perm] perm = Permission[perm]
elif type(perm) != Permission: elif type(perm) != Permission:
raise Exception("Unknown permission given to ThreadReply.check_perm()") raise Exception("Unknown permission given to ThreadReply.checkPerm()")
if perm == Permission.EDIT_REPLY: if perm == Permission.EDIT_REPLY:
return user.rank.at_least(UserRank.NEW_MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked return user.rank.atLeast(UserRank.MEMBER if user == self.author else UserRank.MODERATOR) and not self.thread.locked
elif perm == Permission.DELETE_REPLY: elif perm == Permission.DELETE_REPLY:
return user.rank.at_least(UserRank.MODERATOR) and self.thread.first_reply != self return user.rank.atLeast(UserRank.MODERATOR) and self.thread.replies[0] != self
else: else:
raise Exception("Permission {} is not related to threads".format(perm.name)) raise Exception("Permission {} is not related to threads".format(perm.name))
@@ -177,7 +157,7 @@ class PackageReview(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", foreign_keys=[author_id], back_populates="reviews") author = db.relationship("User", foreign_keys=[author_id], back_populates="reviews")
rating = db.Column(db.Integer, nullable=False) recommends = db.Column(db.Boolean, nullable=False)
thread = db.relationship("Thread", uselist=False, back_populates="review") thread = db.relationship("Thread", uselist=False, back_populates="review")
votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan") votes = db.relationship("PackageReviewVote", back_populates="review", cascade="all, delete, delete-orphan")
@@ -191,13 +171,10 @@ class PackageReview(db.Model):
user_vote = next(filter(lambda vote: vote.user == current_user, votes), None) user_vote = next(filter(lambda vote: vote.user == current_user, votes), None)
return pos, neg, user_vote.is_positive if user_vote else None return pos, neg, user_vote.is_positive if user_vote else None
def as_dict(self, include_package=False): def getAsDictionary(self, include_package=False):
from app.utils import abs_url_for
pos, neg, _user = self.get_totals() pos, neg, _user = self.get_totals()
ret = { ret = {
"is_positive": self.rating > 3, "is_positive": self.recommends,
"rating": self.rating,
"user": { "user": {
"username": self.author.username, "username": self.author.username,
"display_name": self.author.display_name, "display_name": self.author.display_name,
@@ -208,32 +185,24 @@ class PackageReview(db.Model):
"unhelpful": neg, "unhelpful": neg,
}, },
"title": self.thread.title, "title": self.thread.title,
"comment": self.thread.first_reply.comment, "comment": self.thread.replies[0].comment,
"thread": {
"id": self.thread.id,
"url": abs_url_for("threads.view", id=self.thread.id),
},
} }
if include_package: if include_package:
ret["package"] = self.package.as_key_dict() ret["package"] = self.package.getAsDictionaryKey()
return ret return ret
def as_weight(self): def asSign(self):
""" return 1 if self.recommends else -1
From (1, 5) to (-1 to 1)
"""
return (self.rating - 3.0) / 2.0
def get_edit_url(self): def getEditURL(self):
return self.package.get_url("packages.review") return self.package.getURL("packages.review")
def get_delete_url(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 get_vote_url(self, next_url=None): def getVoteUrl(self, next_url=None):
return url_for("packages.review_vote", return url_for("packages.review_vote",
author=self.package.author.username, author=self.package.author.username,
name=self.package.name, name=self.package.name,
@@ -244,20 +213,6 @@ 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 check_perm(self, user, perm):
if not user.is_authenticated:
return False
if type(perm) == str:
perm = Permission[perm]
elif type(perm) != Permission:
raise Exception("Unknown permission given to PackageReview.check_perm()")
if perm == Permission.DELETE_REVIEW:
return user == self.author or user.rank.at_least(UserRank.MODERATOR)
else:
raise Exception("Permission {} is not related to reviews".format(perm.name))
class PackageReviewVote(db.Model): 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

@@ -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/>.
import datetime import datetime
import enum import enum
from flask import url_for
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy import desc, text from sqlalchemy import desc, text
@@ -37,13 +37,13 @@ class UserRank(enum.Enum):
MODERATOR = 8 MODERATOR = 8
ADMIN = 9 ADMIN = 9
def at_least(self, min): def atLeast(self, min):
return self.value >= min.value return self.value >= min.value
def get_title(self): def getTitle(self):
return self.name.replace("_", " ").title() return self.name.replace("_", " ").title()
def to_name(self): def toName(self):
return self.name.lower() return self.name.lower()
def __str__(self): def __str__(self):
@@ -51,7 +51,7 @@ class UserRank(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.get_title()) for choice in cls] return [(choice, choice.getTitle()) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@@ -59,7 +59,7 @@ class UserRank(enum.Enum):
class Permission(enum.Enum): class Permission(enum.Enum):
VIEW_PACKAGE = "VIEW_PACKAGE" 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,16 +87,11 @@ 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"
VIEW_AUDIT_DESCRIPTION = "VIEW_AUDIT_DESCRIPTION"
EDIT_COLLECTION = "EDIT_COLLECTION"
VIEW_COLLECTION = "VIEW_COLLECTION"
CREATE_OAUTH_CLIENT = "CREATE_OAUTH_CLIENT"
# Only return true if the permission is valid for *all* contexts # Only return true if the permission is valid for *all* contexts
# See Package.check_perm for package-specific contexts # See Package.checkPerm for package-specific contexts
def check(self, user): def check(self, user):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
@@ -105,16 +100,16 @@ class Permission(enum.Enum):
self == Permission.APPROVE_RELEASE or \ self == Permission.APPROVE_RELEASE or \
self == Permission.APPROVE_SCREENSHOT or \ self == Permission.APPROVE_SCREENSHOT or \
self == Permission.SEE_THREAD: self == Permission.SEE_THREAD:
return user.rank.at_least(UserRank.APPROVER) return user.rank.atLeast(UserRank.APPROVER)
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG: elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
return user.rank.at_least(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
else: else:
raise Exception("Non-global permission checked globally. Use Package.check_perm or User.check_perm instead.") raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
@staticmethod @staticmethod
def check_perm(user, perm): def checkPerm(user, perm):
if type(perm) == str: if type(perm) == str:
perm = Permission[perm] perm = Permission[perm]
elif type(perm) != Permission: elif type(perm) != Permission:
@@ -153,8 +148,6 @@ 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")
@@ -166,17 +159,17 @@ class User(db.Model, UserMixin):
# Content # Content
notifications = db.relationship("Notification", foreign_keys="Notification.user_id", notifications = db.relationship("Notification", foreign_keys="Notification.user_id",
order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan") order_by=desc(text("Notification.created_at")), back_populates="user", cascade="all, delete, delete-orphan")
caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id", caused_notifications = db.relationship("Notification", foreign_keys="Notification.causer_id",
back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic") back_populates="causer", cascade="all, delete, delete-orphan", lazy="dynamic")
notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user", notification_preferences = db.relationship("UserNotificationPreferences", uselist=False, back_populates="user",
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id", email_verifications = db.relationship("UserEmailVerification", foreign_keys="UserEmailVerification.user_id",
back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic") back_populates="user", cascade="all, delete, delete-orphan", lazy="dynamic")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer", audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.causer_id", back_populates="causer",
order_by=desc("audit_log_entry_created_at"), lazy="dynamic") order_by=desc("audit_log_entry_created_at"), lazy="dynamic")
maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title")) maintained_packages = db.relationship("Package", lazy="dynamic", secondary="maintainers", order_by=db.asc("package_title"))
@@ -187,29 +180,6 @@ class User(db.Model, UserMixin):
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan") threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at")) replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan") forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title"))
clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
def get_dict(self):
from app.utils.flask import abs_url_for
return {
"username": self.username,
"display_name": self.display_name,
"rank": self.rank.name.lower(),
"profile_pic_url": self.profile_pic,
"website_url": self.website_url,
"donate_url": self.donate_url,
"connections": {
"github": self.github_username,
"forums": self.forums_username,
},
"links": {
"api_packages": abs_url_for("api.packages", author=self.username),
"profile": abs_url_for("users.profile", username=self.username),
}
}
def __init__(self, username=None, active=False, email=None, password=None): def __init__(self, username=None, active=False, email=None, password=None):
self.username = username self.username = username
@@ -219,10 +189,14 @@ class User(db.Model, UserMixin):
self.password = password self.password = password
self.rank = UserRank.NOT_JOINED self.rank = UserRank.NOT_JOINED
def can_access_todo_list(self): def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or Permission.APPROVE_RELEASE.check(self) return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self)
def get_profile_pic_url(self): def isClaimed(self):
return self.rank.atLeast(UserRank.NEW_MEMBER)
def getProfilePicURL(self):
if self.profile_pic: if self.profile_pic:
return self.profile_pic return self.profile_pic
elif self.rank == UserRank.BOT: elif self.rank == UserRank.BOT:
@@ -230,89 +204,67 @@ class User(db.Model, UserMixin):
else: else:
return gravatar(self.email or f"{self.username}@content.minetest.net") return gravatar(self.email or f"{self.username}@content.minetest.net")
def check_perm(self, user, perm): def checkPerm(self, user, perm):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if type(perm) == str: if type(perm) == str:
perm = Permission[perm] perm = Permission[perm]
elif type(perm) != Permission: elif type(perm) != Permission:
raise Exception("Unknown permission given to User.check_perm()") raise Exception("Unknown permission given to User.checkPerm()")
# Members can edit their own packages, and editors can edit any packages # Members can edit their own packages, and editors can edit any packages
if perm == Permission.CHANGE_AUTHOR: if perm == Permission.CHANGE_AUTHOR:
return user.rank.at_least(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.CHANGE_USERNAMES: elif perm == Permission.CHANGE_USERNAMES:
return user.rank.at_least(UserRank.MODERATOR) return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_RANK: elif perm == Permission.CHANGE_RANK:
return user.rank.at_least(UserRank.MODERATOR) and not self.rank.at_least(user.rank) return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS: elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
return user == self or (user.rank.at_least(UserRank.MODERATOR) and not self.rank.at_least(user.rank)) return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank))
elif perm == Permission.CHANGE_DISPLAY_NAME: elif perm == Permission.CHANGE_DISPLAY_NAME:
return user.rank.at_least(UserRank.NEW_MEMBER if user == self else UserRank.MODERATOR) return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
elif perm == Permission.CREATE_TOKEN or perm == Permission.CREATE_OAUTH_CLIENT: elif perm == Permission.CREATE_TOKEN:
if user == self: if user == self:
return user.rank.at_least(UserRank.NEW_MEMBER) return user.rank.atLeast(UserRank.MEMBER)
else: else:
return user.rank.at_least(UserRank.MODERATOR) and user.rank.at_least(self.rank) return user.rank.atLeast(UserRank.MODERATOR) and user.rank.atLeast(self.rank)
else: else:
raise Exception("Permission {} is not related to users".format(perm.name)) raise Exception("Permission {} is not related to users".format(perm.name))
def can_comment_ratelimit(self): def canCommentRL(self):
from app.models import ThreadReply from app.models import ThreadReply
factor = 1 factor = 1
if self.rank.at_least(UserRank.ADMIN): if self.rank.atLeast(UserRank.ADMIN):
return True return True
elif self.rank.at_least(UserRank.TRUSTED_MEMBER): elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor = 3 factor *= 2
elif self.rank.at_least(UserRank.MEMBER):
factor = 2
one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1) one_min_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
if ThreadReply.query.filter_by(author=self) \ if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > one_min_ago).count() >= 2 * factor: .filter(ThreadReply.created_at > one_min_ago).count() >= 3 * factor:
return False return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
if ThreadReply.query.filter_by(author=self) \ if ThreadReply.query.filter_by(author=self) \
.filter(ThreadReply.created_at > hour_ago).count() >= 10 * factor: .filter(ThreadReply.created_at > hour_ago).count() >= 20 * factor:
return False return False
return True return True
def can_open_thread_ratelimit(self): def canOpenThreadRL(self):
from app.models import Thread from app.models import Thread
factor = 1 factor = 1
if self.rank.at_least(UserRank.ADMIN): if self.rank.atLeast(UserRank.ADMIN):
return True return True
elif self.rank.at_least(UserRank.TRUSTED_MEMBER): elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor = 5
elif self.rank.at_least(UserRank.MEMBER):
factor = 2
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return Thread.query.filter_by(author=self)\
.filter(Thread.created_at > hour_ago).count() < 2 * factor
def can_review_ratelimit(self):
from app.models import PackageReview
factor = 1
if self.rank.at_least(UserRank.ADMIN):
return True
elif self.rank.at_least(UserRank.TRUSTED_MEMBER):
factor *= 5 factor *= 5
five_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=5)
if PackageReview.query.filter_by(author=self) \
.filter(PackageReview.created_at > five_mins_ago).count() > 2 * factor:
return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1) hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return PackageReview.query.filter_by(author=self) \ return Thread.query.filter_by(author=self) \
.filter(PackageReview.created_at > hour_ago).count() < 10 * factor .filter(Thread.created_at > hour_ago).count() < 2 * factor
def __eq__(self, other): def __eq__(self, other):
if other is None: if other is None:
@@ -325,15 +277,13 @@ class User(db.Model, UserMixin):
return self.id == other.id return self.id == other.id
def can_see_edit_profile(self, current_user): def can_see_edit_profile(self, current_user):
return self.check_perm(current_user, Permission.CHANGE_USERNAMES) or \ return self.checkPerm(current_user, Permission.CHANGE_USERNAMES) or \
self.check_perm(current_user, Permission.CHANGE_EMAIL) or \ self.checkPerm(current_user, Permission.CHANGE_EMAIL) or \
self.check_perm(current_user, Permission.CHANGE_RANK) self.checkPerm(current_user, Permission.CHANGE_RANK)
def can_delete(self): def can_delete(self):
from app.models import ForumTopic from app.models import ForumTopic
return self.packages.count() == 0 and \ return self.packages.count() == 0 and ForumTopic.query.filter_by(author=self).count() == 0
ForumTopic.query.filter_by(author=self).count() == 0 and \
self.rank != UserRank.BANNED
class UserEmailVerification(db.Model): class UserEmailVerification(db.Model):
@@ -357,11 +307,6 @@ class EmailSubscription(db.Model):
self.blacklisted = False self.blacklisted = False
self.token = None self.token = None
@property
def url(self):
from app.utils import abs_url_for
return abs_url_for('users.unsubscribe', token=self.token)
class NotificationType(enum.Enum): class NotificationType(enum.Enum):
# Package / release / etc # Package / release / etc
@@ -395,10 +340,10 @@ class NotificationType(enum.Enum):
OTHER = 0 OTHER = 0
def get_title(self): def getTitle(self):
return self.name.replace("_", " ").title() return self.name.replace("_", " ").title()
def to_name(self): def toName(self):
return self.name.lower() return self.name.lower()
def get_description(self): def get_description(self):
@@ -433,7 +378,7 @@ class NotificationType(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.get_title()) for choice in cls] return [(choice, choice.getTitle()) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
@@ -515,55 +460,18 @@ class UserNotificationPreferences(db.Model):
self.pref_other = 0 self.pref_other = 0
def get_can_email(self, notification_type): def get_can_email(self, notification_type):
return getattr(self, "pref_" + notification_type.to_name()) == 2 return getattr(self, "pref_" + notification_type.toName()) == 2
def set_can_email(self, notification_type, value): def set_can_email(self, notification_type, value):
value = 2 if value else 0 value = 2 if value else 0
setattr(self, "pref_" + notification_type.to_name(), value) setattr(self, "pref_" + notification_type.toName(), value)
def get_can_digest(self, notification_type): def get_can_digest(self, notification_type):
return getattr(self, "pref_" + notification_type.to_name()) >= 1 return getattr(self, "pref_" + notification_type.toName()) >= 1
def set_can_digest(self, notification_type, value): def set_can_digest(self, notification_type, value):
if self.get_can_email(notification_type): if self.get_can_email(notification_type):
return return
value = 1 if value else 0 value = 1 if value else 0
setattr(self, "pref_" + notification_type.to_name(), value) setattr(self, "pref_" + notification_type.toName(), value)
class UserBan(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="ban")
message = db.Column(db.UnicodeText, nullable=False)
banned_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
banned_by = db.relationship("User", foreign_keys=[banned_by_id])
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=True, default=None)
@property
def has_expired(self):
return self.expires_at and datetime.datetime.now() > self.expires_at
class OAuthClient(db.Model):
__tablename__ = "oauth_client"
id = db.Column(db.String(24), primary_key=True)
title = db.Column(db.String(64), unique=True, nullable=False)
description = db.Column(db.String(300), nullable=True)
secret = db.Column(db.String(32), nullable=False)
redirect_url = db.Column(db.String(128), nullable=False)
approved = db.Column(db.Boolean, nullable=False, default=False)
verified = db.Column(db.Boolean, nullable=False, default=False)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
owner = db.relationship("User", foreign_keys=[owner_id], back_populates="clients")
tokens = db.relationship("APIToken", back_populates="client", lazy="dynamic", cascade="all, delete, delete-orphan")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,211 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
function updateOrder() {
const elements = [...document.querySelector(".sortable").children];
const ids = elements
.filter(x => !x.classList.contains("d-none"))
.map(x => x.dataset.id)
.filter(x => x);
document.querySelector("input[name='order']").value = ids.join(",");
}
function removePackage(card) {
const message = document.getElementById("confirm_delete").innerText.trim();
const title = card.querySelector("h5 a").innerText.trim();
if (!confirm(message.replace("{title}", title))) {
return;
}
card.querySelector("input[name^=package_removed]").value = "1";
card.classList.add("d-none");
onPackageQueryUpdate();
updateOrder();
}
function restorePackage(id) {
const idElement = document.querySelector(`[value='${id}']`);
if (!idElement) {
return false;
}
const card = idElement.parentNode.parentNode.parentNode.parentNode;
console.assert(card.classList.contains("card"));
card.classList.remove("d-none");
card.querySelector("input[name^=package_removed]").value = "0";
card.scrollIntoView();
onPackageQueryUpdate();
updateOrder();
return true;
}
function getAddedPackages() {
const ids = document.querySelectorAll("#package_list > article:not(.d-none) input[name^=package_ids]");
return [...ids].map(x => x.value);
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function addPackage(pkg) {
document.getElementById("add_package").value = "";
document.getElementById("add_package_results").innerHTML = "";
const id = `${pkg.author}/${pkg.name}`;
if (restorePackage(id)) {
return;
}
const nextId = document.querySelectorAll("input[name^=package_ids-]").length;
const url = `/packages/${id}/`;
const temp = document.createElement("div");
temp.innerHTML = `
<article class="card my-3" data-id="${escapeHtml(id)}">
<div class="card-body">
<div class="row">
<div class="col-auto text-muted pe-2">
<i class="fas fa-bars"></i>
</div>
<div class="col">
<button class="btn btn-sm btn-danger remove-package float-end" type="button" aria-label="Remove">
<i class="fas fa-trash"></i>
</button>
<h5>
<a href="${escapeHtml(url)}" target="_blank">
${escapeHtml(pkg.title)} by ${escapeHtml(pkg.author)}
</a>
</h5>
<p class="text-muted">
${escapeHtml(pkg.short_description)}
</p>
<input id="package_ids-${nextId}" name="package_ids-${nextId}" type="hidden" value="${id}">
<input id="package_removed-${nextId}" name="package_removed-${nextId}" type="hidden" value="0">
<div>
<label for="descriptions-${nextId}" class="form-label">Short Description</label>
<input class="form-control" id="descriptions-${nextId}" maxlength="500" minlength="0"
name="descriptions-${nextId}" type="text" value="">
<small class="form-text text-muted">You can replace the description with your own</small>
</div>
</div>
</div>
</div>
</article>
`;
const card = temp.children[0];
document.getElementById("package_list").appendChild(card);
card.scrollIntoView();
const button = card.querySelector(".btn-danger");
button.addEventListener("click", () => removePackage(card));
updateOrder();
}
function updateResults(packages) {
const results = document.getElementById("add_package_results");
results.innerHTML = "";
document.getElementById("add_package_empty").style.display = packages.length === 0 ? "block" : "none";
const alreadyAdded = getAddedPackages();
packages.slice(0, 5).forEach(pkg => {
const result = document.createElement("a");
result.classList.add("list-group-item");
result.classList.add("list-group-item-action");
result.innerText = `${pkg.title} by ${pkg.author}`;
if (alreadyAdded.includes(`${pkg.author}/${pkg.name}`)) {
result.classList.add("active");
result.innerHTML = "<i class='fas fa-check me-3 text-success'></i>" + result.innerHTML;
}
result.addEventListener("click", () => addPackage(pkg));
results.appendChild(result);
});
}
let currentRequestId;
async function fetchPackagesAndUpdateResults(query) {
const requestId = Math.random() * 1000000;
currentRequestId = requestId;
if (query === "") {
updateResults([]);
return;
}
const url = new URL("/api/packages/", window.location.origin);
url.searchParams.set("q", query);
const resp = await fetch(url.toString());
if (!resp.ok) {
return;
}
const packages = await resp.json();
if (currentRequestId !== requestId) {
return;
}
updateResults(packages);
}
let timeoutHandle;
function onPackageQueryUpdate() {
const query = document.getElementById("add_package").value.trim();
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
timeoutHandle = setTimeout(
() => fetchPackagesAndUpdateResults(query).catch(console.error),
200);
}
window.addEventListener("load", () => {
document.querySelectorAll(".remove-package").forEach(button => {
const card = button.parentNode.parentNode.parentNode.parentNode;
console.assert(card.classList.contains("card"));
const field = card.querySelector("input[name^=package_removed]");
// Reloading/validation errors will cause this to be 1 at load
if (field && field.value === "1") {
card.classList.add("d-none");
} else {
button.addEventListener("click", () => removePackage(card));
}
});
const addPackageQuery = document.getElementById("add_package");
addPackageQuery.value = "";
addPackageQuery.classList.remove("d-none");
addPackageQuery.addEventListener("input", onPackageQueryUpdate);
addPackageQuery.addEventListener('keydown',(e)=>{
if (e.key === "Enter") {
onPackageQueryUpdate();
e.preventDefault();
}
})
updateOrder();
$(".sortable").sortable({
update: updateOrder,
});
});

View File

@@ -1,13 +0,0 @@
// @author recluse4615
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
const galleryCarousel = new bootstrap.Carousel(document.getElementById("galleryCarousel"));
document.querySelectorAll(".gallery-image").forEach(el => {
el.addEventListener("click", function(e) {
galleryCarousel.to(el.dataset.bsSlideTo);
e.preventDefault();
});
});

View File

@@ -1,306 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
const labelColor = "#bbb";
const annotationColor = "#bbb";
const annotationLabelBgColor = "#444";
const gridColor = "#333";
const chartColors = [
"#7eb26d",
"#eab839",
"#6ed0e0",
"#e24d42",
"#1f78c1",
"#ba43a9",
];
const annotationNov5 = {
type: "line",
borderColor: annotationColor,
borderWidth: 1,
click: function({chart, element}) {
document.location = "https://blog.rubenwardy.com/2022/12/08/contentdb-youtuber-finds-minetest/";
},
label: {
backgroundColor: annotationLabelBgColor,
content: "YouTube Video 🡕",
display: true,
position: "end",
color: "#00bc8c",
rotation: "auto",
backgroundShadowColor: "rgba(0, 0, 0, 0.4)",
shadowBlur: 3,
},
scaleID: "x",
value: "2022-11-05",
};
function hexToRgb(hex) {
var bigint = parseInt(hex, 16);
var r = (bigint >> 16) & 255;
var g = (bigint >> 8) & 255;
var b = bigint & 255;
return r + "," + g + "," + b;
}
function sum(list) {
return list.reduce((acc, x) => acc + x, 0);
}
const chartColorsBg = chartColors.map(color => `rgba(${hexToRgb(color.slice(1))}, 0.2)`);
const SECONDS_IN_A_DAY = 1000 * 3600 * 24;
function format_message(id, values) {
let format = document.getElementById(id).textContent;
values.forEach((value, i) => {
format = format.replace("$" + (i + 1), value);
})
return format;
}
function add_summary_card(title, icon, value, extra) {
const ele = document.createElement("div");
ele.innerHTML = `
<div class="col-md-4">
<div class="card h-100">
<div class="card-body align-items-center text-center">
<div class="mt-0 mb-3">
<i class="fas fa-${icon} me-1"></i>
<span class="summary-title"></span>
</div>
<div class="my-0 h4">
<span class="summary-value"></span>
<small class="text-muted ms-2 summary-extra"></small>
</div>
</div>
</div>
</div>`;
ele.querySelector(".summary-title").textContent = title;
ele.querySelector(".summary-value").textContent = value;
ele.querySelector(".summary-extra").textContent = extra;
document.getElementById("stats-summaries").appendChild(ele.children[0]);
}
async function load_data() {
const root = document.getElementById("stats-root");
const source = root.getAttribute("data-source");
const is_range = root.getAttribute("data-is-range") == "true";
const response = await fetch(source);
const json = await response.json();
document.getElementById("loading").style.display = "none";
if (json == null) {
document.getElementById("empty-view").style.display = "block";
return;
}
const startDate = new Date(json.start);
const endDate = new Date(json.end);
const numberOfDays = Math.round((endDate.valueOf() - startDate.valueOf()) / SECONDS_IN_A_DAY) + 1;
const dates = [...Array(numberOfDays)].map((_, i) => {
const date = new Date(startDate.valueOf() + i*SECONDS_IN_A_DAY);
return date.toISOString().split("T")[0];
});
if (!is_range) {
if (json.platform_minetest.length >= 30) {
const total30 = sum(json.platform_minetest.slice(-30)) + sum(json.platform_other.slice(-30));
add_summary_card(format_message("downloads-30days", []), "download", total30,
format_message("downloads-per-day", [ (total30 / 30).toFixed(0) ]));
}
const total7 = sum(json.platform_minetest.slice(-7)) + sum(json.platform_other.slice(-7));
add_summary_card(format_message("downloads-7days", []), "download", total7,
format_message("downloads-per-day", [ (total7 / 7).toFixed(0) ]));
} else {
const total = sum(json.platform_minetest) + sum(json.platform_other);
const days = Math.max(json.platform_minetest.length, json.platform_other.length);
const title = format_message("downloads-range", [ json.start, json.end ]);
add_summary_card(title, "download", total,
format_message("downloads-per-day", [ (total / days).toFixed(0) ]));
}
const jsonOther = json.platform_minetest.map((value, i) =>
value + json.platform_other[i]
- json.reason_new[i] - json.reason_dependency[i]
- json.reason_update[i]);
root.style.display = "block";
function getData(list) {
return list.map((value, i) => ({ x: dates[i], y: value }));
}
const annotations = {};
if (new Date(json.start) < new Date("2022-11-05")) {
annotations.annotationNov5 = annotationNov5;
}
if (json.package_downloads) {
const packageRecentDownloads = Object.fromEntries(Object.entries(json.package_downloads)
.map(([label, values]) => [label, sum(values.slice(-30))]));
document.getElementById("downloads-by-package").classList.remove("d-none");
const ctx = document.getElementById("chart-packages").getContext("2d");
const data = {
datasets: Object.entries(json.package_downloads)
.sort((a, b) => packageRecentDownloads[a[0]] - packageRecentDownloads[b[0]])
.map(([label, values]) => ({ label, data: getData(values) })),
};
setup_chart(ctx, data, annotations);
}
{
const ctx = document.getElementById("chart-platform").getContext("2d");
const data = {
datasets: [
{ label: "Web / other", data: getData(json.platform_other) },
{ label: "Minetest", data: getData(json.platform_minetest) },
],
};
setup_chart(ctx, data, annotations);
}
{
const ctx = document.getElementById("chart-reason").getContext("2d");
const data = {
datasets: [
{ label: "Other / Unknown", data: getData(jsonOther) },
{ label: "Update", data: getData(json.reason_update) },
{ label: "Dependency", data: getData(json.reason_dependency) },
{ label: "New Install", data: getData(json.reason_new) },
],
};
setup_chart(ctx, data, annotations);
}
{
const ctx = document.getElementById("chart-reason-pie").getContext("2d");
const data = {
labels: [
"New Install",
"Dependency",
"Update",
"Other / Unknown",
],
datasets: [{
label: "My First Dataset",
data: [
sum(json.reason_new),
sum(json.reason_dependency),
sum(json.reason_update),
sum(jsonOther),
],
backgroundColor: chartColors,
hoverOffset: 4,
borderWidth: 0,
}]
};
const config = {
type: "doughnut",
data: data,
options: {
responsive: true,
plugins: {
legend: {
labels: {
color: labelColor,
},
},
},
}
};
new Chart(ctx, config);
}
}
function setup_chart(ctx, data, annotations) {
data.datasets = data.datasets.map((set, i) => {
const colorIdx = (data.datasets.length - i - 1) % chartColors.length;
return {
fill: true,
backgroundColor: chartColorsBg[colorIdx],
borderColor: chartColors[colorIdx],
pointBackgroundColor: chartColors[colorIdx],
...set,
};
});
const config = {
type: "line",
data: data,
options: {
responsive: true,
plugins: {
tooltip: {
mode: "index"
},
legend: {
reverse: true,
labels: {
color: labelColor,
}
},
annotation: {
annotations,
},
},
interaction: {
mode: "nearest",
axis: "x",
intersect: false
},
scales: {
x: {
type: "time",
time: {
// min: start,
// max: end,
unit: "day",
},
ticks: {
color: labelColor,
},
grid: {
color: gridColor,
}
},
y: {
stacked: true,
min: 0,
precision: 0,
ticks: {
color: labelColor,
},
grid: {
color: gridColor,
}
},
}
}
};
new Chart(ctx, config);
}
window.addEventListener("load", load_data);

View File

@@ -1,74 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
function hide(sel) {
document.querySelectorAll(sel).forEach(x => x.classList.add("d-none"));
}
function show(sel) {
document.querySelectorAll(sel).forEach(x => x.classList.remove("d-none"));
}
window.addEventListener("load", () => {
function finish() {
hide(".pkg_wiz_1");
hide(".pkg_wiz_2");
show(".pkg_repo");
show(".pkg_meta");
}
hide(".pkg_meta");
show(".pkg_wiz_1");
document.getElementById("pkg_wiz_1_skip").addEventListener("click", finish);
document.getElementById("pkg_wiz_1_next").addEventListener("click", () => {
const repoURL = document.getElementById("repo").value;
if (repoURL.trim() !== "") {
hide(".pkg_wiz_1");
show(".pkg_wiz_2");
hide(".pkg_repo");
function setField(sel, value) {
if (value && value !== "") {
const ele = document.querySelector(sel);
ele.value = value;
ele.dispatchEvent(new Event("change"));
// EasyMDE doesn't always refresh the codemirror correctly
if (ele.easy_mde) {
setTimeout(() => {
ele.easy_mde.value(value);
ele.easy_mde.codemirror.refresh()
}, 100);
}
}
}
performTask("/tasks/getmeta/new/?url=" + encodeURI(repoURL)).then(function(result) {
setField("#name", result.name);
setField("#title", result.title);
setField("#repo", result.repo || repoURL);
setField("#issueTracker", result.issueTracker);
setField("#desc", result.desc);
setField("#short_desc", result.short_desc);
setField("#forums", result.forums);
if (result.type && result.type.length > 2) {
setField("[name='type']", result.type);
}
finish();
}).catch(function(e) {
alert(e);
show(".pkg_wiz_1");
hide(".pkg_wiz_2");
show(".pkg_repo");
// finish()
});
} else {
finish();
}
})
})

View File

@@ -1,84 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
function hide(sel) {
document.querySelectorAll(sel).forEach(x => x.classList.add("d-none"));
}
function show(sel) {
document.querySelectorAll(sel).forEach(x => x.classList.remove("d-none"));
}
window.addEventListener("load", () => {
const typeEle = document.getElementById("type");
typeEle.addEventListener("change", () => {
show(".not_mod, .not_game, .not_txp");
hide(".not_" + typeEle.value.toLowerCase());
})
show(".not_mod, .not_game, .not_txp");
hide(".not_" + typeEle.value.toLowerCase());
const forumsField = document.getElementById("forums");
forumsField.addEventListener("paste", function(e) {
try {
const pasteData = e.clipboardData.getData('text');
const url = new URL(pasteData);
if (url.hostname === "forum.minetest.net") {
forumsField.value = url.searchParams.get("t");
e.preventDefault();
}
} catch (e) {
console.log("Not a URL");
}
});
const openForums = document.getElementById("forums-button");
openForums.addEventListener("click", () => {
window.open("https://forum.minetest.net/viewtopic.php?t=" + forumsField.value, "_blank");
});
let hint = null;
function showHint(ele, text) {
if (hint) {
hint.remove();
}
hint = document.createElement("div");
hint.classList.add("alert");
hint.classList.add("alert-warning");
hint.classList.add("my-1");
hint.innerHTML = text;
ele.parentNode.appendChild(hint);
}
let hint_mtmods = `Tip:
Don't include <i>Minetest</i>, <i>mod</i>, or <i>modpack</i> anywhere in the short description.
It is unnecessary and wastes characters.`;
let hint_thegame = `Tip:
It's obvious that this adds something to Minetest,
there's no need to use phrases such as \"adds X to the game\".`;
const shortDescField = document.getElementById("short_desc");
function handleShortDescChange() {
const val = shortDescField.value.toLowerCase();
if (val.indexOf("minetest") >= 0 || val.indexOf("mod") >= 0 ||
val.indexOf("modpack") >= 0 || val.indexOf("mod pack") >= 0) {
showHint(shortDescField, hint_mtmods);
} else if (val.indexOf("the game") >= 0) {
showHint(shortDescField, hint_thegame);
} else if (hint) {
hint.remove();
hint = null;
}
}
shortDescField.addEventListener("change", handleShortDescChange);
shortDescField.addEventListener("paste", handleShortDescChange);
shortDescField.addEventListener("keyup", handleShortDescChange);
})

View File

@@ -1,64 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
async function getJSON(url, method) {
const response = await fetch(new Request(url, {
method: method || "get",
credentials: "same-origin",
headers: {
"Accept": "application/json",
},
}));
return await response.json();
}
function sleep(interval) {
return new Promise(resolve => setTimeout(resolve, interval));
}
async function pollTask(poll_url, disableTimeout) {
let tries = 0;
while (true) {
tries++;
if (!disableTimeout && tries > 30) {
throw "timeout";
} else {
const interval = Math.min(tries * 100, 1000);
console.log("Polling task in " + interval + "ms");
await sleep(interval);
}
let res = undefined;
try {
res = await getJSON(poll_url);
} catch (e) {
console.error(e);
}
if (res && res.status === "SUCCESS") {
console.log("Got result")
return res.result;
} else if (res && (res.status === "FAILURE" || res.status === "REVOKED")) {
throw res.error ?? "Unknown server error";
}
}
}
async function performTask(url) {
const startResult = await getJSON(url, "post");
console.log(startResult);
if (typeof startResult.poll_url == "string") {
return await pollTask(startResult.poll_url);
} else {
throw "Start task didn't return string!";
}
}

View File

@@ -1,101 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
function getVoteCount(button) {
const badge = button.querySelector(".badge");
return badge ? parseInt(badge.textContent) : 0;
}
function setVoteCount(button, count) {
let badge = button.querySelector(".badge");
if (count == 0) {
if (badge) {
badge.remove();
}
return;
}
if (!badge) {
badge = document.createElement("span")
badge.classList.add("badge");
badge.classList.add("bg-light");
badge.classList.add("text-dark");
badge.classList.add("ms-1");
button.appendChild(badge);
}
badge.textContent = count.toString();
}
async function submitForm(form, is_helpful) {
const data = new URLSearchParams();
for (const pair of new FormData(form)) {
data.append(pair[0], pair[1]);
}
data.set("is_positive", is_helpful ? "yes" : "no");
const res = await fetch(form.getAttribute("action"), {
method: "post",
body: data,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
});
if (!res.ok) {
const json = await res.json();
alert(json.error ?? "Unknown server error");
}
}
function setButtonSelected(ele, isSelected) {
if (isSelected) {
ele.classList.add("btn-primary");
ele.classList.remove("btn-secondary");
} else {
ele.classList.add("btn-secondary");
ele.classList.remove("btn-primary");
}
}
window.addEventListener("load", () => {
document.querySelectorAll(".review-helpful-vote").forEach((helpful_form) => {
const yes = helpful_form.querySelector("button[name='is_positive'][value='yes']");
const no = helpful_form.querySelector("button[name='is_positive'][value='no']");
function setVote(is_helpful) {
const selected = is_helpful ? yes : no;
const not_selected = is_helpful ? no : yes;
if (not_selected.classList.contains("btn-primary")) {
setVoteCount(not_selected, Math.max(getVoteCount(not_selected) - 1, 0));
setButtonSelected(not_selected, false);
}
if (selected.classList.contains("btn-secondary")) {
setVoteCount(selected, getVoteCount(selected) + 1);
setButtonSelected(selected, true);
} else if (selected.classList.contains("btn-primary")) {
setVoteCount(selected, Math.max(getVoteCount(selected) - 1, 0));
setButtonSelected(selected, false);
}
submitForm(helpful_form, is_helpful).catch(console.error);
}
yes.addEventListener("click", (e) => {
setVote(true);
e.preventDefault();
});
no.addEventListener("click", (e) => {
setVote(false)
e.preventDefault();
});
});
});

View File

@@ -1,28 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
window.addEventListener("load", () => {
function setup_toggle(type) {
const toggle = document.getElementById("set_" + type);
function on_change() {
const rel = document.getElementById(type + "_rel");
if (toggle.checked) {
rel.parentElement.style.opacity = "1";
} else {
// $("#" + type + "_rel").attr("disabled", "disabled");
rel.parentElement.style.opacity = "0.4";
rel.value = document.querySelector(`#${type}_rel option:first-child`).value;
rel.dispatchEvent(new Event("change"));
}
}
toggle.addEventListener("change", on_change);
on_change();
}
setup_toggle("min");
setup_toggle("max");
});

View File

@@ -1,25 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
window.addEventListener("load", () => {
const min = document.getElementById("min_rel");
const max = document.getElementById("max_rel");
const none = parseInt(document.querySelector("#min_rel option:first-child").value);
const warning = document.getElementById("minmax_warning");
function ver_check() {
const minv = parseInt(min.value);
const maxv = parseInt(max.value);
if (minv != none && maxv != none && minv > maxv) {
warning.style.display = "block";
} else {
warning.style.display = "none";
}
}
min.addEventListener("change", ver_check);
max.addEventListener("change", ver_check);
ver_check();
});

View File

@@ -1,19 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
window.addEventListener("load", () => {
function check_opt() {
if (document.querySelector("input[name='uploadOpt']:checked").value === "vcs") {
document.getElementById("file_upload").parentElement.classList.add("d-none");
document.getElementById("vcsLabel").parentElement.classList.remove("d-none");
} else {
document.getElementById("file_upload").parentElement.classList.remove("d-none");
document.getElementById("vcsLabel").parentElement.classList.add("d-none");
}
}
document.querySelectorAll("input[name='uploadOpt']").forEach(x => x.addEventListener("change", check_opt));
check_opt();
});

View File

@@ -1,17 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
window.addEventListener("load", () => {
function update() {
const elements = [...document.querySelector(".sortable").children];
const ids = elements.map(x => x.dataset.id).filter(x => x);
document.querySelector("input[name='order']").value = ids.join(",");
}
update();
$(".sortable").sortable({
update: update
});
})

View File

@@ -1,33 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
document.querySelectorAll(".topic-discard").forEach(ele => ele.addEventListener("click", (e) => {
const row = ele.parentNode.parentNode;
const tid = ele.getAttribute("data-tid");
const discard = !row.classList.contains("discardtopic");
fetch(new Request("/api/topic_discard/?tid=" + tid +
"&discard=" + (discard ? "true" : "false"), {
method: "post",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"X-CSRFToken": csrf_token,
},
})).then(function(response) {
response.text().then(function(txt) {
if (JSON.parse(txt).discarded) {
row.classList.add("discardtopic");
ele.classList.remove("btn-danger");
ele.classList.add("btn-success");
ele.innerText = "Show";
} else {
row.classList.remove("discardtopic");
ele.classList.remove("btn-success");
ele.classList.add("btn-danger");
ele.innerText = "Discard";
}
}).catch(console.error);
}).catch(console.error);
}));

View File

@@ -1,39 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
"use strict";
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("ratio");
ele.classList.add("ratio-16x9");
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;
}
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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