Compare commits

..

2 Commits

Author SHA1 Message Date
rubenwardy a93eea8612 Use vertical nav 2021-05-07 23:12:49 +01:00
rubenwardy a6ab2d6f79 Unify package edit UI 2021-05-07 23:12:47 +01:00
206 changed files with 2079 additions and 54899 deletions

View File

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

5
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
FROM python:3.10 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
@ -16,9 +16,7 @@ COPY utils utils
COPY config.cfg config.cfg COPY config.cfg config.cfg
COPY migrations migrations COPY migrations migrations
COPY app app COPY app app
COPY translations translations
RUN pybabel compile -d translations
RUN chown -R cdb:cdb /home/cdb RUN chown -R cdb:cdb /home/cdb
USER cdb USER cdb

View File

@ -4,9 +4,7 @@
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.
## How-tos ## How-tos

View File

@ -13,60 +13,54 @@
# #
# 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 * from flask import *
from flask_gravatar import Gravatar from flask_gravatar import Gravatar
import flask_menu as menu
from flask_mail import Mail from flask_mail import Mail
from flask_github import GitHub from flask_github import GitHub
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from flask_flatpages import FlatPages from flask_flatpages import FlatPages
from flask_babel import Babel, gettext from flask_babel import Babel
from flask_login import logout_user, current_user, LoginManager from flask_login import logout_user, current_user, LoginManager
import os, redis import os, redis
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"
app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = MARKDOWN_EXTENSIONS app.config["FLATPAGES_MARKDOWN_EXTENSIONS"] = ["fenced_code", "tables", "codehilite", 'toc']
app.config["FLATPAGES_EXTENSION_CONFIG"] = MARKDOWN_EXTENSION_CONFIG app.config["FLATPAGES_EXTENSION_CONFIG"] = {
app.config["BABEL_TRANSLATION_DIRECTORIES"] = "../translations" "fenced_code": {},
app.config["LANGUAGES"] = { "tables": {},
"en": "English", "codehilite": {
"de": "Deutsch", "guess_lang": False,
"fr": "Français", }
"id": "Bahasa Indonesia",
"ms": "Bahasa Melayu",
"ru": "русский язык",
} }
app.config.from_pyfile(os.environ["FLASK_CONFIG"]) app.config.from_pyfile(os.environ["FLASK_CONFIG"])
r = redis.Redis.from_url(app.config["REDIS_URL"]) r = redis.Redis.from_url(app.config["REDIS_URL"])
menu.Menu(app=app)
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(app) babel = Babel(app)
gravatar = Gravatar(app, gravatar = Gravatar(app,
size=64, size=58,
rating="g", rating="g",
default="retro", default="mp",
force_default=False, force_default=False,
force_lower=False, force_lower=False,
use_ssl=True, use_ssl=True,
base_url=None) base_url=None)
init_markdown(app)
login_manager = LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
login_manager.login_view = "users.login" login_manager.login_view = "users.login"
from .sass import sass
from .sass import init_app as sass
sass(app) sass(app)
@ -75,8 +69,14 @@ if not app.debug and app.config["MAIL_UTILS_ERROR_SEND_TO"]:
app.logger.addHandler(build_handler(app)) app.logger.addHandler(build_handler(app))
from . import models, template_filters from app.utils.markdown import init_app
init_app(app)
# @babel.localeselector
# def get_locale():
# return request.accept_languages.best_match(app.config["LANGUAGES"].keys())
from . import models, template_filters
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
@ -90,6 +90,7 @@ create_blueprints(app)
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)
@menu.register_menu(app, ".help", "Help", order=19, endpoint_arguments_constructor=lambda: { "path": "help" })
@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)
@ -100,15 +101,14 @@ def flatpage(path):
def check_for_ban(): def check_for_ban():
if current_user.is_authenticated: if current_user.is_authenticated:
if current_user.rank == models.UserRank.BANNED: if current_user.rank == models.UserRank.BANNED:
flash(gettext("You have been banned."), "danger") flash("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.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 clearNotifications
@app.before_request @app.before_request
def check_for_notifications(): def check_for_notifications():
@ -118,55 +118,3 @@ def check_for_notifications():
@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
@babel.localeselector
def get_locale():
if not request:
return None
locales = app.config["LANGUAGES"].keys()
if current_user.is_authenticated and current_user.locale in locales:
return current_user.locale
locale = request.cookies.get("locale")
if locale not in locales:
locale = request.accept_languages.best_match(locales)
if locale and current_user.is_authenticated:
new_session = models.db.create_session({})()
new_session.query(models.User) \
.filter(models.User.username == current_user.username) \
.update({ "locale": locale })
new_session.commit()
new_session.close()
return locale
@app.route("/set-locale/", methods=["POST"])
@csrf.exempt
def set_locale():
locale = request.form.get("locale")
if locale not in app.config["LANGUAGES"].keys():
flash("Unknown locale {}".format(locale), "danger")
locale = None
next_url = request.form.get("r")
if next_url and is_safe_url(next_url):
resp = make_response(redirect(next_url))
else:
resp = make_response(redirect(url_for("homepage.home")))
if locale:
expire_date = datetime.datetime.now()
expire_date = expire_date + datetime.timedelta(days=5*365)
resp.set_cookie("locale", locale, expires=expire_date)
if current_user.is_authenticated:
current_user.locale = locale
models.db.session.commit()
return resp

View File

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

View File

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

View File

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

View File

@ -14,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.utils.markdown import render_markdown
from app.models import *
from app.tasks.emails import send_user_email from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog from app.utils import rank_required, addAuditLog
from . import bp from . import bp
from ...models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm): class SendEmailForm(FlaskForm):
@ -54,7 +55,7 @@ def send_single_email():
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
task = send_user_email.delay(user.email, 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)
@ -66,12 +67,12 @@ def send_bulk_email():
form = SendEmailForm(request.form) form = SendEmailForm(request.form)
if form.validate_on_submit(): if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user, addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk email", 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)
for user in User.query.filter(User.email.isnot(None)).all(): for user in User.query.filter(User.email != None).all():
send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html) 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,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, flash from flask import *
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.validators import *
from app.utils import rank_required, nonEmptyOrNone from app.models import *
from app.utils import rank_required
from . import bp from . import bp
from ...models import UserRank, License, db
@bp.route("/licenses/") @bp.route("/licenses/")
@ -30,13 +30,10 @@ from ...models import UserRank, License, db
def license_list(): def license_list():
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all()) return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
class LicenseForm(FlaskForm): class LicenseForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3, 100)]) name = StringField("Name", [InputRequired(), Length(3,100)])
is_foss = BooleanField("Is FOSS") is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional], filters=[nonEmptyOrNone]) submit = SubmitField("Save")
submit = SubmitField("Save")
@bp.route("/licenses/new/", methods=["GET", "POST"]) @bp.route("/licenses/new/", methods=["GET", "POST"])
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"]) @bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])

View File

@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import 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 ...models import Permission, Tag, db
@bp.route("/tags/") @bp.route("/tags/")
@ -40,14 +40,11 @@ def tag_list():
return render_template("admin/tags/list.html", tags=query.all()) return render_template("admin/tags/list.html", tags=query.all())
class TagForm(FlaskForm): class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)]) title = StringField("Title", [InputRequired(), Length(3,100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)]) description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")]) name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
is_protected = BooleanField("Is Protected") 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"])
@ -62,16 +59,14 @@ def create_edit_tag(name=None):
if not Permission.checkPerm(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(formdata=request.form, 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)
else: else:
form.populate_obj(tag) form.populate_obj(tag)
db.session.commit() db.session.commit()
if Permission.EDIT_TAGS.check(current_user): if Permission.EDIT_TAGS.check(current_user):

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, flash from flask import *
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.models import *
from app.utils import rank_required from app.utils import rank_required
from . import bp from . import bp
from ...models import UserRank, MinetestRelease, db
@bp.route("/versions/") @bp.route("/versions/")
@ -30,12 +30,10 @@ from ...models import UserRank, MinetestRelease, db
def version_list(): def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all()) return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm): class VersionForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3, 100)]) name = StringField("Name", [InputRequired(), Length(3,100)])
protocol = IntegerField("Protocol") protocol = IntegerField("Protocol")
submit = SubmitField("Save") submit = SubmitField("Save")
@bp.route("/versions/new/", methods=["GET", "POST"]) @bp.route("/versions/new/", methods=["GET", "POST"])
@bp.route("/versions/<name>/edit/", methods=["GET", "POST"]) @bp.route("/versions/<name>/edit/", methods=["GET", "POST"])

View File

@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, render_template, abort, url_for, request, flash 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 ...models import UserRank, ContentWarning, db
@bp.route("/admin/warnings/") @bp.route("/admin/warnings/")
@ -30,14 +30,11 @@ from ...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

@ -14,41 +14,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/>.
import math
from typing import List
import flask_sqlalchemy
from flask import request, jsonify, current_app from flask import request, jsonify, current_app
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
from app import csrf from app import csrf
from app.markdown import render_markdown from app.utils.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
MinetestRelease, APIToken, PackageScreenshot, License, ContentWarning, User, PackageReview, Thread
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, isYes from app.utils import is_package_page, get_int_or_abort
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
def cors_allowed(f):
@wraps(f)
def inner(*args, **kwargs):
res = f(*args, **kwargs)
res.headers["Access-Control-Allow-Origin"] = "*"
res.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
res.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
return res
return inner
@bp.route("/api/packages/") @bp.route("/api/packages/")
@cors_allowed
def packages(): def packages():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
@ -64,7 +44,6 @@ def packages():
@bp.route("/api/packages/<author>/<name>/") @bp.route("/api/packages/<author>/<name>/")
@is_package_page @is_package_page
@cors_allowed
def package(package): def package(package):
return jsonify(package.getAsDictionary(current_app.config["BASE_URL"])) return jsonify(package.getAsDictionary(current_app.config["BASE_URL"]))
@ -73,7 +52,6 @@ def package(package):
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed
def edit_package(token, package): def edit_package(token, package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
@ -81,7 +59,7 @@ def edit_package(token, package):
return api_edit_package(token, package, request.json) return api_edit_package(token, package, request.json)
def resolve_package_deps(out, package, only_hard, depth=1): def resolve_package_deps(out, package, only_hard):
id = package.getId() id = package.getId()
if id in out: if id in out:
return return
@ -89,9 +67,6 @@ def resolve_package_deps(out, package, only_hard, depth=1):
ret = [] ret = []
out[id] = ret out[id] = ret
if package.type != PackageType.MOD:
return
for dep in package.dependencies: for dep in package.dependencies:
if only_hard and dep.optional: if only_hard and dep.optional:
continue continue
@ -99,16 +74,12 @@ 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.getId() ] fulfilled_by = [ dep.package.getId() ]
resolve_package_deps(out, dep.package, only_hard, depth) resolve_package_deps(out, dep.package, only_hard)
elif dep.meta_package: elif dep.meta_package:
name = dep.meta_package.name name = dep.meta_package.name
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages] fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
# TODO: resolve most likely candidate
if depth == 1 and not dep.optional:
most_likely = next((pkg for pkg in dep.meta_package.packages if pkg.type == PackageType.MOD), None)
if most_likely:
resolve_package_deps(out, most_likely, only_hard, depth + 1)
else: else:
raise Exception("Malformed dependency") raise Exception("Malformed dependency")
@ -122,7 +93,6 @@ def resolve_package_deps(out, package, only_hard, depth=1):
@bp.route("/api/packages/<author>/<name>/dependencies/") @bp.route("/api/packages/<author>/<name>/dependencies/")
@is_package_page @is_package_page
@cors_allowed
def package_dependencies(package): def package_dependencies(package):
only_hard = request.args.get("only_hard") only_hard = request.args.get("only_hard")
@ -133,7 +103,6 @@ def package_dependencies(package):
@bp.route("/api/topics/") @bp.route("/api/topics/")
@cors_allowed
def topics(): def topics():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True) query = qb.buildTopicQuery(show_added=True)
@ -160,7 +129,6 @@ def topic_set_discard():
@bp.route("/api/whoami/") @bp.route("/api/whoami/")
@is_api_authd @is_api_authd
@cors_allowed
def whoami(token): def whoami(token):
if token is None: if token is None:
return jsonify({ "is_authenticated": False, "username": None }) return jsonify({ "is_authenticated": False, "username": None })
@ -175,7 +143,6 @@ def markdown():
@bp.route("/api/releases/") @bp.route("/api/releases/")
@cors_allowed
def list_all_releases(): def list_all_releases():
query = PackageRelease.query.filter_by(approved=True) \ query = PackageRelease.query.filter_by(approved=True) \
.filter(PackageRelease.package.has(state=PackageState.APPROVED)) \ .filter(PackageRelease.package.has(state=PackageState.APPROVED)) \
@ -199,7 +166,6 @@ def list_all_releases():
@bp.route("/api/packages/<author>/<name>/releases/") @bp.route("/api/packages/<author>/<name>/releases/")
@is_package_page @is_package_page
@cors_allowed
def list_releases(package): def list_releases(package):
return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ]) return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ])
@ -208,7 +174,6 @@ def list_releases(package):
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed
def create_release(token, package): def create_release(token, package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
@ -242,7 +207,6 @@ 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
def release(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:
@ -255,7 +219,6 @@ def release(package: Package, id: int):
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed
def delete_release(token: APIToken, package: Package, id: int): def delete_release(token: APIToken, 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:
@ -278,7 +241,6 @@ def delete_release(token: APIToken, package: Package, id: int):
@bp.route("/api/packages/<author>/<name>/screenshots/") @bp.route("/api/packages/<author>/<name>/screenshots/")
@is_package_page @is_package_page
@cors_allowed
def list_screenshots(package): def list_screenshots(package):
screenshots = package.screenshots.all() screenshots = package.screenshots.all()
return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots]) return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots])
@ -288,7 +250,6 @@ def list_screenshots(package):
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed
def create_screenshot(token: APIToken, package: Package): def create_screenshot(token: APIToken, package: Package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
@ -304,12 +265,11 @@ 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, isYes(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>/")
@is_package_page @is_package_page
@cors_allowed
def screenshot(package, id): def screenshot(package, id):
ss = PackageScreenshot.query.get(id) ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package: if ss is None or ss.package != package:
@ -322,7 +282,6 @@ def screenshot(package, id):
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed
def delete_screenshot(token: APIToken, package: Package, id: int): def delete_screenshot(token: APIToken, package: Package, id: int):
ss = PackageScreenshot.query.get(id) ss = PackageScreenshot.query.get(id)
if ss is None or ss.package != package: if ss is None or ss.package != package:
@ -351,13 +310,12 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
@csrf.exempt @csrf.exempt
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed
def order_screenshots(token: APIToken, package: Package): def order_screenshots(token: APIToken, package: Package):
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS): if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots") error(403, "You do not have the permission to delete screenshots")
if not token.canOperateOnPackage(package): if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
@ -369,71 +327,7 @@ def order_screenshots(token: APIToken, package: Package):
return api_order_screenshots(token, package, request.json) return api_order_screenshots(token, package, request.json)
@bp.route("/api/packages/<author>/<name>/screenshots/cover-image/", methods=["POST"])
@csrf.exempt
@is_package_page
@is_api_authd
@cors_allowed
def set_cover_image(token: APIToken, package: Package):
if not token:
error(401, "Authentication needed")
if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to change screenshots")
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")
json = request.json
if json is None or not isinstance(json, dict) or "cover_image" not in json:
error(400, "Expected body to be an object with cover_image as a key")
return api_set_cover_image(token, package, request.json["cover_image"])
@bp.route("/api/packages/<author>/<name>/reviews/")
@is_package_page
@cors_allowed
def list_reviews(package):
reviews = package.reviews
return jsonify([review.getAsDictionary() for review in reviews])
@bp.route("/api/reviews/")
@cors_allowed
def list_all_reviews():
page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 100)
query = PackageReview.query
query = query.options(joinedload(PackageReview.author), joinedload(PackageReview.package))
if request.args.get("author"):
query = query.filter(PackageReview.author.has(User.username == request.args.get("author")))
if request.args.get("is_positive"):
query = query.filter(PackageReview.recommends == isYes(request.args.get("is_positive")))
q = request.args.get("q")
if q:
query = query.filter(PackageReview.thread.has(Thread.title.ilike(f"%{q}%")))
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
return jsonify({
"page": pagination.page,
"per_page": pagination.per_page,
"page_count": math.ceil(pagination.total / pagination.per_page),
"total": pagination.total,
"urls": {
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
},
"items": [review.getAsDictionary(True) for review in pagination.items],
})
@bp.route("/api/scores/") @bp.route("/api/scores/")
@cors_allowed
def package_scores(): def package_scores():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
@ -443,32 +337,26 @@ def package_scores():
@bp.route("/api/tags/") @bp.route("/api/tags/")
@cors_allowed
def tags(): def tags():
return jsonify([tag.getAsDictionary() 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
def content_warnings(): def content_warnings():
return jsonify([warning.getAsDictionary() 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
def licenses(): def licenses():
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \ return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
for license in License.query.order_by(db.asc(License.name)).all() ]) for license in License.query.order_by(db.asc(License.name)).all() ])
@bp.route("/api/homepage/") @bp.route("/api/homepage/")
@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()
featured = query.filter(Package.tags.any(name="featured")).order_by(
func.random()).limit(6).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all() 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()
@ -485,44 +373,22 @@ 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 mapPackages(packages: List[Package]): def mapPackages(packages):
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages] return [pkg.getAsDictionaryKey() for pkg in packages]
return jsonify({ return {
"count": count, "count": count,
"downloads": downloads, "downloads": downloads,
"featured": mapPackages(featured),
"new": mapPackages(new), "new": mapPackages(new),
"updated": mapPackages(updated), "updated": mapPackages(updated),
"pop_mod": mapPackages(pop_mod), "pop_mod": mapPackages(pop_mod),
"pop_txp": mapPackages(pop_txp), "pop_txp": mapPackages(pop_txp),
"pop_game": mapPackages(pop_gam), "pop_game": mapPackages(pop_gam),
"high_reviewed": mapPackages(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.tags.any(name="featured")) \
.order_by(func.random()) \
.limit(5).all()
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
featured.insert(2, mtg)
def map_packages(packages: List[Package]):
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({
"featured": map_packages(featured),
})
@bp.route("/api/minetest_versions/") @bp.route("/api/minetest_versions/")
@cors_allowed
def versions(): def versions():
protocol_version = request.args.get("protocol_version") protocol_version = request.args.get("protocol_version")
engine_version = request.args.get("engine_version") engine_version = request.args.get("engine_version")
@ -535,35 +401,3 @@ def versions():
return jsonify([rel.getAsDictionary() \ return jsonify([rel.getAsDictionary() \
for rel in MinetestRelease.query.all() if rel.getActual() is not None]) for rel in MinetestRelease.query.all() if rel.getActual() is not None])
@bp.route("/api/dependencies/")
@cors_allowed
def all_deps():
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
def format_pkg(pkg: Package):
return {
"type": pkg.type.toName(),
"author": pkg.author.username,
"name": pkg.name,
"provides": [x.name for x in pkg.provides],
"depends": [str(x) for x in pkg.dependencies if not x.optional],
"optional_depends": [str(x) for x in pkg.dependencies if x.optional],
}
page = get_int_or_abort(request.args.get("page"), 1)
num = min(get_int_or_abort(request.args.get("n"), 100), 300)
pagination: flask_sqlalchemy.Pagination = query.paginate(page, num, True)
return jsonify({
"page": pagination.page,
"per_page": pagination.per_page,
"page_count": math.ceil(pagination.total / pagination.per_page),
"total": pagination.total,
"urls": {
"previous": abs_url(url_set_query(page=page - 1)) if pagination.has_prev else None,
"next": abs_url(url_set_query(page=page + 1)) if pagination.has_next else None,
},
"items": [format_pkg(pkg) for pkg in pagination.items],
})

View File

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

View File

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

View File

@ -15,7 +15,6 @@
# 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 from flask import Blueprint
from flask_babel import gettext
bp = Blueprint("github", __name__) bp = Blueprint("github", __name__)
@ -43,7 +42,7 @@ def view_permissions():
def callback(oauth_token): def callback(oauth_token):
next_url = request.args.get("next") 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("Authorization failed [err=gh-oauth-login-failed]", "danger")
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
# Get Github username # Get Github username
@ -59,28 +58,30 @@ def callback(oauth_token):
if userByGithub is None: if userByGithub is None:
current_user.github_username = username current_user.github_username = username
db.session.commit() db.session.commit()
flash(gettext("Linked GitHub to account"), "success") flash("Linked github to account", "success")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
else: else:
flash(gettext("GitHub account is already associated with another user"), "danger") flash("Github account is already associated with another user", "danger")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
# If not logged in, log in # If not logged in, log in
else: else:
if userByGithub is None: if userByGithub is None:
flash(gettext("Unable to find an account for that GitHub user"), "danger") flash("Unable to find an account for that Github user", "danger")
return redirect(url_for("users.claim_forums")) return redirect(url_for("users.claim_forums"))
elif login_user_set_active(userByGithub, remember=True):
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
ret = login_user_set_active(userByGithub, remember=True) if not current_user.password:
if ret is None: return redirect(next_url or url_for("users.set_password", optional=True))
flash(gettext("Authorization failed [err=gh-login-failed]"), "danger") else:
return redirect(next_url or url_for("homepage.home"))
else:
flash("Authorization failed [err=gh-login-failed]", "danger")
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
addAuditLog(AuditSeverity.USER, userByGithub, "Logged in using GitHub OAuth",
url_for("users.profile", username=userByGithub.username))
db.session.commit()
return ret
@bp.route("/github/webhook/", methods=["POST"]) @bp.route("/github/webhook/", methods=["POST"])
@csrf.exempt @csrf.exempt
@ -137,23 +138,13 @@ def webhook():
if branch not in [ "master", "main" ]: if branch not in [ "master", "main" ]:
return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" }) return jsonify({ "success": False, "message": "Webhook ignored, as it's not on the master/main branch" })
elif event == "create": elif event == "create" and json["ref_type"] == "tag":
ref_type = json.get("ref_type")
if ref_type != "tag":
return jsonify({
"success": False,
"message": "Webhook ignored, as it's a non-tag create event. ref_type='{}'.".format(ref_type)
})
ref = json["ref"] ref = json["ref"]
title = ref title = ref
elif event == "ping": elif event == "ping":
return jsonify({ "success": True, "message": "Ping successful" }) return jsonify({ "success": True, "message": "Ping successful" })
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"))
# #
# Perform release # Perform release

View File

@ -63,8 +63,7 @@ def webhook_impl():
ref = json["ref"] ref = json["ref"]
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' and 'tag_push' are supported.")
.format(event or "null"))
# #
# Perform release # Perform release

View File

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

View File

@ -53,11 +53,12 @@ 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("metapackages/view.html", mpackage=mpackage, return render_template("metapackages/view.html", mpackage=mpackage,
dependers=dependers, optional_dependers=optional_dependers, dependers=dependers, optional_dependers=optional_dependers,

View File

@ -50,9 +50,9 @@ def generate_metrics(full=False):
users = User.query.filter(User.rank != UserRank.NOT_JOINED).count() users = User.query.filter(User.rank != UserRank.NOT_JOINED).count()
ret = "" ret = ""
ret += write_single_stat("contentdb_packages", "Total packages", "gauge", packages) ret += write_single_stat("contentdb_packages", "Total packages", "counter", packages)
ret += write_single_stat("contentdb_users", "Number of registered users", "gauge", users) ret += write_single_stat("contentdb_users", "Number of registered users", "counter", users)
ret += write_single_stat("contentdb_downloads", "Total downloads", "gauge", downloads) ret += write_single_stat("contentdb_downloads", "Total downloads", "counter", downloads)
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) \

View File

@ -15,7 +15,6 @@
# 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 from flask import Blueprint
from flask_babel import gettext
from app.models import User, Package, Permission from app.models import User, Package, Permission
@ -29,40 +28,30 @@ def get_package_tabs(user: User, package: Package):
return [ return [
{ {
"id": "edit", "id": "edit",
"title": gettext("Edit Details"), "title": "Edit Details",
"url": package.getURL("packages.create_edit") "url": package.getEditURL()
}, },
{ {
"id": "releases", "id": "releases",
"title": gettext("Releases"), "title": "Releases",
"url": package.getURL("packages.list_releases") "url": package.getReleaseListURL()
}, },
{ {
"id": "screenshots", "id": "screenshots",
"title": gettext("Screenshots"), "title": "Screenshots",
"url": package.getURL("packages.screenshots") "url": package.getEditScreenshotsURL()
}, },
{ {
"id": "maintainers", "id": "maintainers",
"title": gettext("Maintainers"), "title": "Maintainers",
"url": package.getURL("packages.edit_maintainers") "url": package.getEditMaintainersURL()
},
{
"id": "audit",
"title": gettext("Audit Log"),
"url": package.getURL("packages.audit")
},
{
"id": "share",
"title": gettext("Share and Badges"),
"url": package.getURL("packages.share")
}, },
{ {
"id": "remove", "id": "remove",
"title": gettext("Remove"), "title": "Remove",
"url": package.getURL("packages.remove") "url": package.getRemoveURL()
} }
] ]
from . import packages, screenshots, releases, reviews, game_hub from . import packages, screenshots, releases, reviews

View File

@ -1,54 +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 ...models import Package, PackageType, PackageState, db, PackageRelease
@bp.route("/packages/<author>/<name>/hub/")
@is_package_page
def game_hub(package: Package):
if package.type != PackageType.GAME:
abort(404)
def join(query):
return query.options(
joinedload(Package.license),
joinedload(Package.media_license))
query = Package.query.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED)
count = query.count()
new = join(query.order_by(db.desc(Package.approved_at))).limit(4).all()
pop_mod = join(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
pop_gam = join(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
pop_txp = join(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
high_reviewed = join(query.order_by(db.desc(Package.score - Package.score_downloads))) \
.filter(Package.reviews.any()).limit(4).all()
updated = db.session.query(Package).select_from(PackageRelease).join(Package) \
.filter(Package.supported_games.any(game=package), Package.state==PackageState.APPROVED) \
.order_by(db.desc(PackageRelease.releaseDate)) \
.limit(20).all()
updated = updated[:4]
return render_template("packages/game_hub.html", package=package, count=count,
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam,
high_reviewed=high_reviewed)

View File

@ -17,27 +17,30 @@
from urllib.parse import quote as urlescape from urllib.parse import quote as urlescape
from flask import render_template import flask_menu as menu
from flask_babel import lazy_gettext, gettext from celery import uuid
from flask import render_template, flash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_login import login_required from flask_login import login_required
from sqlalchemy import or_, func from sqlalchemy import or_, func
from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.orm import joinedload, subqueryload
from wtforms import * from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.validators import * from wtforms.validators import *
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 importRepoScreenshot from app.tasks.importtasks import importRepoScreenshot, checkZipRelease
from app.utils import * from app.utils import *
from . import bp, get_package_tabs from . import bp, get_package_tabs
from app.logic.LogicError import LogicError from ...logic.LogicError import LogicError
from app.logic.packages import do_edit_package from ...logic.packages import do_edit_package
from app.models.packages import PackageProvides
from app.tasks.webhooktasks import post_discord_webhook
@menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' })
@menu.register_menu(bp, ".games", "Games", order=12, endpoint_arguments_constructor=lambda: { 'type': 'game' })
@menu.register_menu(bp, ".txp", "Texture Packs", order=13, endpoint_arguments_constructor=lambda: { 'type': 'txp' })
@menu.register_menu(bp, ".random", "Random", order=14, endpoint_arguments_constructor=lambda: { 'random': '1', 'lucky': '1' })
@bp.route("/packages/") @bp.route("/packages/")
def list_all(): def list_all():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
@ -67,7 +70,7 @@ def list_all():
if qb.lucky: if qb.lucky:
package = query.first() package = query.first()
if package: if package:
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
topic = qb.buildTopicQuery().first() topic = qb.buildTopicQuery().first()
if qb.search and topic: if qb.search and topic:
@ -100,7 +103,7 @@ def list_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, title=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) authors=authors, packages_count=query.total, topics=topics)
@ -115,36 +118,26 @@ def getReleases(package):
@bp.route("/packages/<author>/<name>/") @bp.route("/packages/<author>/<name>/")
@is_package_page @is_package_page
def view(package): def view(package):
show_similar = not package.approved and ( alternatives = None
current_user in package.maintainers or if package.type == PackageType.MOD:
package.checkPerm(current_user, Permission.APPROVE_NEW)) alternatives = Package.query \
.filter_by(name=package.name, type=PackageType.MOD) \
.filter(Package.id != package.id, Package.state!=PackageState.DELETED) \
.order_by(db.desc(Package.score)) \
.all()
conflicting_modnames = None
if show_similar and package.type != PackageType.TXP:
conflicting_modnames = db.session.query(MetaPackage.name) \
.filter(MetaPackage.id.in_([ mp.id for mp in package.provides ])) \
.filter(MetaPackage.packages.any(Package.id != package.id)) \
.all()
conflicting_modnames += db.session.query(ForumTopic.name) \ show_similar_topics = current_user == package.author or \
.filter(ForumTopic.name.in_([ mp.name for mp in package.provides ])) \ package.checkPerm(current_user, Permission.APPROVE_NEW)
similar_topics = None if not show_similar_topics else \
ForumTopic.query \
.filter_by(name=package.name) \
.filter(ForumTopic.topic_id != package.forums) \ .filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \ .filter(~ db.exists().where(Package.forums==ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \ .order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all() .all()
conflicting_modnames = set([x[0] for x in conflicting_modnames])
packages_uses = None
if package.type == PackageType.MOD:
packages_uses = Package.query.filter(
Package.type == PackageType.MOD,
Package.id != package.id,
Package.state == PackageState.APPROVED,
Package.dependencies.any(
Dependency.meta_package_id.in_([p.id for p in package.provides]))) \
.order_by(db.desc(Package.score)).limit(6).all()
releases = getReleases(package) releases = getReleases(package)
review_thread = package.review_thread review_thread = package.review_thread
@ -156,16 +149,16 @@ def view(package):
if package.state != PackageState.APPROVED and package.forums is not None: if package.state != PackageState.APPROVED and package.forums is not None:
errors = [] errors = []
if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1: if Package.query.filter(Package.forums==package.forums, Package.state!=PackageState.DELETED).count() > 1:
errors.append("<b>" + gettext("Error: Another package already uses this forum topic!") + "</b>") errors.append("<b>Error: Another package already uses this forum topic!</b>")
topic_error_lvl = "danger" topic_error_lvl = "danger"
topic = ForumTopic.query.get(package.forums) topic = ForumTopic.query.get(package.forums)
if topic is not None: if topic is not None:
if topic.author != package.author: if topic.author != package.author:
errors.append("<b>" + gettext("Error: Forum topic author doesn't match package author.") + "</b>") errors.append("<b>Error: Forum topic author doesn't match package author.</b>")
topic_error_lvl = "danger" topic_error_lvl = "danger"
elif package.type != PackageType.TXP: elif package.type != PackageType.TXP:
errors.append(gettext("Warning: Forum topic not found. This may happen if the topic has only just been created.")) errors.append("Warning: Forum topic not found. This may happen if the topic has only just been created.")
topic_error = "<br />".join(errors) topic_error = "<br />".join(errors)
@ -173,14 +166,14 @@ def view(package):
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.atLeast(UserRank.APPROVER) and not current_user == package.author: elif not current_user.rank.atLeast(UserRank.EDITOR) 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 PackageReview.query.filter_by(package=package, author=current_user).count() > 0 has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).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,
conflicting_modnames=conflicting_modnames, alternatives=alternatives, similar_topics=similar_topics,
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) threads=threads.all(), has_review=has_review)
@ -189,7 +182,7 @@ def view(package):
@is_package_page @is_package_page
def shield(package, type): def shield(package, type):
if type == "title": if type == "title":
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \ url = "https://img.shields.io/badge/ContentDB-{}-{}" \
.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", author=package.author.username, name=package.name) #api_url = abs_url_for("api.package", author=package.author.username, name=package.name)
@ -212,8 +205,8 @@ def download(package):
not "text/html" in request.accept_mimetypes: not "text/html" in request.accept_mimetypes:
return "", 204 return "", 204
else: else:
flash(gettext("No download available."), "danger") flash("No download available.", "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
else: else:
return redirect(release.getDownloadURL()) return redirect(release.getDownloadURL())
@ -224,29 +217,25 @@ def makeLabel(obj):
else: else:
return obj.title return obj.title
class PackageForm(FlaskForm): class PackageForm(FlaskForm):
type = SelectField(lazy_gettext("Type"), [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD) type = SelectField("Type", [InputRequired()], choices=PackageType.choices(), coerce=PackageType.coerce, default=PackageType.MOD)
title = StringField(lazy_gettext("Title (Human-readable)"), [InputRequired(), Length(1, 100)]) title = StringField("Title (Human-readable)", [InputRequired(), Length(1, 100)])
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))]) name = StringField("Name (Technical)", [InputRequired(), Length(1, 100), Regexp("^[a-z0-9_]+$", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
short_desc = StringField(lazy_gettext("Short Description (Plaintext)"), [InputRequired(), Length(1,200)]) short_desc = StringField("Short Description (Plaintext)", [InputRequired(), Length(1,200)])
dev_state = SelectField(lazy_gettext("Maintenance State"), [InputRequired()], choices=PackageDevState.choices(with_none=True), coerce=PackageDevState.coerce) tags = QuerySelectMultipleField('Tags', query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel)
content_warnings = QuerySelectMultipleField('Content Warnings', query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
license = QuerySelectField("License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField("Media License", [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
tags = QuerySelectMultipleField(lazy_gettext('Tags'), query_factory=lambda: Tag.query.order_by(db.asc(Tag.name)), get_pk=lambda a: a.id, get_label=makeLabel) desc = TextAreaField("Long Description (Markdown)", [Optional(), Length(0,10000)])
content_warnings = QuerySelectMultipleField(lazy_gettext('Content Warnings'), query_factory=lambda: ContentWarning.query.order_by(db.asc(ContentWarning.name)), get_pk=lambda a: a.id, get_label=makeLabel)
license = QuerySelectField(lazy_gettext("License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
media_license = QuerySelectField(lazy_gettext("Media License"), [DataRequired()], allow_blank=True, query_factory=lambda: License.query.order_by(db.asc(License.name)), get_pk=lambda a: a.id, get_label=lambda a: a.name)
desc = TextAreaField(lazy_gettext("Long Description (Markdown)"), [Optional(), Length(0,10000)]) repo = StringField("VCS Repository URL", [Optional(), URL()], filters = [lambda x: x or None])
website = StringField("Website URL", [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField("Issue Tracker URL", [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField("Forum Topic ID", [Optional(), NumberRange(0,999999)])
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None]) submit = SubmitField("Save")
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/new/", methods=["GET", "POST"]) @bp.route("/packages/new/", methods=["GET", "POST"])
@ -262,11 +251,11 @@ def create_edit(author=None, name=None):
else: else:
author = User.query.filter_by(username=author).first() author = User.query.filter_by(username=author).first()
if author is None: if author is None:
flash(gettext("Unable to find that user"), "danger") flash("Unable to find that user", "danger")
return redirect(url_for("packages.create_edit")) return redirect(url_for("packages.create_edit"))
if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR): if not author.checkPerm(current_user, Permission.CHANGE_AUTHOR):
flash(gettext("Permission denied"), "danger") flash("Permission denied", "danger")
return redirect(url_for("packages.create_edit")) return redirect(url_for("packages.create_edit"))
else: else:
@ -274,7 +263,7 @@ def create_edit(author=None, name=None):
if package is None: if package is None:
abort(404) abort(404)
if not package.checkPerm(current_user, Permission.EDIT_PACKAGE): if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
author = package.author author = package.author
@ -283,15 +272,15 @@ def create_edit(author=None, name=None):
# Initial form class from post data and default data # Initial form class from post data and default data
if request.method == "GET": if request.method == "GET":
if package is None: if package is None:
form.name.data = request.args.get("bname") form.name.data = request.args.get("bname")
form.title.data = request.args.get("title") form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo") form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums") form.forums.data = request.args.get("forums")
form.license.data = None form.license.data = None
form.media_license.data = None form.media_license.data = None
else: else:
form.tags.data = 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
@ -304,7 +293,7 @@ def create_edit(author=None, name=None):
if package.state == PackageState.READY_FOR_REVIEW: if package.state == PackageState.READY_FOR_REVIEW:
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete() Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else: else:
flash(gettext("Package already exists!"), "danger") flash("Package already exists!", "danger")
return redirect(url_for("packages.create_edit")) return redirect(url_for("packages.create_edit"))
package = Package() package = Package()
@ -313,12 +302,11 @@ def create_edit(author=None, name=None):
wasNew = True wasNew = True
try: try:
do_edit_package(current_user, package, wasNew, True, { do_edit_package(current_user, package, wasNew, {
"type": form.type.data, "type": form.type.data,
"title": form.title.data, "title": form.title.data,
"name": form.name.data, "name": form.name.data,
"short_desc": form.short_desc.data, "short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data, "tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data, "content_warnings": form.content_warnings.raw_data,
"license": form.license.data, "license": form.license.data,
@ -328,17 +316,16 @@ def create_edit(author=None, name=None):
"website": form.website.data, "website": form.website.data,
"issueTracker": form.issueTracker.data, "issueTracker": form.issueTracker.data,
"forums": form.forums.data, "forums": form.forums.data,
"video_url": form.video_url.data,
}) })
if wasNew and package.repo is not None: if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id) importRepoScreenshot.delay(package.id)
next_url = package.getURL("packages.view") next_url = package.getDetailsURL()
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name): 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) next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew: elif wasNew:
next_url = package.getURL("packages.setup_releases") next_url = package.getSetupReleasesURL()
return redirect(next_url) return redirect(next_url)
except LogicError as e: except LogicError as e:
@ -365,16 +352,14 @@ def move_to_state(package):
abort(400) abort(400)
if not package.canMoveToState(current_user, state): if not package.canMoveToState(current_user, state):
flash(gettext("You don't have permission to do that"), "danger") flash("You don't have permission to do that", "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
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.username,
"New package {}".format(package.getURL("packages.view", absolute=True)), False)
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()
@ -382,24 +367,21 @@ def move_to_state(package):
s.approved = True s.approved = True
msg = "Approved {}".format(package.title) msg = "Approved {}".format(package.title)
elif state == PackageState.READY_FOR_REVIEW:
post_discord_webhook.delay(package.author.username,
"Ready for Review: {}".format(package.getURL("packages.view", absolute=True)), True)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package) addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), 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
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package) addAuditLog(severity, current_user, msg, package.getDetailsURL(), 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("Please comment what changes are needed in the review thread", "warning")
if package.review_thread: if package.review_thread:
return redirect(package.review_thread.getViewURL()) 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.getURL("packages.view")) return redirect(package.getDetailsURL())
@bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/remove/", methods=["GET", "POST"])
@ -410,48 +392,46 @@ def remove(package):
return render_template("packages/remove.html", package=package, return render_template("packages/remove.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="remove") tabs=get_package_tabs(current_user, package), current_tab="remove")
reason = request.form.get("reason") or "?"
if "delete" in request.form: if "delete" in request.form:
if not package.checkPerm(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("You don't have permission to do that.", "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
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 {}".format(package.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package) addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url) addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
db.session.commit() db.session.commit()
flash(gettext("Deleted package"), "success") flash("Deleted package", "success")
return redirect(url) return redirect(url)
elif "unapprove" in request.form: elif "unapprove" in request.form:
if not package.checkPerm(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("You don't have permission to do that.", "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
package.state = PackageState.WIP package.state = PackageState.WIP
msg = "Unapproved {}, reason={}".format(package.title, reason) msg = "Unapproved {}".format(package.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getURL("packages.view"), package) addNotification(package.maintainers, current_user, NotificationType.PACKAGE_APPROVAL, msg, package.getDetailsURL(), package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getURL("packages.view"), package) addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()
flash(gettext("Unapproved package"), "success") flash("Unapproved package", "success")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
else: else:
abort(400) abort(400)
class PackageMaintainersForm(FlaskForm): class PackageMaintainersForm(FlaskForm):
maintainers_str = StringField(lazy_gettext("Maintainers (Comma-separated)"), [Optional()]) maintainers_str = StringField("Maintainers (Comma-separated)", [Optional()])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField("Save")
@bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/edit-maintainers/", methods=["GET", "POST"])
@ -459,8 +439,8 @@ class PackageMaintainersForm(FlaskForm):
@is_package_page @is_package_page
def edit_maintainers(package): def edit_maintainers(package):
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS): if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
flash(gettext("You do not have permission to edit maintainers"), "danger") flash("You do not have permission to edit maintainers", "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
form = PackageMaintainersForm(formdata=request.form) form = PackageMaintainersForm(formdata=request.form)
if request.method == "GET": if request.method == "GET":
@ -470,19 +450,15 @@ def edit_maintainers(package):
usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")] usernames = [x.strip().lower() for x in form.maintainers_str.data.split(",")]
users = User.query.filter(func.lower(User.username).in_(usernames)).all() users = User.query.filter(func.lower(User.username).in_(usernames)).all()
thread = package.threads.filter_by(author=get_system_user()).first()
for user in users: for user in users:
if not user in package.maintainers: if not user in package.maintainers:
if thread:
thread.watchers.append(user)
addNotification(user, current_user, NotificationType.MAINTAINER, addNotification(user, current_user, NotificationType.MAINTAINER,
"Added you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package) "Added you as a maintainer of {}".format(package.title), package.getDetailsURL(), 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:
addNotification(user, current_user, NotificationType.MAINTAINER, addNotification(user, current_user, NotificationType.MAINTAINER,
"Removed you as a maintainer of {}".format(package.title), package.getURL("packages.view"), package) "Removed you as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
package.maintainers.clear() package.maintainers.clear()
package.maintainers.extend(users) package.maintainers.extend(users)
@ -490,13 +466,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)
addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getURL("packages.view"), package) addNotification(package.author, current_user, NotificationType.MAINTAINER, msg, package.getDetailsURL(), package)
severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION
addAuditLog(severity, current_user, msg, package.getURL("packages.view"), package) addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
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()
@ -509,28 +485,55 @@ def edit_maintainers(package):
@is_package_page @is_package_page
def remove_self_maintainers(package): def remove_self_maintainers(package):
if not current_user in package.maintainers: if not current_user in package.maintainers:
flash(gettext("You are not a maintainer"), "danger") flash("You are not a maintainer", "danger")
elif current_user == package.author: elif current_user == package.author:
flash(gettext("Package owners cannot remove themselves as maintainers"), "danger") flash("Package owners cannot remove themselves as maintainers", "danger")
else: else:
package.maintainers.remove(current_user) package.maintainers.remove(current_user)
addNotification(package.author, current_user, NotificationType.MAINTAINER, addNotification(package.author, current_user, NotificationType.MAINTAINER,
"Removed themself as a maintainer of {}".format(package.title), package.getURL("packages.view"), package) "Removed themself as a maintainer of {}".format(package.title), package.getDetailsURL(), package)
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
@bp.route("/packages/<author>/<name>/import-meta/", methods=["POST"])
@login_required
@is_package_page
def update_from_release(package):
if not package.checkPerm(current_user, Permission.REIMPORT_META):
flash("You don't have permission to reimport meta", "danger")
return redirect(package.getDetailsURL())
release = package.releases.first()
if not release:
flash("Release needed", "danger")
return redirect(package.getDetailsURL())
msg = "Updated meta from latest release"
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT,
msg, package.getDetailsURL(), package)
severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, current_user, msg, package.getDetailsURL(), package)
db.session.commit()
task_id = uuid()
zippath = release.url.replace("/uploads/", app.config["UPLOAD_DIR"])
checkZipRelease.apply_async((release.id, zippath), task_id=task_id)
return redirect(url_for("tasks.check", id=task_id, r=package.getEditURL()))
@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.checkPerm(current_user, Permission.EDIT_PACKAGE) or if not package.checkPerm(current_user, Permission.EDIT_PACKAGE):
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)
@ -539,75 +542,4 @@ 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, num, True) pagination = query.paginate(page, num, True)
return render_template("packages/audit.html", log=pagination.items, pagination=pagination, return render_template("admin/audit.html", log=pagination.items, pagination=pagination)
package=package, tabs=get_package_tabs(current_user, package), current_tab="audit")
class PackageAliasForm(FlaskForm):
author = StringField(lazy_gettext("Author Name"), [InputRequired(), Length(1, 50)])
name = StringField(lazy_gettext("Name (Technical)"), [InputRequired(), Length(1, 100),
Regexp("^[a-z0-9_]+$", 0, lazy_gettext("Lower case letters (a-z), digits (0-9), and underscores (_) only"))])
submit = SubmitField(lazy_gettext("Save"))
@bp.route("/packages/<author>/<name>/aliases/")
@rank_required(UserRank.EDITOR)
@is_package_page
def alias_list(package: Package):
return render_template("packages/alias_list.html", package=package)
@bp.route("/packages/<author>/<name>/aliases/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/aliases/<int:alias_id>/", methods=["GET", "POST"])
@rank_required(UserRank.EDITOR)
@is_package_page
def alias_create_edit(package: Package, alias_id: int = None):
alias = None
if alias_id:
alias = PackageAlias.query.get(alias_id)
if alias is None or alias.package != package:
abort(404)
form = PackageAliasForm(request.form, obj=alias)
if form.validate_on_submit():
if alias is None:
alias = PackageAlias()
alias.package = package
db.session.add(alias)
form.populate_obj(alias)
db.session.commit()
return redirect(package.getURL("packages.alias_list"))
return render_template("packages/alias_create_edit.html", package=package, form=form)
@bp.route("/packages/<author>/<name>/share/")
@login_required
@is_package_page
def share(package):
return render_template("packages/share.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="share")
@bp.route("/packages/<author>/<name>/similar/")
@is_package_page
def similar(package):
packages_modnames = {}
for metapackage in package.provides:
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,
Package.state != PackageState.DELETED) \
.filter(Package.provides.any(PackageProvides.c.metapackage_id == metapackage.id)) \
.order_by(db.desc(Package.score)) \
.all()
similar_topics = ForumTopic.query \
.filter_by(name=package.name) \
.filter(ForumTopic.topic_id != package.forums) \
.filter(~ db.exists().where(Package.forums == ForumTopic.topic_id)) \
.order_by(db.asc(ForumTopic.name), db.asc(ForumTopic.title)) \
.all()
return render_template("packages/similar.html", package=package,
packages_modnames=packages_modnames, similar_topics=similar_topics)

View File

@ -16,11 +16,10 @@
from flask import * from flask import *
from flask_babel import gettext, lazy_gettext
from flask_login import login_required from flask_login import login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import * from wtforms.validators import *
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
@ -49,27 +48,26 @@ def get_mt_releases(is_max):
class CreatePackageReleaseForm(FlaskForm): class CreatePackageReleaseForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)]) title = StringField("Title", [InputRequired(), Length(1, 30)])
uploadOpt = RadioField(lazy_gettext("Method"), choices=[("upload", lazy_gettext("File Upload"))], default="upload") uploadOpt = RadioField ("Method", choices=[("upload", "File Upload")], default="upload")
vcsLabel = StringField(lazy_gettext("Git reference (ie: commit hash, branch, or tag)"), default=None) vcsLabel = StringField("Git reference (ie: commit hash, branch, or tag)", default=None)
fileUpload = FileField(lazy_gettext("File Upload")) fileUpload = FileField("File Upload")
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()], min_rel = QuerySelectField("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("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("Save")
class EditPackageReleaseForm(FlaskForm): class EditPackageReleaseForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(1, 30)]) title = StringField("Title", [InputRequired(), Length(1, 30)])
url = StringField(lazy_gettext("URL"), [Optional()]) url = StringField("URL", [Optional()])
task_id = StringField(lazy_gettext("Task ID"), filters = [lambda x: x or None]) task_id = StringField("Task ID", filters = [lambda x: x or None])
approved = BooleanField(lazy_gettext("Is Approved")) approved = BooleanField("Is Approved")
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()], min_rel = QuerySelectField("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("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("Save")
@bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/releases/new/", methods=["GET", "POST"])
@ -77,12 +75,12 @@ class EditPackageReleaseForm(FlaskForm):
@is_package_page @is_package_page
def create_release(package): def create_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE): if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
# Initial form class from post data and default data # Initial form class from post data and default data
form = CreatePackageReleaseForm() form = CreatePackageReleaseForm()
if package.repo is not None: if package.repo is not None:
form["uploadOpt"].choices = [("vcs", gettext("Import from Git")), ("upload", gettext("Upload .zip file"))] form["uploadOpt"].choices = [("vcs", "Import from Git"), ("upload", "Upload .zip file")]
if request.method == "GET": if request.method == "GET":
form["uploadOpt"].data = "vcs" form["uploadOpt"].data = "vcs"
form.vcsLabel.data = request.args.get("ref") form.vcsLabel.data = request.args.get("ref")
@ -146,7 +144,7 @@ def edit_release(package, id):
canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE) canEdit = package.checkPerm(current_user, Permission.MAKE_RELEASE)
canApprove = release.checkPerm(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.getURL("packages.view")) return redirect(package.getDetailsURL())
# 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)
@ -173,21 +171,21 @@ def edit_release(package, id):
release.approved = False release.approved = False
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.list_releases")) return redirect(package.getReleaseListURL())
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)
class BulkReleaseForm(FlaskForm): class BulkReleaseForm(FlaskForm):
set_min = BooleanField(lazy_gettext("Set Min")) set_min = BooleanField("Set Min")
min_rel = QuerySelectField(lazy_gettext("Minimum Minetest Version"), [InputRequired()], min_rel = QuerySelectField("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)
set_max = BooleanField(lazy_gettext("Set Max")) set_max = BooleanField("Set Max")
max_rel = QuerySelectField(lazy_gettext("Maximum Minetest Version"), [InputRequired()], max_rel = QuerySelectField("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)
only_change_none = BooleanField(lazy_gettext("Only change values previously set as none")) only_change_none = BooleanField("Only change values previously set as none")
submit = SubmitField(lazy_gettext("Update")) submit = SubmitField("Update")
@bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/releases/bulk_change/", methods=["GET", "POST"])
@ -195,7 +193,7 @@ class BulkReleaseForm(FlaskForm):
@is_package_page @is_package_page
def bulk_change_release(package): def bulk_change_release(package):
if not package.checkPerm(current_user, Permission.MAKE_RELEASE): if not package.checkPerm(current_user, Permission.MAKE_RELEASE):
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
# Initial form class from post data and default data # Initial form class from post data and default data
form = BulkReleaseForm() form = BulkReleaseForm()
@ -213,7 +211,7 @@ def bulk_change_release(package):
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.list_releases")) return redirect(package.getReleaseListURL())
return render_template("packages/release_bulk_change.html", package=package, form=form) return render_template("packages/release_bulk_change.html", package=package, form=form)
@ -227,25 +225,21 @@ def delete_release(package, id):
abort(404) abort(404)
if not release.checkPerm(current_user, Permission.DELETE_RELEASE): if not release.checkPerm(current_user, Permission.DELETE_RELEASE):
return redirect(package.getURL("packages.list_releases")) return redirect(release.getReleaseListURL())
db.session.delete(release) db.session.delete(release)
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
class PackageUpdateConfigFrom(FlaskForm): class PackageUpdateConfigFrom(FlaskForm):
trigger = RadioField(lazy_gettext("Trigger"), [InputRequired()], trigger = RadioField("Trigger", [InputRequired()], choices=PackageUpdateTrigger.choices(), coerce=PackageUpdateTrigger.coerce,
choices=[(PackageUpdateTrigger.COMMIT, lazy_gettext("New Commit")), default=PackageUpdateTrigger.TAG)
(PackageUpdateTrigger.TAG, lazy_gettext("New Tag"))], ref = StringField("Branch name", [Optional()], default=None)
coerce=PackageUpdateTrigger.coerce, default=PackageUpdateTrigger.TAG) action = RadioField("Action", [InputRequired()], choices=[("notification", "Send notification and mark as outdated"), ("make_release", "Create release")], default="make_release")
ref = StringField(lazy_gettext("Branch name"), [Optional()], default=None) submit = SubmitField("Save Settings")
action = RadioField(lazy_gettext("Action"), [InputRequired()], disable = SubmitField("Disable Automation")
choices=[("notification", lazy_gettext("Send notification and mark as outdated")), ("make_release", lazy_gettext("Create release"))],
default="make_release")
submit = SubmitField(lazy_gettext("Save Settings"))
disable = SubmitField(lazy_gettext("Disable Automation"))
def set_update_config(package, form): def set_update_config(package, form):
@ -284,8 +278,8 @@ def update_config(package):
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("Please add a Git repository URL in order to set up automatic releases", "danger")
return redirect(package.getURL("packages.create_edit")) return redirect(package.getEditURL())
form = PackageUpdateConfigFrom(obj=package.update_config) form = PackageUpdateConfigFrom(obj=package.update_config)
if request.method == "GET": if request.method == "GET":
@ -300,7 +294,7 @@ def update_config(package):
if form.validate_on_submit(): if form.validate_on_submit():
if form.disable.data: if form.disable.data:
flash(gettext("Deleted update configuration"), "success") flash("Deleted update configuration", "success")
if package.update_config: if package.update_config:
db.session.delete(package.update_config) db.session.delete(package.update_config)
db.session.commit() db.session.commit()
@ -308,10 +302,10 @@ def update_config(package):
set_update_config(package, form) set_update_config(package, form)
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("Now, please create an initial release", "success")
return redirect(package.getURL("packages.create_release")) return redirect(package.getCreateReleaseURL())
return redirect(package.getURL("packages.list_releases")) return redirect(package.getReleaseListURL())
return render_template("packages/update_config.html", package=package, form=form) return render_template("packages/update_config.html", package=package, form=form)
@ -324,7 +318,7 @@ def setup_releases(package):
abort(403) abort(403)
if package.update_config: if package.update_config:
return redirect(package.getURL("packages.update_config")) return redirect(package.getUpdateConfigURL())
return render_template("packages/release_wizard.html", package=package) return render_template("packages/release_wizard.html", package=package)

View File

@ -13,9 +13,6 @@
# #
# 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 flask_babel import gettext, lazy_gettext
from . import bp from . import bp
@ -24,10 +21,8 @@ from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import *
from wtforms.validators import * from wtforms.validators import *
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \ from app.models import db, PackageReview, Thread, ThreadReply, NotificationType
Permission, AuditSeverity from app.utils import is_package_page, addNotification, get_int_or_abort
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
from app.tasks.webhooktasks import post_discord_webhook
@bp.route("/reviews/") @bp.route("/reviews/")
@ -40,19 +35,18 @@ def list_reviews():
class ReviewForm(FlaskForm): class ReviewForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)]) title = StringField("Title", [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)]) comment = TextAreaField("Comment", [InputRequired(), Length(10, 2000)])
recommends = RadioField(lazy_gettext("Private"), [InputRequired()], recommends = RadioField("Private", [InputRequired()], choices=[("yes", "Yes"), ("no", "No")])
choices=[("yes", lazy_gettext("Yes")), ("no", lazy_gettext("No"))]) submit = SubmitField("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
@is_package_page @is_package_page
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("You can't review your own package!", "danger")
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
review = PackageReview.query.filter_by(package=package, author=current_user).first() review = PackageReview.query.filter_by(package=package, author=current_user).first()
@ -114,31 +108,22 @@ def review(package):
addNotification(package.maintainers, current_user, type, notif_msg, addNotification(package.maintainers, current_user, type, notif_msg,
url_for("threads.view", id=thread.id), package) url_for("threads.view", id=thread.id), package)
if was_new:
post_discord_webhook.delay(thread.author.username,
"Reviewed {}: {}".format(package.title, thread.getViewURL(absolute=True)), False)
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
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.checkPerm(current_user, Permission.DELETE_REVIEW):
abort(403)
thread = review.thread thread = review.thread
reply = ThreadReply() reply = ThreadReply()
@ -149,92 +134,10 @@ def delete_review(package, reviewer):
thread.review = None thread.review = None
msg = "Converted review by {} to thread".format(review.author.display_name)
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
current_user, msg, thread.getViewURL(), thread.package)
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title) notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package) addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
db.session.delete(review) db.session.delete(review)
package.recalcScore()
db.session.commit() db.session.commit()
return redirect(thread.getViewURL()) return redirect(thread.getViewURL())
def handle_review_vote(package: Package, review_id: int):
if current_user in package.maintainers:
flash(gettext("You can't vote on the reviews on your own package!"), "danger")
return
review: PackageReview = PackageReview.query.get(review_id)
if review is None or review.package != package:
abort(404)
if review.author == current_user:
flash(gettext("You can't vote on your own reviews!"), "danger")
return
is_positive = isYes(request.form["is_positive"])
vote = PackageReviewVote.query.filter_by(review=review, user=current_user).first()
if vote is None:
vote = PackageReviewVote()
vote.review = review
vote.user = current_user
vote.is_positive = is_positive
db.session.add(vote)
elif vote.is_positive == is_positive:
db.session.delete(vote)
else:
vote.is_positive = is_positive
review.update_score()
db.session.commit()
@bp.route("/packages/<author>/<name>/review/<int:review_id>/", methods=["POST"])
@login_required
@is_package_page
def review_vote(package, review_id):
handle_review_vote(package, review_id)
next_url = request.args.get("r")
if next_url and is_safe_url(next_url):
return redirect(next_url)
else:
return redirect(review.thread.getViewURL())
@bp.route("/packages/<author>/<name>/review-votes/")
@rank_required(UserRank.ADMIN)
@is_package_page
def review_votes(package):
user_biases = {}
for review in package.reviews:
review_sign = 1 if review.recommends else -1
for vote in review.votes:
user_biases[vote.user.username] = user_biases.get(vote.user.username, [0, 0])
vote_sign = 1 if vote.is_positive else -1
vote_bias = review_sign * vote_sign
if vote_bias == 1:
user_biases[vote.user.username][0] += 1
else:
user_biases[vote.user.username][1] += 1
BiasInfo = namedtuple("BiasInfo", "username balance with_ against no_vote perc_with")
user_biases_info = []
for username, bias in user_biases.items():
total_votes = bias[0] + bias[1]
balance = bias[0] - bias[1]
perc_with = round((100 * bias[0]) / total_votes)
user_biases_info.append(BiasInfo(username, balance, bias[0], bias[1], len(package.reviews) - total_votes, perc_with))
user_biases_info.sort(key=lambda x: -abs(x.balance))
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews,
user_biases=user_biases_info)

View File

@ -16,11 +16,10 @@
from flask import * from flask import *
from flask_babel import gettext, lazy_gettext
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_login import login_required from flask_login import login_required
from wtforms import * from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import * from wtforms.validators import *
from app.utils import * from app.utils import *
@ -30,20 +29,20 @@ from app.logic.screenshots import do_create_screenshot, do_order_screenshots
class CreateScreenshotForm(FlaskForm): class CreateScreenshotForm(FlaskForm):
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)]) title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
fileUpload = FileField(lazy_gettext("File Upload"), [InputRequired()]) fileUpload = FileField("File Upload", [InputRequired()])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField("Save")
class EditScreenshotForm(FlaskForm): class EditScreenshotForm(FlaskForm):
title = StringField(lazy_gettext("Title/Caption"), [Optional(), Length(-1, 100)]) title = StringField("Title/Caption", [Optional(), Length(-1, 100)])
approved = BooleanField(lazy_gettext("Is Approved")) approved = BooleanField("Is Approved")
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField("Save")
class EditPackageScreenshotsForm(FlaskForm): class EditPackageScreenshotsForm(FlaskForm):
cover_image = QuerySelectField(lazy_gettext("Cover Image"), [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title) cover_image = QuerySelectField("Cover Image", [DataRequired()], allow_blank=True, get_pk=lambda a: a.id, get_label=lambda a: a.title)
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField("Save")
@bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"]) @bp.route("/packages/<author>/<name>/screenshots/", methods=["GET", "POST"])
@ -51,10 +50,10 @@ class EditPackageScreenshotsForm(FlaskForm):
@is_package_page @is_package_page
def screenshots(package): def screenshots(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS): if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
if package.screenshots.count() == 0: if package.screenshots.count() == 0:
return redirect(package.getURL("packages.create_screenshot")) return redirect(package.getNewScreenshotURL())
form = EditPackageScreenshotsForm(obj=package) form = EditPackageScreenshotsForm(obj=package)
form.cover_image.query = package.screenshots form.cover_image.query = package.screenshots
@ -64,7 +63,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.getURL("packages.view")) return redirect(package.getDetailsURL())
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")
@ -81,14 +80,14 @@ def screenshots(package):
@is_package_page @is_package_page
def create_screenshot(package): def create_screenshot(package):
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS): if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
return redirect(package.getURL("packages.view")) return redirect(package.getDetailsURL())
# 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.fileUpload.data, False) do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
return redirect(package.getURL("packages.screenshots")) return redirect(package.getEditScreenshotsURL())
except LogicError as e: except LogicError as e:
flash(e.message, "danger") flash(e.message, "danger")
@ -106,7 +105,7 @@ def edit_screenshot(package, id):
canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS) canEdit = package.checkPerm(current_user, Permission.ADD_SCREENSHOTS)
canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT) canApprove = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT)
if not (canEdit or canApprove): if not (canEdit or canApprove):
return redirect(package.getURL("packages.screenshots")) return redirect(package.getEditScreenshotsURL())
# 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)
@ -122,7 +121,7 @@ def edit_screenshot(package, id):
screenshot.approved = wasApproved screenshot.approved = wasApproved
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.screenshots")) return redirect(package.getEditScreenshotsURL())
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)
@ -136,7 +135,7 @@ def delete_screenshot(package, id):
abort(404) abort(404)
if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS): if not package.checkPerm(current_user, Permission.ADD_SCREENSHOTS):
flash(gettext("Permission denied"), "danger") flash("Permission denied", "danger")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
if package.cover_image == screenshot: if package.cover_image == screenshot:
@ -146,4 +145,4 @@ def delete_screenshot(package, id):
db.session.delete(screenshot) db.session.delete(screenshot)
db.session.commit() db.session.commit()
return redirect(package.getURL("packages.screenshots")) return redirect(package.getEditScreenshotsURL())

View File

@ -1,64 +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 Blueprint, request, render_template, url_for
from flask_babel import lazy_gettext
from flask_login import current_user
from flask_wtf import FlaskForm
from werkzeug.utils import redirect
from wtforms import TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length
from app.models import User, UserRank
from app.tasks.emails import send_user_email
from app.tasks.webhooktasks import post_discord_webhook
from app.utils import isNo, abs_url_samesite
bp = Blueprint("report", __name__)
class ReportForm(FlaskForm):
message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)])
submit = SubmitField(lazy_gettext("Report"))
@bp.route("/report/", methods=["GET", "POST"])
def report():
is_anon = not current_user.is_authenticated or not isNo(request.args.get("anon"))
url = request.args.get("url")
if url:
url = abs_url_samesite(url)
form = ReportForm(formdata=request.form)
if form.validate_on_submit():
if current_user.is_authenticated:
user_info = f"{current_user.username}"
else:
user_info = request.headers.get("X-Forwarded-For") or request.remote_addr
text = f"{url}\n\n{form.message.data}"
task = None
for admin in User.query.filter_by(rank=UserRank.ADMIN).all():
task = send_user_email.delay(admin.email, admin.locale or "en",
f"User report from {user_info}", text)
post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True)
return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home")))
return render_template("report/index.html", form=form, url=url, is_anon=is_anon)

View File

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

View File

@ -17,7 +17,7 @@
from celery import uuid from celery import uuid
from flask import * from flask import *
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import or_, and_ from sqlalchemy import or_
from app.models import * from app.models import *
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
@ -63,24 +63,18 @@ def view_editor():
else: else:
abort(400) 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_packages = Package.query.filter_by(state=PackageState.APPROVED).count()
total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count() total_to_tag = Package.query.filter_by(state=PackageState.APPROVED, tags=None).count()
unfulfilled_meta_packages = MetaPackage.query \ unfulfilled_meta_packages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \ .filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(Package.state == PackageState.APPROVED, optional=False)) \ .filter(MetaPackage.dependencies.any(optional=False)) \
.order_by(db.asc(MetaPackage.name)).count() .order_by(db.asc(MetaPackage.name)).count()
return render_template("todo/editor.html", current_tab="editor", return render_template("todo/editor.html", current_tab="editor",
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots, packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn, canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag, total_packages=total_packages, total_to_tag=total_to_tag,
unfulfilled_meta_packages=unfulfilled_meta_packages) unfulfilled_meta_packages=unfulfilled_meta_packages)
@ -99,7 +93,7 @@ def topics():
page = get_int_or_abort(request.args.get("page"), 1) page = get_int_or_abort(request.args.get("page"), 1)
num = get_int_or_abort(request.args.get("n"), 100) num = get_int_or_abort(request.args.get("n"), 100)
if num > 100 and not current_user.rank.atLeast(UserRank.APPROVER): if num > 100 and not current_user.rank.atLeast(UserRank.EDITOR):
num = 100 num = 100
query = query.paginate(page, num, True) query = query.paginate(page, num, True)
@ -160,7 +154,7 @@ def view_user(username=None):
if not user: if not user:
abort(404) abort(404)
if current_user != user and not current_user.rank.atLeast(UserRank.APPROVER): if current_user != user and not current_user.rank.atLeast(UserRank.EDITOR):
abort(403) abort(403)
unapproved_packages = user.packages \ unapproved_packages = user.packages \
@ -168,11 +162,6 @@ def view_user(username=None):
Package.state == PackageState.CHANGES_NEEDED)) \ Package.state == PackageState.CHANGES_NEEDED)) \
.order_by(db.asc(Package.created_at)).all() .order_by(db.asc(Package.created_at)).all()
packages_with_small_screenshots = user.maintained_packages \
.filter(Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
.all()
outdated_packages = user.maintained_packages \ outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED, .filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \ Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
@ -185,14 +174,12 @@ def view_user(username=None):
.all() .all()
needs_tags = user.maintained_packages \ needs_tags = user.maintained_packages \
.filter(Package.state != PackageState.DELETED, Package.tags==None) \ .filter(Package.state != PackageState.DELETED) \
.order_by(db.asc(Package.title)).all() .filter_by(tags=None).order_by(db.asc(Package.title)).all()
return render_template("todo/user.html", current_tab="user", user=user, return render_template("todo/user.html", current_tab="user", user=user,
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages, unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
needs_tags=needs_tags, topics_to_add=topics_to_add, needs_tags=needs_tags, topics_to_add=topics_to_add)
packages_with_small_screenshots=packages_with_small_screenshots,
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"]) @bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])
@ -234,8 +221,8 @@ def apply_all_updates(username):
msg = "Created release {} (Applied all Git Update Detection)".format(rel.title) msg = "Created release {} (Applied all Git Update Detection)".format(rel.title)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg,
rel.getURL("packages.create_edit"), package) rel.getEditURL(), package)
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()
return redirect(url_for("todo.view_user", username=username)) return redirect(url_for("todo.view_user", username=username))

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 * from flask import *
from flask_babel import gettext, lazy_gettext, get_locale
from flask_login import current_user, login_required, logout_user, login_user from flask_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_
@ -26,24 +24,23 @@ from wtforms.validators import *
from app.models 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 randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \ from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, nonEmptyOrNone
nonEmptyOrNone, post_login, is_username_valid
from passlib.pwd import genphrase from passlib.pwd import genphrase
from . import bp from . import bp
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
username = StringField(lazy_gettext("Username or email"), [InputRequired()]) username = StringField("Username or email", [InputRequired()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)]) password = PasswordField("Password", [InputRequired(), Length(6, 100)])
remember_me = BooleanField(lazy_gettext("Remember me"), default=True) remember_me = BooleanField("Remember me", default=True)
submit = SubmitField(lazy_gettext("Sign in")) submit = SubmitField("Sign in")
def handle_login(form): def handle_login(form):
def show_safe_err(err): def show_safe_err(err):
if "@" in username: if "@" in username:
flash(gettext("Incorrect email or password"), "danger") flash("Incorrect email or password", "danger")
else: else:
flash(err, "danger") flash(err, "danger")
@ -51,24 +48,27 @@ def handle_login(form):
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:
return show_safe_err(gettext(u"User %(username)s does not exist", username=username)) return show_safe_err("User {} does not exist".format(username))
if not check_password_hash(user.password, form.password.data): if not check_password_hash(user.password, form.password.data):
return show_safe_err(gettext(u"Incorrect password. Did you set one?")) return show_safe_err("Incorrect password. Did you set one?")
if not user.is_active: if not user.is_active:
flash(gettext("You need to confirm the registration email"), "danger") flash("You need to confirm the registration email", "danger")
return return
addAuditLog(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): login_user(user, remember=form.remember_me.data)
flash(gettext("Login failed"), "danger") flash("Logged in successfully.", "success")
return
return post_login(user, request.args.get("next")) next = request.args.get("next")
if next and not is_safe_url(next):
abort(400)
return redirect(next or url_for("homepage.home"))
@bp.route("/user/login/", methods=["GET", "POST"]) @bp.route("/user/login/", methods=["GET", "POST"])
@ -100,23 +100,19 @@ def logout():
class RegisterForm(FlaskForm): class RegisterForm(FlaskForm):
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone]) display_name = StringField("Display Name", [Optional(), Length(1, 20)], filters=[lambda x: nonEmptyOrNone(x)])
username = StringField(lazy_gettext("Username"), [InputRequired(), username = StringField("Username", [InputRequired(),
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))]) Regexp("^[a-zA-Z0-9._-]+$", message="Only a-zA-Z0-9._ allowed")])
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()]) email = StringField("Email", [InputRequired(), Email()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)]) password = PasswordField("Password", [InputRequired(), Length(6, 100)])
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()]) question = StringField("What is the result of the above calculation?", [InputRequired()])
agree = BooleanField(lazy_gettext("I agree"), [DataRequired()]) agree = BooleanField("I agree", [DataRequired()])
submit = SubmitField(lazy_gettext("Register")) submit = SubmitField("Register")
def handle_register(form): def handle_register(form):
if form.question.data.strip().lower() != "19": if form.question.data.strip().lower() != "19":
flash(gettext("Incorrect captcha answer"), "danger") flash("Incorrect captcha answer", "danger")
return
if not is_username_valid(form.username.data):
flash(gettext("Username is invalid"))
return return
user_by_name = User.query.filter(or_( user_by_name = User.query.filter(or_(
@ -127,27 +123,22 @@ def handle_register(form):
User.github_username == form.username.data)).first() User.github_username == form.username.data)).first()
if user_by_name: if user_by_name:
if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username: if user_by_name.rank == UserRank.NOT_JOINED and user_by_name.forums_username:
flash(gettext("An account already exists for that username but hasn't been claimed yet."), "danger") flash("An account already exists for that username but hasn't been claimed yet.", "danger")
return redirect(url_for("users.claim_forums", username=user_by_name.forums_username)) return redirect(url_for("users.claim_forums", username=user_by_name.forums_username))
else: else:
flash(gettext("That username/display name is already in use, please choose another."), "danger") flash("That username/display name is already in use, please choose another.", "danger")
return return
alias_by_name = PackageAlias.query.filter(or_(
PackageAlias.author==form.username.data,
PackageAlias.author==form.display_name.data)).first()
if alias_by_name:
flash(gettext("That username/display name is already in use, please choose another."), "danger")
return
user_by_email = User.query.filter_by(email=form.email.data).first() 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, "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.", "We were unable to create the account as the email is already in use by {}. Try a different email address.".format(
display_name=user_by_email.display_name)) user_by_email.display_name))
return redirect(url_for("users.email_sent")) flash("Check your email address to verify your account", "success")
return redirect(url_for("homepage.home"))
elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0: elif EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(gettext("That email address has been unsubscribed/blacklisted, and cannot be used"), "danger") flash("That email address has been unsubscribed/blacklisted, and cannot be used", "danger")
return return
user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data)) user = User(form.username.data, False, form.email.data, make_flask_login_password(form.password.data))
@ -168,9 +159,10 @@ 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")) flash("Check your email address to verify your account", "success")
return redirect(url_for("homepage.home"))
@bp.route("/user/register/", methods=["GET", "POST"]) @bp.route("/user/register/", methods=["GET", "POST"])
@ -186,8 +178,8 @@ def register():
class ForgotPasswordForm(FlaskForm): class ForgotPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()]) email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password")) submit = SubmitField("Reset Password")
@bp.route("/user/forgot-password/", methods=["GET", "POST"]) @bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password(): def forgot_password():
@ -209,37 +201,42 @@ 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>
If you weren't expecting to receive this email, then you can safely ignore it.
</p>
""")
return redirect(url_for("users.email_sent")) flash("Check your email address to continue the reset", "success")
return redirect(url_for("homepage.home"))
return render_template("users/forgot_password.html", form=form) return render_template("users/forgot_password.html", form=form)
class SetPasswordForm(FlaskForm): class SetPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [Optional(), Email()]) email = StringField("Email", [Optional(), Email()])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)]) password = PasswordField("New password", [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100), password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))]) submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
class ChangePasswordForm(FlaskForm): class ChangePasswordForm(FlaskForm):
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)]) old_password = PasswordField("Old password", [InputRequired(), Length(8, 100)])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)]) password = PasswordField("New password", [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100), password2 = PasswordField("Verify password", [InputRequired(), Length(8, 100), validators.EqualTo('password', message='Passwords must match')])
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))]) submit = SubmitField("Save")
submit = SubmitField(lazy_gettext("Save"))
def handle_set_password(form): def handle_set_password(form):
one = form.password.data one = form.password.data
two = form.password2.data two = form.password2.data
if one != two: if one != two:
flash(gettext("Passwords do not match"), "danger") flash("Passwords do not much", "danger")
return return
addAuditLog(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))
@ -250,31 +247,19 @@ def handle_set_password(form):
newEmail = nonEmptyOrNone(form.email.data) newEmail = nonEmptyOrNone(form.email.data)
if newEmail and newEmail != 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("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() token = randomString(32)
if user_by_email:
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
else:
token = randomString(32)
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = current_user ver.user = current_user
ver.token = token ver.token = token
ver.email = newEmail ver.email = newEmail
db.session.add(ver) db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language)
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("users.email_sent"))
db.session.commit() db.session.commit()
flash(gettext("Your password has been changed successfully."), "success") flash("Your password has been changed successfully.", "success")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
@ -289,7 +274,7 @@ def change_password():
if ret: if ret:
return ret return ret
else: else:
flash(gettext("Old password is incorrect"), "danger") flash("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")) suggested_password=genphrase(entropy=52, wordset="bip39"))
@ -317,17 +302,9 @@ def set_password():
@bp.route("/user/verify/") @bp.route("/user/verify/")
def verify_email(): def verify_email():
token = request.args.get("token") token = request.args.get("token")
ver: UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first() ver : UserEmailVerification = UserEmailVerification.query.filter_by(token=token).first()
if ver is None: if ver is None:
flash(gettext("Unknown verification token!"), "danger") flash("Unknown verification token!", "danger")
return redirect(url_for("homepage.home"))
delta = (datetime.datetime.now() - ver.created_at)
delta: datetime.timedelta
if delta.total_seconds() > 12*60*60:
flash(gettext("Token has expired"), "danger")
db.session.delete(ver)
db.session.commit()
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
user = ver.user user = ver.user
@ -339,16 +316,15 @@ def verify_email():
if ver.email and user.email != ver.email: if ver.email and user.email != ver.email:
if User.query.filter_by(email=ver.email).count() > 0: if User.query.filter_by(email=ver.email).count() > 0:
flash(gettext("Another user is already using that email"), "danger") flash("Another user is already using that email", "danger")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
flash(gettext("Confirmed email change"), "success") flash("Confirmed email change", "success")
if user.email: if user.email:
send_user_email.delay(user.email, send_user_email.delay(user.email,
user.locale or "en", "Email address changed",
gettext("Email address changed"), "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."))
user.is_active = True user.is_active = True
user.email = ver.email user.email = ver.email
@ -366,15 +342,15 @@ def verify_email():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("users.profile", username=current_user.username)) return redirect(url_for("users.profile", username=current_user.username))
elif was_activating: elif was_activating:
flash(gettext("You may now log in"), "success") flash("You may now log in", "success")
return redirect(url_for("users.login")) return redirect(url_for("users.login"))
else: else:
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
class UnsubscribeForm(FlaskForm): class UnsubscribeForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()]) email = StringField("Email", [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Send")) submit = SubmitField("Send")
def unsubscribe_verify(): def unsubscribe_verify():
@ -388,9 +364,10 @@ def unsubscribe_verify():
sub.token = randomString(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")) flash("Check your email address to continue the unsubscribe", "success")
return redirect(url_for("homepage.home"))
return render_template("users/unsubscribe.html", form=form) return render_template("users/unsubscribe.html", form=form)
@ -405,7 +382,7 @@ def unsubscribe_manage(sub: EmailSubscription):
sub.blacklisted = True sub.blacklisted = True
db.session.commit() db.session.commit()
flash(gettext("That email is now blacklisted. Please contact an admin if you wish to undo this."), "success") flash("That email is now blacklisted. Please contact an admin if you wish to undo this.", "success")
return redirect(url_for("homepage.home")) return redirect(url_for("homepage.home"))
return render_template("users/unsubscribe.html", user=user) return render_template("users/unsubscribe.html", user=user)
@ -420,8 +397,3 @@ def unsubscribe():
return unsubscribe_manage(sub) return unsubscribe_manage(sub)
return unsubscribe_verify() return unsubscribe_verify()
@bp.route("/email_sent/")
def email_sent():
return render_template("users/email_sent.html")

View File

@ -13,14 +13,19 @@
# #
# 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 . 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 randomString, login_user_set_active, is_username_valid from app.utils import randomString, login_user_set_active
from app.tasks.forumtasks import checkForumAccount from app.tasks.forumtasks import checkForumAccount
from app.utils.phpbbparser import getProfile from app.utils.phpbbparser import getProfile
import re
def check_username(username):
return username is not None and len(username) >= 2 and re.match("^[A-Za-z0-9._-]*$", username)
@bp.route("/user/claim/", methods=["GET", "POST"]) @bp.route("/user/claim/", methods=["GET", "POST"])
@ -36,17 +41,17 @@ def claim_forums():
else: else:
method = request.args.get("method") method = request.args.get("method")
if not is_username_valid(username): if not check_username(username):
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger") flash("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.atLeast(UserRank.NEW_MEMBER): if user and user.rank.atLeast(UserRank.NEW_MEMBER):
flash(gettext("User has already been claimed"), "danger") flash("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":
if user is None or user.github_username is None: if user is None or user.github_username is None:
flash(gettext("Unable to get GitHub username for user"), "danger") flash("Unable to get GitHub username for user", "danger")
return redirect(url_for("users.claim_forums", username=username)) return redirect(url_for("users.claim_forums", username=username))
else: else:
return redirect(url_for("github.start")) return redirect(url_for("github.start"))
@ -61,15 +66,15 @@ def claim_forums():
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 check_username(username):
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger") flash("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin", "danger")
elif ctype == "github": elif ctype == "github":
task = checkForumAccount.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.atLeast(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("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
@ -83,11 +88,11 @@ def claim_forums():
else: else:
message = str(e) message = str(e)
flash(gettext(u"Error whilst attempting to access forums: %(message)s", message=message), "danger") flash("Error whilst attempting to access forums: " + message, "danger")
return redirect(url_for("users.claim_forums", username=username)) return redirect(url_for("users.claim_forums", username=username))
if profile is None: if profile is None:
flash(gettext("Unable to get forum signature - does the user exist?"), "danger") flash("Unable to get forum signature - does the user exist?", "danger")
return redirect(url_for("users.claim_forums", username=username)) return redirect(url_for("users.claim_forums", username=username))
# Look for key # Look for key
@ -100,17 +105,16 @@ def claim_forums():
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
ret = login_user_set_active(user, remember=True) if login_user_set_active(user, remember=True):
if ret is None: return redirect(url_for("users.set_password"))
flash(gettext("Unable to login as user"), "danger") else:
flash("Unable to login as user", "danger")
return redirect(url_for("users.claim_forums", username=username)) return redirect(url_for("users.claim_forums", username=username))
return ret
else: else:
flash(gettext("Could not find the key in your signature!"), "danger") flash("Could not find the key in your signature!", "danger")
return redirect(url_for("users.claim_forums", username=username)) return redirect(url_for("users.claim_forums", username=username))
else: else:
flash(gettext("Unknown claim type"), "danger") flash("Unknown claim type", "danger")
return render_template("users/claim_forums.html", username=username, key="cdb_" + token) return render_template("users/claim_forums.html", username=username, key="cdb_" + token)

View File

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

View File

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

View File

@ -11,11 +11,6 @@ def populate(session):
admin_user.rank = UserRank.ADMIN admin_user.rank = UserRank.ADMIN
session.add(admin_user) session.add(admin_user)
system_user = User("ContentDB", active=False)
system_user.email_confirmed_at = datetime.datetime.now() - datetime.timedelta(days=6000)
system_user.rank = UserRank.BOT
session.add(system_user)
session.add(MinetestRelease("None", 0)) session.add(MinetestRelease("None", 0))
session.add(MinetestRelease("0.4.16/17", 32)) session.add(MinetestRelease("0.4.16/17", 32))
session.add(MinetestRelease("5.0", 37)) session.add(MinetestRelease("5.0", 37))
@ -27,7 +22,7 @@ def populate(session):
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", "Featured"]: "PvP", "PvE", "Survival", "Creative", "Puzzle", "Multiplayer", "Singleplayer"]:
row = Tag(tag) row = Tag(tag)
tags[row.name] = row tags[row.name] = row
session.add(row) session.add(row)

View File

@ -1,17 +1,14 @@
title: Help title: Help
toc: False toc: False
## General Help ## General Help
* [Frequently Asked Questions](faq)
* [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) * [Reporting Content](reporting)
* [Top Packages Algorithm](top_packages) * [Top Packages Algorithm](top_packages)
* [Featured Packages](featured)
## Help for Package Authors ## Help for Package Authors

View File

@ -1,11 +1,5 @@
title: API title: API
## Resources
* [How the Minetest client uses the API](https://github.com/minetest/contentdb/blob/master/docs/minetest_client.md)
## Responses and Error Handling ## Responses and Error Handling
If there is an error, the response will be JSON similar to the following with a non-200 status code: If there is an error, the response will be JSON similar to the following with a non-200 status code:
@ -32,23 +26,6 @@ often other keys with information. For example:
``` ```
### Paginated Results
Some API endpoints returns results in pages. The page number is specified using the `page` query argument, and
the number of items is specified using `num`
The response will be a dictionary with the following keys:
* `page`: page number, integer from 1 to max
* `per_page`: number of items per page, same as `n`
* `page_count`: number of pages
* `total`: total number of results
* `urls`: dictionary containing
* `next`: url to next page
* `previous`: url to previous page
* `items`: array of items
## Authentication ## Authentication
Not all endpoints require authentication, but it is done using Bearer tokens: Not all endpoints require authentication, but it is done using Bearer tokens:
@ -78,8 +55,6 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `title`: Human-readable title. * `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved). * `name`: Technical name (needs permission if already approved).
* `short_description` * `short_description`
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
`LOOKING_FOR_MAINTAINER`.
* `tags`: List of [tag](#tags) names. * `tags`: List of [tag](#tags) names.
* `content_warnings`: List of [content warning](#content-warnings) names. * `content_warnings`: List of [content warning](#content-warnings) names.
* `license`: A [license](#licenses) name. * `license`: A [license](#licenses) name.
@ -89,43 +64,19 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/).
* `website`: Website URL. * `website`: Website URL.
* `issue_tracker`: Issue tracker URL. * `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID. * `forums`: forum topic ID.
* `video_url`: URL to a video.
* `game_support`: Array of game support information objects. Not currently documented, as subject to change.
* GET `/api/packages/<username>/<name>/dependencies/` * GET `/api/packages/<username>/<name>/dependencies/`
* Returns dependencies, with suggested candidates
* 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.
* GET `/api/dependencies/`
* Returns `provides` and raw dependencies for all packages.
* Supports [Package Queries](#package-queries)
* [Paginated result](#paginated-results), max 300 results per page
* Each item in `items` will be a dictionary with the following keys:
* `type`: One of `GAME`, `MOD`, `TXP`.
* `author`: Username of the package author.
* `name`: Package name.
* `provides`: List of technical mod names inside the package.
* `depends`: List of hard dependencies.
* Each dep will either be a metapackage dependency (`name`), or a
package dependency (`author/name`).
* `optional_depends`: list of optional dependencies
* Same as above.
You can download a package by building one of the two URLs:
```
https://content.minetest.net/packages/${author}/${name}/download/`
https://content.minetest.net/packages/${author}/${name}/releases/${release}/download/`
```
Examples: Examples:
```bash ```bash
# Edit package # Edit package
curl -X PUT https://content.minetest.net/api/packages/username/name/ \ curl -X PUT http://localhost:5123/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }' -d '{ "title": "Foo bar", "tags": ["pvp", "survival"], "license": "MIT" }'
# Remove website URL # Remove website URL
curl -X PUT https://content.minetest.net/api/packages/username/name/ \ curl -X PUT http://localhost:5123/api/packages/username/name/ \
-H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \
-d '{ "website": null }' -d '{ "website": null }'
``` ```
@ -226,7 +177,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)
@ -234,19 +184,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.
The resolutions returned may change in the future, and we may move to a more capable thumbnail generation.
Examples: Examples:
@ -255,11 +198,6 @@ Examples:
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \ -H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -F title="My Release" -F file=@path/to/screnshot.png
# Create screenshot and set it as the cover image
curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \
-H "Authorization: Bearer YOURTOKEN" \
-F title="My Release" -F file=@path/to/screnshot.png -F is_cover_image="true"
# Delete screenshot # Delete screenshot
curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \ curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \
@ -269,74 +207,26 @@ 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
* GET `/api/packages/<username>/<name>/reviews/` (List)
* Returns array of review dictionaries with keys:
* `user`: dictionary with `display_name` and `username`.
* `title`: review title
* `comment`: the text
* `is_positive`: boolean
* `created_at`: iso timestamp
* `votes`: dictionary with `helpful` and `unhelpful`,
* GET `/api/reviews/` (List)
* Returns a paginated response. This is a dictionary with `page`, `url`, and `items`.
* [Paginated result](#paginated-results)
* `items`: array of review dictionaries, like above
* Each review also has a `package` dictionary with `type`, `author` and `name`
* Query arguments:
* `page`: page number, integer from 1 to max
* `n`: number of results per page, max 100
* `author`: filter by review author username
* `is_positive`: true or false. Default: null
* `q`: filter by title (case insensitive, no fulltext search)
Example:
```json
[
{
"comment": "This is a really good mod!",
"created_at": "2021-11-24T16:18:33.764084",
"is_positive": true,
"title": "Really good",
"user": {
"display_name": "rubenwardy",
"username": "rubenwardy"
},
"votes": {
"helpful": 0,
"unhelpful": 0
}
}
]
``` ```
## Topics ## Topics
* GET `/api/topics/` ([View](/api/topics/)) * GET `/api/topics/` ([View](/api/topics/)): Supports [Package Queries](#package-queries), and the following two options:
* See [Topic Queries](#topic-queries) * `show_added`: Show topics which exist as packages, default true.
* `show_discarded`: Show topics which have been marked as outdated, default false.
### Topic Queries ### Topic Queries
Example: Example:
/api/topics/?q=mobs&type=mod&type=game /api/topics/?q=mobs
Supported query parameters: Supported query parameters:
* `q`: Query string. * `q`: Query string.
* `type`: Package types (`mod`, `game`, `txp`). * `sort`: Sort by (`name`, `views`, `date`).
* `sort`: Sort by (`name`, `views`, `created_at`). * `order`: Sort ascending (`asc`) or descending (`desc`).
* `show_added`: Show topics that have an existing package. * `show_added`: Show topics that have an existing package.
* `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.
@ -346,11 +236,9 @@ Supported query parameters:
### Tags ### Tags
* GET `/api/tags/` ([View](/api/tags/)): List of: * GET `/api/tags/` ([View](/api/tags/)): List of:
* `name`: technical name. * `name`: technical name
* `title`: human-readable title. * `title`: human-readable title
* `description`: tag description or null. * `description`: tag description or null
* `is_protected`: boolean, whether the tag is protected (can only be set by Editors in the web interface).
* `views`: number of views of this tag.
### Content Warnings ### Content Warnings
@ -394,5 +282,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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,8 +50,6 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `title`: Human-readable title. * `title`: Human-readable title.
* `name`: Technical name (needs permission if already approved). * `name`: Technical name (needs permission if already approved).
* `short_description` * `short_description`
* `dev_state`: One of `WIP`, `BETA`, `ACTIVELY_DEVELOPED`, `MAINTENANCE_ONLY`, `AS_IS`, `DEPRECATED`,
`LOOKING_FOR_MAINTAINER`.
* `tags`: List of tag names, see [/api/tags/](/api/tags/). * `tags`: List of tag names, see [/api/tags/](/api/tags/).
* `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/). * `content_warnings`: List of content warning names, see [/api/content_warnings/](/api/content_warnings/).
* `license`: A license name, see [/api/licenses/](/api/licenses/). * `license`: A license name, see [/api/licenses/](/api/licenses/).
@ -61,7 +59,6 @@ It should be a JSON dictionary with one or more of the following optional keys:
* `website`: Website URL. * `website`: Website URL.
* `issue_tracker`: Issue tracker URL. * `issue_tracker`: Issue tracker URL.
* `forums`: forum topic ID. * `forums`: forum topic ID.
* `video_url`: URL to a video.
Use `null` to unset fields where relevant. Use `null` to unset fields where relevant.

View File

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

View File

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

View File

@ -27,7 +27,7 @@ including ones not covered by this document, and to ban users who abuse this ser
### 2.1. Acceptable Content ### 2.1. Acceptable Content
Sexually-orientated content is not permitted. Sexually-orientated content is not permitted.
If in doubt at what this means, [contact us by raising a report](/report/). If in doubt at what this means, [contact us by raising a report](/help/reporting/).
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/).
@ -46,9 +46,6 @@ but still has value. Note that this doesn't mean that you should add a thing
you started working on yesterday, it's worth adding all the basic stuff to you started working on yesterday, it's worth adding all the basic stuff to
make your package useful. make your package useful.
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
as this will help advise players.
Adding non-player facing mods, such as libraries and server tools, is perfectly fine Adding non-player facing mods, such as libraries and server tools, is perfectly fine
and encouraged. ContentDB isn't just for player-facing things, and adding and encouraged. ContentDB isn't just for player-facing things, and adding
libraries allows them to be installed when a mod depends on it. libraries allows them to be installed when a mod depends on it.
@ -137,20 +134,6 @@ ContentDB is for the community. We may remove any promotions if we feel that
they're inappropriate. they're inappropriate.
## 6. Reviews and Package Score ## 6. Reporting Violations
You may invite players to review your package(s). One way to do this is by sharing the link found in the See the [Reporting Content](/help/reporting/) page.
"Share and Badges" page of the package's settings.
You must not require anyone to review a package. You must not promise or provide incentives for reviewing a package,
including but not limited to monetary rewards, in-game items, features, and/or privileges.
You may give a cosmetic-only role or badge to those who review your package - this must not be tied to the content or
rating of the review.
You must not attempt to unfairly manipulate your package's ranking, whether by reviews or any other method.
Doing so may result in temporary or permanent suspension from ContentDB.
## 7. Reporting Violations
Please click "Report" on the package page.

View File

@ -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](https://content.minetest.net/report/?anon=0) if you Please [raise a report](https://content.minetest.net/help/reporting/) 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,188 +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, Optional, Iterator, Iterable
from app.logic.LogicError import LogicError
from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport, db
"""
get_game_support(package):
if package is a game:
return [ package ]
for all hard dependencies:
support = support AND get_meta_package_support(dep)
return support
get_meta_package_support(meta):
for package implementing meta package:
support = support OR get_game_support(package)
return support
"""
minetest_game_mods = {
"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
}
mtg_mod_blacklist = {
"repixture", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
"survivethedays"
}
class PackageSet:
packages: Dict[str, Package]
def __init__(self, packages: Optional[Iterable[Package]] = None):
self.packages = {}
if packages:
self.update(packages)
def update(self, packages: Iterable[Package]):
for package in packages:
key = package.getId()
if key not in self.packages:
self.packages[key] = package
def intersection_update(self, other):
keys = set(self.packages.keys())
keys.difference_update(set(other.packages.keys()))
for key in keys:
del self.packages[key]
def __len__(self):
return len(self.packages)
def __iter__(self):
return self.packages.values().__iter__()
class GameSupportResolver:
checked_packages = set()
checked_metapackages = set()
resolved_packages: Dict[str, PackageSet] = {}
resolved_metapackages: Dict[str, PackageSet] = {}
def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> PackageSet:
print(f"Resolving for {meta.name}", file=sys.stderr)
key = meta.name
if key in self.resolved_metapackages:
return self.resolved_metapackages.get(key)
if key in self.checked_metapackages:
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
return PackageSet()
self.checked_metapackages.add(key)
retval = PackageSet()
for package in meta.packages:
if package.state != PackageState.APPROVED:
continue
if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist:
continue
ret = self.resolve(package, history)
if len(ret) == 0:
retval = PackageSet()
break
retval.update(ret)
self.resolved_metapackages[key] = retval
return retval
def resolve(self, package: Package, history: List[str]) -> PackageSet:
db.session.merge(package)
key = package.getId()
print(f"Resolving for {key}", file=sys.stderr)
history = history.copy()
history.append(key)
if package.type == PackageType.GAME:
return PackageSet([package])
if key in self.resolved_packages:
return self.resolved_packages.get(key)
if key in self.checked_packages:
print(f"Error, cycle found: {','.join(history)}", file=sys.stderr)
return PackageSet()
self.checked_packages.add(key)
if package.type != PackageType.MOD:
raise LogicError(500, "Got non-mod")
retval = PackageSet()
for dep in package.dependencies.filter_by(optional=False).all():
ret = self.resolve_for_meta_package(dep.meta_package, history)
if len(ret) == 0:
continue
elif len(retval) == 0:
retval.update(ret)
else:
retval.intersection_update(ret)
if len(retval) == 0:
raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games")
self.resolved_packages[key] = retval
return retval
def update_all(self) -> None:
for package in Package.query.filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all():
retval = self.resolve(package, [])
for game in retval:
support = PackageGameSupport(package, game)
db.session.add(support)
def update(self, package: Package) -> None:
previous_supported: Dict[str, PackageGameSupport] = {}
for support in package.supported_games.all():
previous_supported[support.game.getId()] = support
retval = self.resolve(package, [])
for game in retval:
assert game
lookup = previous_supported.pop(game.getId(), None)
if lookup is None:
support = PackageGameSupport(package, game)
db.session.add(support)
elif lookup.confidence == 0:
lookup.supports = True
db.session.merge(lookup)
for game, support in previous_supported.items():
if support.confidence == 0:
db.session.remove(support)

View File

@ -17,13 +17,10 @@
import re import re
import validators import validators
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
License, UserRank, PackageDevState
from app.utils import addAuditLog from app.utils import addAuditLog
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: str): def check(cond: bool, msg: str):
@ -37,24 +34,23 @@ def get_license(name):
license = License.query.filter(License.name.ilike(name)).first() license = License.query.filter(License.name.ilike(name)).first()
if license is None: if license is None:
raise LogicError(400, "Unknown license " + name) raise LogicError(400, "Unknown license: " + name)
return license return license
name_re = re.compile("^[a-z0-9_]+$") name_re = re.compile("^[a-z0-9_]+$")
AnyType = "?" any = "?"
ALLOWED_FIELDS = { ALLOWED_FIELDS = {
"type": AnyType, "type": any,
"title": str, "title": str,
"name": str, "name": str,
"short_description": str, "short_description": str,
"short_desc": str, "short_desc": str,
"dev_state": AnyType,
"tags": list, "tags": list,
"content_warnings": list, "content_warnings": list,
"license": AnyType, "license": any,
"media_license": AnyType, "media_license": any,
"long_description": str, "long_description": str,
"desc": str, "desc": str,
"repo": str, "repo": str,
@ -62,7 +58,6 @@ ALLOWED_FIELDS = {
"issue_tracker": str, "issue_tracker": str,
"issueTracker": str, "issueTracker": str,
"forums": int, "forums": int,
"video_url": str,
} }
ALIASES = { ALIASES = {
@ -85,14 +80,14 @@ def validate(data: dict):
if value is not None: if value is not None:
typ = ALLOWED_FIELDS.get(key) typ = ALLOWED_FIELDS.get(key)
check(typ is not None, key + " is not a known field") check(typ is not None, key + " is not a known field")
if typ != AnyType: if typ != any:
check(isinstance(value, typ), key + " must be a " + typ.__name__) check(isinstance(value, typ), key + " must be a " + typ.__name__)
if "name" in data: if "name" in data:
name = data["name"] name = data["name"]
check(isinstance(name, str), "Name must be a string") check(isinstance(name, str), "Name must be a string")
check(bool(name_re.match(name)), check(bool(name_re.match(name)),
lazy_gettext("Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)")) "Name can only contain lower case letters (a-z), digits (0-9), and underscores (_)")
for key in ["repo", "website", "issue_tracker", "issueTracker"]: for key in ["repo", "website", "issue_tracker", "issueTracker"]:
value = data.get(key) value = data.get(key)
@ -103,14 +98,13 @@ def validate(data: dict):
check(validators.url(value, public=True), key + " must be a valid URL") check(validators.url(value, public=True), key + " must be a valid URL")
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict, def do_edit_package(user: User, package: Package, was_new: bool, data: dict, reason: str = None):
reason: str = None):
if not package.checkPerm(user, Permission.EDIT_PACKAGE): if not package.checkPerm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, lazy_gettext("You do not have permission to edit this package")) raise LogicError(403, "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.checkPerm(user, Permission.CHANGE_NAME): not package.checkPerm(user, Permission.CHANGE_NAME):
raise LogicError(403, lazy_gettext("You do not have permission to change the package name")) raise LogicError(403, "You do not have permission to change the package name")
for alias, to in ALIASES.items(): for alias, to in ALIASES.items():
if alias in data: if alias in data:
@ -121,22 +115,14 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if "type" in data: if "type" in data:
data["type"] = PackageType.coerce(data["type"]) data["type"] = PackageType.coerce(data["type"])
if "dev_state" in data:
data["dev_state"] = PackageDevState.coerce(data["dev_state"])
if "license" in data: if "license" in data:
data["license"] = get_license(data["license"]) data["license"] = get_license(data["license"])
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: for key in ["name", "title", "short_desc", "desc", "type", "license", "media_license",
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"] "repo", "website", "issueTracker", "forums"]:
if "dQw4w9WgXcQ" in data["video_url"]:
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums", "video_url"]:
if key in data: if key in data:
setattr(package, key, data[key]) setattr(package, key, data[key])
@ -148,28 +134,15 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
package.provides.append(m) package.provides.append(m)
if "tags" in data: if "tags" in data:
old_tags = list(package.tags)
package.tags.clear() package.tags.clear()
for tag_id in data["tags"]: for tag_id in data["tags"]:
if is_int(tag_id): if is_int(tag_id):
tag = Tag.query.get(tag_id) package.tags.append(Tag.query.get(tag_id))
else: else:
tag = Tag.query.filter_by(name=tag_id).first() tag = Tag.query.filter_by(name=tag_id).first()
if tag is None: if tag is None:
raise LogicError(400, "Unknown tag: " + tag_id) raise LogicError(400, "Unknown tag: " + tag_id)
package.tags.append(tag)
if not was_web and tag.is_protected:
continue
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))
package.tags.append(tag)
if not was_web:
for tag in old_tags:
if tag.is_protected:
package.tags.append(tag)
if "content_warnings" in data: if "content_warnings" in data:
package.content_warnings.clear() package.content_warnings.clear()
@ -189,7 +162,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
msg = "Edited {} ({})".format(package.title, reason) msg = "Edited {} ({})".format(package.title, reason)
severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR
addAuditLog(severity, user, msg, package.getURL("packages.view"), package) addAuditLog(severity, user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()

View File

@ -18,7 +18,6 @@
import datetime, re import datetime, re
from celery import uuid from celery import uuid
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
@ -29,12 +28,12 @@ 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.checkPerm(user, Permission.MAKE_RELEASE): if not package.checkPerm(user, Permission.MAKE_RELEASE):
raise LogicError(403, lazy_gettext("You do not have permission to make releases")) raise LogicError(403, "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()
if count >= 5: if count >= 5:
raise LogicError(429, lazy_gettext("You've created too many releases for this package in the last 5 minutes, please wait before trying again")) raise LogicError(429, "You've created too many releases for this package in the last 5 minutes, please wait before trying again")
def do_create_vcs_release(user: User, package: Package, title: str, ref: str, def do_create_vcs_release(user: User, package: Package, title: str, ref: str,
@ -54,7 +53,7 @@ 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)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()
@ -71,7 +70,7 @@ def do_create_zip_release(user: User, package: Package, title: str, file,
if commit_hash: if commit_hash:
commit_hash = commit_hash.lower() commit_hash = commit_hash.lower()
if not (len(commit_hash) == 40 and re.match(r"^[0-9a-f]+$", commit_hash)): if not (len(commit_hash) == 40 and re.match(r"^[0-9a-f]+$", commit_hash)):
raise LogicError(400, lazy_gettext("Invalid commit hash; it must be a 40 character long base16 string")) raise LogicError(400, "Invalid commit hash; it must be a 40 character long base16 string")
uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file") uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file")
@ -89,7 +88,7 @@ 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)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()

View File

@ -1,21 +1,18 @@
import datetime, json import datetime, json
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 addNotification, addAuditLog from app.utils import addNotification, addAuditLog
from app.utils.image import get_image_size
def do_create_screenshot(user: User, package: Package, title: str, file, 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:
raise LogicError(429, lazy_gettext("Too many requests, please wait before trying again")) raise LogicError(429, "Too many requests, please wait before trying again")
uploaded_url, uploaded_path = upload_file(file, "image", lazy_gettext("a PNG or JPG image file")) uploaded_url, uploaded_path = upload_file(file, "image", "a PNG or JPG image file")
counter = 1 counter = 1
for screenshot in package.screenshots.all(): for screenshot in package.screenshots.all():
@ -28,13 +25,6 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
ss.url = uploaded_url ss.url = uploaded_url
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT) ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
ss.order = counter ss.order = counter
ss.width, ss.height = get_image_size(uploaded_path)
if ss.is_too_small():
raise LogicError(429,
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
db.session.add(ss) db.session.add(ss)
if reason is None: if reason is None:
@ -42,15 +32,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)
addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getURL("packages.view"), package) addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getDetailsURL(), package)
addAuditLog(AuditSeverity.NORMAL, user, msg, package.getURL("packages.view"), package) addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()
if is_cover_image:
package.cover_image = ss
db.session.commit()
return ss return ss
@ -70,18 +56,3 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id))) raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
db.session.commit() db.session.commit()
def do_set_cover_image(_user: User, package: Package, cover_image):
try:
cover_image = int(cover_image)
except (ValueError, TypeError) as e:
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
for screenshot in package.screenshots.all():
if screenshot.id == cover_image:
package.cover_image = screenshot
db.session.commit()
return
raise LogicError(400, "Unable to find screenshot")

View File

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

View File

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

View File

@ -115,10 +115,10 @@ class ForumTopic(db.Model):
topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False) topic_id = db.Column(db.Integer, primary_key=True, autoincrement=False)
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")
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)

View File

@ -19,14 +19,12 @@ import datetime
import enum import enum
from flask import url_for from flask import url_for
from flask_babel import lazy_gettext
from flask_sqlalchemy import BaseQuery from flask_sqlalchemy import BaseQuery
from sqlalchemy_searchable import SearchQueryMixin from sqlalchemy_searchable import SearchQueryMixin
from sqlalchemy_utils.types import TSVectorType from sqlalchemy_utils.types import TSVectorType
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank, User
from .. import app
class PackageQuery(BaseQuery, SearchQueryMixin): class PackageQuery(BaseQuery, SearchQueryMixin):
@ -37,12 +35,10 @@ class License(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False, unique=True) name = db.Column(db.String(50), nullable=False, unique=True)
is_foss = db.Column(db.Boolean, nullable=False, default=True) is_foss = db.Column(db.Boolean, nullable=False, default=True)
url = db.Column(db.String(128), nullable=True, default=None)
def __init__(self, v: str, is_foss: bool = True, url: str = None): def __init__(self, v, is_foss=True):
self.name = v self.name = v
self.is_foss = is_foss self.is_foss = is_foss
self.url = url
def __str__(self): def __str__(self):
return self.name return self.name
@ -59,24 +55,6 @@ class PackageType(enum.Enum):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def text(self):
if self == PackageType.MOD:
return lazy_gettext("Mod")
elif self == PackageType.GAME:
return lazy_gettext("Game")
elif self == PackageType.TXP:
return lazy_gettext("Texture Pack")
@property
def plural(self):
if self == PackageType.MOD:
return lazy_gettext("Mods")
elif self == PackageType.GAME:
return lazy_gettext("Games")
elif self == PackageType.TXP:
return lazy_gettext("Texture Packs")
@classmethod @classmethod
def get(cls, name): def get(cls, name):
try: try:
@ -86,72 +64,13 @@ class PackageType(enum.Enum):
@classmethod @classmethod
def choices(cls): def choices(cls):
return [(choice, choice.text) for choice in cls] return [(choice, choice.value) for choice in cls]
@classmethod @classmethod
def coerce(cls, item): def coerce(cls, item):
return item if type(item) == PackageType else PackageType[item.upper()] return item if type(item) == PackageType else PackageType[item.upper()]
class PackageDevState(enum.Enum):
WIP = "Work in Progress"
BETA = "Beta"
ACTIVELY_DEVELOPED = "Actively Developed"
MAINTENANCE_ONLY = "Maintenance Only"
AS_IS = "As-Is"
DEPRECATED = "Deprecated"
LOOKING_FOR_MAINTAINER = "Looking for Maintainer"
def toName(self):
return self.name.lower()
def __str__(self):
return self.name
def get_desc(self):
if self == PackageDevState.WIP:
return "Under active development, and may break worlds/things without warning"
elif self == PackageDevState.BETA:
return "Fully playable, but with some breakages/changes expected"
elif self == PackageDevState.MAINTENANCE_ONLY:
return "Finished, with bug fixes being made as needed"
elif self == PackageDevState.AS_IS:
return "Finished, the maintainer doesn't intend to continue working on it or provide support"
elif self == PackageDevState.DEPRECATED:
return "The maintainer doesn't recommend this package. See the description for more info"
else:
return None
@classmethod
def get(cls, name):
try:
return PackageDevState[name.upper()]
except KeyError:
return None
@classmethod
def choices(cls, with_none):
def build_label(choice):
desc = choice.get_desc()
if desc is None:
return choice.value
else:
return f"{choice.value}: {desc}"
ret = [(choice, build_label(choice)) for choice in cls]
if with_none:
ret.insert(0, (None, ""))
return ret
@classmethod
def coerce(cls, item):
if item is None or (isinstance(item, str) and item.upper() == "NONE"):
return None
return item if type(item) == PackageDevState else PackageDevState[item.upper()]
class PackageState(enum.Enum): class PackageState(enum.Enum):
WIP = "Draft" WIP = "Draft"
CHANGES_NEEDED = "Changes Needed" CHANGES_NEEDED = "Changes Needed"
@ -164,30 +83,17 @@ class PackageState(enum.Enum):
def verb(self): def verb(self):
if self == self.READY_FOR_REVIEW: if self == self.READY_FOR_REVIEW:
return lazy_gettext("Submit for Approval") return "Submit for Review"
elif self == self.APPROVED: elif self == self.APPROVED:
return lazy_gettext("Approve") return "Approve"
elif self == self.DELETED: elif self == self.DELETED:
return lazy_gettext("Delete") return "Delete"
else: else:
return self.value return self.value
def __str__(self): def __str__(self):
return self.name return self.name
@property
def color(self):
if self == self.WIP:
return "warning"
elif self == self.CHANGES_NEEDED:
return "danger"
elif self == self.READY_FOR_REVIEW:
return "success"
elif self == self.APPROVED:
return "info"
else:
return "danger"
@classmethod @classmethod
def get(cls, name): def get(cls, name):
try: try:
@ -237,7 +143,7 @@ class PackagePropertyKey(enum.Enum):
return str(value) return str(value)
PackageProvides = db.Table("provides", provides = 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)
) )
@ -344,25 +250,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):
self.package = package
self.game = game
class Package(db.Model): class Package(db.Model):
query_class = PackageQuery query_class = PackageQuery
@ -390,8 +277,7 @@ class Package(db.Model):
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])
state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP) state = db.Column(db.Enum(PackageState), nullable=False, default=PackageState.WIP)
dev_state = db.Column(db.Enum(PackageDevState), nullable=True, default=None)
@property @property
def approved(self): def approved(self):
@ -409,18 +295,11 @@ class Package(db.Model):
website = db.Column(db.String(200), nullable=True) website = db.Column(db.String(200), nullable=True)
issueTracker = db.Column(db.String(200), nullable=True) issueTracker = db.Column(db.String(200), nullable=True)
forums = db.Column(db.Integer, nullable=True) forums = db.Column(db.Integer, nullable=True)
video_url = db.Column(db.String(200), nullable=True, default=None)
provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages") provides = db.relationship("MetaPackage", secondary=provides, 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")
@ -432,7 +311,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)
@ -443,8 +322,7 @@ class Package(db.Model):
threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"), threads = db.relationship("Thread", back_populates="package", order_by=db.desc("thread_created_at"),
foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic") foreign_keys="Thread.package_id", cascade="all, delete, delete-orphan", lazy="dynamic")
reviews = db.relationship("PackageReview", back_populates="package", reviews = db.relationship("PackageReview", back_populates="package", order_by=db.desc("package_review_created_at"),
order_by=[db.desc("package_review_score"),db.desc("package_review_created_at")],
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id", audit_log_entries = db.relationship("AuditLogEntry", foreign_keys="AuditLogEntry.package_id",
@ -459,9 +337,6 @@ class Package(db.Model):
update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package", update_config = db.relationship("PackageUpdateConfig", uselist=False, back_populates="package",
cascade="all, delete, delete-orphan") cascade="all, delete, delete-orphan")
aliases = db.relationship("PackageAlias", foreign_keys="PackageAlias.package_id",
back_populates="package", cascade="all, delete, delete-orphan")
def __init__(self, package=None): def __init__(self, package=None):
if package is None: if package is None:
return return
@ -475,14 +350,6 @@ class Package(db.Model):
for e in PackagePropertyKey: for e in PackagePropertyKey:
setattr(self, e.name, getattr(package, e.name)) setattr(self, e.name, getattr(package, e.name))
@classmethod
def get_by_key(cls, key):
parts = key.split("/")
if len(parts) != 2:
return None
return Package.query.filter(Package.name == parts[1], Package.author.has(username=parts[0])).first()
def getId(self): def getId(self):
return "{}/{}".format(self.author.username, self.name) return "{}/{}".format(self.author.username, self.name)
@ -504,15 +371,10 @@ class Package(db.Model):
def getSortedOptionalDependencies(self): def getSortedOptionalDependencies(self):
return self.getSortedDependencies(False) return self.getSortedDependencies(False)
def getSortedSupportedGames(self):
supported = self.supported_games.all()
supported.sort(key=lambda x: -x.game.score)
return supported
def getAsDictionaryKey(self): def getAsDictionaryKey(self):
return { return {
"name": self.name, "name": self.name,
"author": self.author.username, "author": self.author.display_name,
"type": self.type.toName(), "type": self.type.toName(),
} }
@ -523,26 +385,16 @@ class Package(db.Model):
release = self.getDownloadRelease(version=version) release = self.getDownloadRelease(version=version)
release_id = release and release.id release_id = release and release.id
short_desc = self.short_desc return {
if self.dev_state == PackageDevState.WIP:
short_desc = "Work in Progress. " + self.short_desc
ret = {
"name": self.name, "name": self.name,
"title": self.title, "title": self.title,
"author": self.author.username, "author": self.author.username,
"short_description": short_desc, "short_description": self.short_desc,
"type": self.type.toName(), "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.getAsDictionary() for alias in self.aliases ],
} }
if not ret["aliases"]:
del ret["aliases"]
return ret
def getAsDictionary(self, base_url, version=None): def getAsDictionary(self, base_url, version=None):
tnurl = self.getThumbnailURL(1) tnurl = self.getThumbnailURL(1)
release = self.getDownloadRelease(version=version) release = self.getDownloadRelease(version=version)
@ -551,7 +403,6 @@ class Package(db.Model):
"maintainers": [x.username for x in self.maintainers], "maintainers": [x.username for x in self.maintainers],
"state": self.state.name, "state": self.state.name,
"dev_state": self.dev_state.name if self.dev_state else None,
"name": self.name, "name": self.name,
"title": self.title, "title": self.title,
@ -567,7 +418,6 @@ class Package(db.Model):
"website": self.website, "website": self.website,
"issue_tracker": self.issueTracker, "issue_tracker": self.issueTracker,
"forums": self.forums, "forums": self.forums,
"video_url": self.video_url,
"tags": [x.name for x in self.tags], "tags": [x.name for x in self.tags],
"content_warnings": [x.name for x in self.content_warnings], "content_warnings": [x.name for x in self.content_warnings],
@ -576,19 +426,11 @@ class Package(db.Model):
"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.getURL("packages.download"), "url": base_url + self.getDownloadURL(),
"release": release and release.id, "release": release and release.id,
"score": round(self.score * 10) / 10, "score": round(self.score * 10) / 10,
"downloads": self.downloads, "downloads": self.downloads
"game_support": [
{
"supports": support.supports,
"confidence": support.confidence,
"game": support.game.getAsDictionaryShort(base_url, version)
} for support in self.supported_games.all()
]
} }
def getThumbnailOrPlaceholder(self, level=2): def getThumbnailOrPlaceholder(self, level=2):
@ -609,12 +451,14 @@ class Package(db.Model):
else: else:
return screenshot.url return screenshot.url
def getURL(self, endpoint, absolute=False, **kwargs): def getDetailsURL(self, absolute=False):
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("packages.view",
author=self.author.username, name=self.name)
else: else:
return url_for(endpoint, author=self.author.username, name=self.name, **kwargs) return url_for("packages.view",
author=self.author.username, name=self.name)
def getShieldURL(self, type): def getShieldURL(self, type):
from app.utils import abs_url_for from app.utils import abs_url_for
@ -623,7 +467,15 @@ class Package(db.Model):
def makeShield(self, type): def makeShield(self, type):
return "[![ContentDB]({})]({})" \ return "[![ContentDB]({})]({})" \
.format(self.getShieldURL(type), self.getURL("packages.view", True)) .format(self.getShieldURL(type), self.getDetailsURL(True))
def getEditURL(self):
return url_for("packages.create_edit",
author=self.author.username, name=self.name)
def getReleaseListURL(self):
return url_for("packages.list_releases",
author=self.author.username, name=self.name)
def getSetStateURL(self, state): def getSetStateURL(self, state):
if type(state) == str: if type(state) == str:
@ -634,6 +486,58 @@ class Package(db.Model):
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 getRemoveURL(self):
return url_for("packages.remove",
author=self.author.username, name=self.name)
def getNewScreenshotURL(self):
return url_for("packages.create_screenshot",
author=self.author.username, name=self.name)
def getEditScreenshotsURL(self):
return url_for("packages.screenshots",
author=self.author.username, name=self.name)
def getCreateReleaseURL(self, **kwargs):
return url_for("packages.create_release",
author=self.author.username, name=self.name, **kwargs)
def getBulkReleaseURL(self):
return url_for("packages.bulk_change_release",
author=self.author.username, name=self.name)
def getUpdateConfigURL(self, trigger=None, action=None):
return url_for("packages.update_config",
author=self.author.username, name=self.name, trigger=trigger, action=action)
def getSetupReleasesURL(self):
return url_for("packages.setup_releases",
author=self.author.username, name=self.name)
def getDownloadURL(self):
return url_for("packages.download",
author=self.author.username, name=self.name)
def getEditMaintainersURL(self):
return url_for("packages.edit_maintainers",
author=self.author.username, name=self.name)
def getRemoveSelfMaintainerURL(self):
return url_for("packages.remove_self_maintainers",
author=self.author.username, name=self.name)
def getUpdateFromReleaseURL(self):
return url_for("packages.update_from_release",
author=self.author.username, name=self.name)
def getReviewURL(self):
return url_for('packages.review',
author=self.author.username, name=self.name)
def getAuditLogURL(self):
return url_for('packages.audit',
author=self.author.username, name=self.name)
def getDownloadRelease(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
@ -654,7 +558,6 @@ class Package(db.Model):
isOwner = user == self.author isOwner = user == self.author
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
isApprover = user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.CREATE_THREAD: if perm == Permission.CREATE_THREAD:
return user.rank.atLeast(UserRank.MEMBER) return user.rank.atLeast(UserRank.MEMBER)
@ -663,33 +566,33 @@ class Package(db.Model):
elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS: elif perm == Permission.MAKE_RELEASE or perm == Permission.ADD_SCREENSHOTS:
return isMaintainer return isMaintainer
elif perm == Permission.EDIT_PACKAGE: elif perm == Permission.EDIT_PACKAGE or \
perm == Permission.APPROVE_CHANGES or perm == Permission.APPROVE_RELEASE:
return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER) return isMaintainer and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
elif perm == Permission.APPROVE_RELEASE:
return (isMaintainer or isApprover) and user.rank.atLeast(UserRank.MEMBER if self.approved else UserRank.NEW_MEMBER)
# Anyone can change the package name when not approved, but only editors when approved # 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 or user.rank.atLeast(UserRank.EDITOR) 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 isApprover return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.APPROVE_SCREENSHOT: elif perm == Permission.APPROVE_SCREENSHOT:
return (isMaintainer or isApprover) and \ return isMaintainer and user.rank.atLeast(UserRank.TRUSTED_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:
return isOwner or user.rank.atLeast(UserRank.EDITOR) return isOwner or user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.UNAPPROVE_PACKAGE: elif perm == Permission.UNAPPROVE_PACKAGE or perm == Permission.DELETE_PACKAGE:
return isOwner or user.rank.atLeast(UserRank.APPROVER) return user.rank.atLeast(UserRank.MEMBER if isOwner else UserRank.EDITOR)
elif perm == Permission.CHANGE_RELEASE_URL: elif perm == Permission.CHANGE_RELEASE_URL:
return user.rank.atLeast(UserRank.MODERATOR) return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.REIMPORT_META:
return user.rank.atLeast(UserRank.ADMIN)
else: else:
raise Exception("Permission {} is not related to packages".format(perm.name)) raise Exception("Permission {} is not related to packages".format(perm.name))
@ -715,10 +618,9 @@ class Package(db.Model):
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.checkPerm(user, Permission.APPROVE_NEW): requiredPerm = Permission.APPROVE_NEW if state == PackageState.APPROVED else Permission.EDIT_PACKAGE
return False
if not (self.checkPerm(user, Permission.APPROVE_NEW) or self.checkPerm(user, Permission.EDIT_PACKAGE)): if not self.checkPerm(user, requiredPerm):
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):
@ -730,8 +632,7 @@ class Package(db.Model):
needsScreenshot = \ needsScreenshot = \
(self.type == self.type.GAME or self.type == self.type.TXP) and \ (self.type == self.type.GAME or self.type == self.type.TXP) and \
self.screenshots.count() == 0 self.screenshots.count() == 0
return self.releases.count() > 0 and not needsScreenshot
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
elif state == PackageState.CHANGES_NEEDED: elif state == PackageState.CHANGES_NEEDED:
return self.checkPerm(user, Permission.APPROVE_NEW) return self.checkPerm(user, Permission.APPROVE_NEW)
@ -770,7 +671,7 @@ 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=provides)
mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'") mp_name_valid = db.CheckConstraint("name ~* '^[a-z0-9_]+$'")
@ -847,7 +748,6 @@ 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)
@ -862,13 +762,7 @@ class Tag(db.Model):
def getAsDictionary(self): def getAsDictionary(self):
description = self.description if self.description != "" else None description = self.description if self.description != "" else None
return { return { "name": self.name, "title": self.title, "description": description }
"name": self.name,
"title": self.title,
"description": description,
"is_protected": self.is_protected,
"views": self.views,
}
class MinetestRelease(db.Model): class MinetestRelease(db.Model):
@ -936,10 +830,6 @@ class PackageRelease(db.Model):
# If the release is approved, then the task_id must be null and the url must be present # If the release is approved, then the task_id must be null and the url must be present
CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)") CK_approval_valid = db.CheckConstraint("not approved OR (task_id IS NULL AND (url = '') IS NOT FALSE)")
@property
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
def getAsDictionary(self): def getAsDictionary(self):
return { return {
"id": self.id, "id": self.id,
@ -1033,7 +923,7 @@ class PackageRelease(db.Model):
return count > 0 return count > 0
elif perm == Permission.APPROVE_RELEASE: elif perm == Permission.APPROVE_RELEASE:
return user.rank.atLeast(UserRank.APPROVER) or \ return user.rank.atLeast(UserRank.EDITOR) or \
(isMaintainer and user.rank.atLeast( (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:
@ -1041,9 +931,6 @@ class PackageRelease(db.Model):
class PackageScreenshot(db.Model): class PackageScreenshot(db.Model):
HARD_MIN_SIZE = (920, 517)
SOFT_MIN_SIZE = (1280, 720)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False) package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
@ -1055,22 +942,6 @@ class PackageScreenshot(db.Model):
approved = db.Column(db.Boolean, nullable=False, default=False) approved = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
def is_very_small(self):
return self.width < 720 or self.height < 405
def is_too_small(self):
return self.width < PackageScreenshot.HARD_MIN_SIZE[0] or self.height < PackageScreenshot.HARD_MIN_SIZE[1]
def is_low_res(self):
return self.width < PackageScreenshot.SOFT_MIN_SIZE[0] or self.height < PackageScreenshot.SOFT_MIN_SIZE[1]
@property
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
def getEditURL(self): def getEditURL(self):
return url_for("packages.edit_screenshot", return url_for("packages.edit_screenshot",
author=self.package.author.username, author=self.package.author.username,
@ -1092,11 +963,8 @@ class PackageScreenshot(db.Model):
"order": self.order, "order": self.order,
"title": self.title, "title": self.title,
"url": base_url + self.url, "url": base_url + self.url,
"width": self.width,
"height": self.height,
"approved": self.approved, "approved": self.approved,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"is_cover_image": self.package.cover_image == self,
} }
@ -1167,25 +1035,4 @@ 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.getURL("packages.create_release", title=self.get_title(), ref=self.get_ref()) return self.package.getCreateReleaseURL(title=self.get_title(), ref=self.get_ref())
class PackageAlias(db.Model):
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
package = db.relationship("Package", back_populates="aliases", foreign_keys=[package_id])
author = db.Column(db.String(50), nullable=False)
name = db.Column(db.String(100), nullable=False)
def __init__(self, author="", name=""):
self.author = author
self.name = name
def getEditURL(self):
return url_for("packages.alias_create_edit", author=self.package.author.username,
name=self.package.name, alias_id=self.id)
def getAsDictionary(self):
return f"{self.author}/{self.name}"

View File

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

View File

@ -31,11 +31,10 @@ class UserRank(enum.Enum):
NEW_MEMBER = 2 NEW_MEMBER = 2
MEMBER = 3 MEMBER = 3
TRUSTED_MEMBER = 4 TRUSTED_MEMBER = 4
APPROVER = 5 EDITOR = 5
EDITOR = 6 BOT = 6
BOT = 7 MODERATOR = 7
MODERATOR = 8 ADMIN = 8
ADMIN = 9
def atLeast(self, min): def atLeast(self, min):
return self.value >= min.value return self.value >= min.value
@ -60,12 +59,14 @@ class UserRank(enum.Enum):
class Permission(enum.Enum): class Permission(enum.Enum):
EDIT_PACKAGE = "EDIT_PACKAGE" EDIT_PACKAGE = "EDIT_PACKAGE"
APPROVE_CHANGES = "APPROVE_CHANGES"
DELETE_PACKAGE = "DELETE_PACKAGE" DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR" CHANGE_AUTHOR = "CHANGE_AUTHOR"
CHANGE_NAME = "CHANGE_NAME" CHANGE_NAME = "CHANGE_NAME"
MAKE_RELEASE = "MAKE_RELEASE" MAKE_RELEASE = "MAKE_RELEASE"
DELETE_RELEASE = "DELETE_RELEASE" DELETE_RELEASE = "DELETE_RELEASE"
ADD_SCREENSHOTS = "ADD_SCREENSHOTS" ADD_SCREENSHOTS = "ADD_SCREENSHOTS"
REIMPORT_META = "REIMPORT_META"
APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT" APPROVE_SCREENSHOT = "APPROVE_SCREENSHOT"
APPROVE_RELEASE = "APPROVE_RELEASE" APPROVE_RELEASE = "APPROVE_RELEASE"
APPROVE_NEW = "APPROVE_NEW" APPROVE_NEW = "APPROVE_NEW"
@ -86,7 +87,6 @@ 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"
@ -97,14 +97,13 @@ class Permission(enum.Enum):
return False return False
if self == Permission.APPROVE_NEW or \ if self == Permission.APPROVE_NEW or \
self == Permission.APPROVE_CHANGES or \
self == Permission.APPROVE_RELEASE or \ self == Permission.APPROVE_RELEASE or \
self == Permission.APPROVE_SCREENSHOT or \ self == Permission.APPROVE_SCREENSHOT or \
self == Permission.EDIT_TAGS or \
self == Permission.CREATE_TAG or \
self == Permission.SEE_THREAD: self == Permission.SEE_THREAD:
return user.rank.atLeast(UserRank.APPROVER)
elif self == Permission.EDIT_TAGS or self == Permission.CREATE_TAG:
return user.rank.atLeast(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
else: else:
raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.") raise Exception("Non-global permission checked globally. Use Package.checkPerm or User.checkPerm instead.")
@ -125,8 +124,6 @@ def display_name_default(context):
class User(db.Model, UserMixin): class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
created_at = db.Column(db.DateTime, nullable=True, default=datetime.datetime.utcnow)
# User authentication information # User authentication information
username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True) username = db.Column(db.String(50, collation="NOCASE"), nullable=False, unique=True, index=True)
password = db.Column(db.String(255), nullable=True, server_default=None) password = db.Column(db.String(255), nullable=True, server_default=None)
@ -148,8 +145,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")
@ -177,11 +172,9 @@ class User(db.Model, UserMixin):
packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title")) packages = db.relationship("Package", back_populates="author", lazy="dynamic", order_by=db.asc("package_title"))
reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan") reviews = db.relationship("PackageReview", back_populates="author", order_by=db.desc("package_review_created_at"), cascade="all, delete, delete-orphan")
review_votes = db.relationship("PackageReviewVote", back_populates="user", cascade="all, delete, delete-orphan")
tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan") tokens = db.relationship("APIToken", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan")
threads = db.relationship("Thread", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan") 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")
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
def __init__(self, username=None, active=False, email=None, password=None): def __init__(self, username=None, active=False, email=None, password=None):
self.username = username self.username = username
@ -193,7 +186,8 @@ class User(db.Model, UserMixin):
def canAccessTodoList(self): def canAccessTodoList(self):
return Permission.APPROVE_NEW.check(self) or \ return Permission.APPROVE_NEW.check(self) or \
Permission.APPROVE_RELEASE.check(self) Permission.APPROVE_RELEASE.check(self) or \
Permission.APPROVE_CHANGES.check(self)
def isClaimed(self): def isClaimed(self):
return self.rank.atLeast(UserRank.NEW_MEMBER) return self.rank.atLeast(UserRank.NEW_MEMBER)
@ -204,7 +198,7 @@ class User(db.Model, UserMixin):
elif self.rank == UserRank.BOT: elif self.rank == UserRank.BOT:
return "/static/bot_avatar.png" return "/static/bot_avatar.png"
else: else:
return gravatar(self.email or f"{self.username}@content.minetest.net") return gravatar(self.email or "")
def checkPerm(self, user, perm): def checkPerm(self, user, perm):
if not user.is_authenticated: if not user.is_authenticated:
@ -218,12 +212,10 @@ class User(db.Model, UserMixin):
# 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.atLeast(UserRank.EDITOR) return user.rank.atLeast(UserRank.EDITOR)
elif perm == Permission.CHANGE_USERNAMES: elif perm == Permission.CHANGE_RANK or perm == Permission.CHANGE_USERNAMES:
return user.rank.atLeast(UserRank.MODERATOR) return user.rank.atLeast(UserRank.MODERATOR)
elif perm == Permission.CHANGE_RANK:
return user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)
elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS: elif perm == Permission.CHANGE_EMAIL or perm == Permission.CHANGE_PROFILE_URLS:
return user == self or (user.rank.atLeast(UserRank.MODERATOR) and not self.rank.atLeast(user.rank)) return user == self or user.rank.atLeast(UserRank.ADMIN)
elif perm == Permission.CHANGE_DISPLAY_NAME: elif perm == Permission.CHANGE_DISPLAY_NAME:
return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR) return user.rank.atLeast(UserRank.MEMBER if user == self else UserRank.MODERATOR)
elif perm == Permission.CREATE_TOKEN: elif perm == Permission.CREATE_TOKEN:
@ -295,7 +287,6 @@ class UserEmailVerification(db.Model):
token = db.Column(db.String(32), nullable=True) token = db.Column(db.String(32), nullable=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications") user = db.relationship("User", foreign_keys=[user_id], back_populates="email_verifications")
is_password_reset = db.Column(db.Boolean, nullable=False, default=False) is_password_reset = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
class EmailSubscription(db.Model): class EmailSubscription(db.Model):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,6 +1,3 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
$("textarea.markdown").each(function() { $("textarea.markdown").each(function() {
async function render(plainText, preview) { async function render(plainText, preview) {
const response = await fetch(new Request("/api/markdown/", { const response = await fetch(new Request("/api/markdown/", {
@ -17,53 +14,11 @@ $("textarea.markdown").each(function() {
let timeout_id = null; let timeout_id = null;
function urlInserter(url) {
return (editor) => {
var cm = editor.codemirror;
var stat = getState(cm);
var options = editor.options;
_replaceSelection(cm, stat.table, `[](${url})`);
};
}
this.easy_mde = new EasyMDE({ this.easy_mde = new EasyMDE({
element: this, element: this,
hideIcons: ["image"], hideIcons: ["image"],
showIcons: ["code", "table"], showIcons: ["code", "table"],
forceSync: true, forceSync: true,
toolbar: [
"bold",
"italic",
"heading",
"|",
"code",
"quote",
"unordered-list",
"ordered-list",
"|",
"link",
"table",
"|",
"preview",
"side-by-side",
"fullscreen",
"|",
"guide",
// {
// name: "rules",
// className: "fa fa-book",
// title: "others buttons",
// children: [
// {
// name: "rules",
// action: urlInserter("/policy_and_guidance/#2-accepted-content"),
// className: "fa fa-star",
// title: "2. Accepted content",
// text: "2. Accepted content",
// },
// ]
// },
],
previewRender: (plainText, preview) => { previewRender: (plainText, preview) => {
if (timeout_id) { if (timeout_id) {
clearTimeout(timeout_id); clearTimeout(timeout_id);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -70,7 +70,7 @@
} }
.NOT_JOINED a, .NOT_JOINED { .NOT_JOINED a, .NOT_JOINED {
color: #aaa !important; color: #7ac !important;
} }
.ADMIN a, .ADMIN{ .ADMIN a, .ADMIN{
@ -81,10 +81,6 @@
color: #e90 !important; color: #e90 !important;
} }
.APPROVER a, .APPROVER {
color: #69f !important;
}
.EDITOR a, .EDITOR { .EDITOR a, .EDITOR {
color: #b6f !important; color: #b6f !important;
} }

View File

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

View File

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

View File

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

View File

@ -13,7 +13,6 @@
# #
# 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 logging import Filter from logging import Filter
import flask import flask
@ -68,32 +67,28 @@ celery = make_celery(app)
CELERYBEAT_SCHEDULE = { CELERYBEAT_SCHEDULE = {
'topic_list_import': { 'topic_list_import': {
'task': 'app.tasks.forumtasks.importTopicList', 'task': 'app.tasks.forumtasks.importTopicList',
'schedule': crontab(minute=1, hour=1), # 0101 'schedule': crontab(minute=1, hour=1),
}, },
'package_score_update': { 'package_score_update': {
'task': 'app.tasks.pkgtasks.updatePackageScores', 'task': 'app.tasks.pkgtasks.updatePackageScores',
'schedule': crontab(minute=10, hour=1), # 0110 'schedule': crontab(minute=10, hour=1),
}, },
'check_for_updates': { 'check_for_updates': {
'task': 'app.tasks.importtasks.check_for_updates', 'task': 'app.tasks.importtasks.check_for_updates',
'schedule': crontab(minute=10, hour=1), # 0110 'schedule': crontab(minute=10, hour=1),
}, },
'send_pending_notifications': { 'send_pending_notifications': {
'task': 'app.tasks.emails.send_pending_notifications', 'task': 'app.tasks.emails.send_pending_notifications',
'schedule': crontab(minute='*/5'), # every 5 minutes 'schedule': crontab(minute='*/5'),
}, },
'send_notification_digests': { 'send_notification_digests': {
'task': 'app.tasks.emails.send_pending_digests', 'task': 'app.tasks.emails.send_pending_digests',
'schedule': crontab(minute=0, hour=14), # 1400 'schedule': crontab(minute=0, hour=14),
}, }
'delete_inactive_users': {
'task': 'app.tasks.usertasks.delete_inactive_users',
'schedule': crontab(minute=15), # every hour at quarter past
},
} }
celery.conf.beat_schedule = CELERYBEAT_SCHEDULE celery.conf.beat_schedule = CELERYBEAT_SCHEDULE
from . import importtasks, forumtasks, emails, pkgtasks, usertasks from . import importtasks, forumtasks, emails, pkgtasks, celery
# noinspection PyUnusedLocal # noinspection PyUnusedLocal

View File

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

View File

@ -18,7 +18,6 @@
import json, re, sys import json, re, sys
from app.models import * from app.models import *
from app.tasks import celery from app.tasks import celery
from app.utils import is_username_valid
from app.utils.phpbbparser import getProfile, getTopicsFromForum from app.utils.phpbbparser import getProfile, getTopicsFromForum
import urllib.request import urllib.request
@ -45,7 +44,7 @@ def checkForumAccount(username, forceNoSave=False):
# Get github username # Get github username
github_username = profile.get("github") github_username = profile.get("github")
if github_username is not None and github_username.strip() != "": if github_username is not None and github_username.strip() != "":
print("Updated GitHub username for " + user.display_name + " to " + github_username) print("Updated github username for " + user.display_name + " to " + github_username)
user.github_username = github_username user.github_username = github_username
needsSaving = True needsSaving = True
@ -138,9 +137,6 @@ def importTopicList():
if user: if user:
return user return user
if not is_username_valid(username):
return None
user = User.query.filter_by(forums_username=username).first() user = User.query.filter_by(forums_username=username).first()
if user is None: if user is None:
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()

View File

@ -13,7 +13,6 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import json import json
import os, shutil, gitdb import os, shutil, gitdb
from zipfile import ZipFile from zipfile import ZipFile
@ -23,13 +22,11 @@ from kombu import uuid
from app.models import * from app.models import *
from app.tasks import celery, TaskError from app.tasks import celery, TaskError
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_system_user
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
from .minetestcheck import build_tree, MinetestCheckError, ContentType from .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError from ..logic.LogicError import LogicError
from ..logic.game_support import GameSupportResolver
from ..logic.packages import do_edit_package, ALIASES from ..logic.packages import do_edit_package, ALIASES
from ..utils.image import get_image_size
@celery.task() @celery.task()
@ -73,14 +70,11 @@ def getMeta(urlstr, author):
return result return result
def postReleaseCheckUpdate(self, release: PackageRelease, path): def postReleaseCheckUpdate(self, release, path):
try: try:
tree = build_tree(path, expected_type=ContentType[release.package.type.name], tree = build_tree(path, expected_type=ContentType[release.package.type.name],
author=release.package.author.username, name=release.package.name) author=release.package.author.username, name=release.package.name)
if tree.name is not None and release.package.name != tree.name and tree.type == ContentType.MOD:
raise MinetestCheckError(f"Expected {tree.relative} to have technical name {release.package.name}, instead has name {tree.name}")
cache = {} cache = {}
def getMetaPackages(names): def getMetaPackages(names):
return [ MetaPackage.GetOrCreate(x, cache) for x in names ] return [ MetaPackage.GetOrCreate(x, cache) for x in names ]
@ -103,11 +97,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
depends.discard(mod) depends.discard(mod)
optional_depends.discard(mod) optional_depends.discard(mod)
# Raise error on unresolved game dependencies
if package.type == PackageType.GAME and len(depends) > 0:
deps = ", ".join(depends)
raise MinetestCheckError("Game has unresolved hard dependencies: " + deps)
# Add dependencies # Add dependencies
for meta in getMetaPackages(depends): for meta in getMetaPackages(depends):
db.session.add(Dependency(package, meta=meta, optional=False)) db.session.add(Dependency(package, meta=meta, optional=False))
@ -115,11 +104,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
for meta in getMetaPackages(optional_depends): for meta in getMetaPackages(optional_depends):
db.session.add(Dependency(package, meta=meta, optional=True)) db.session.add(Dependency(package, meta=meta, optional=True))
# Update game supports
if package.type == PackageType.MOD:
resolver = GameSupportResolver()
resolver.update(package)
# Update min/max # Update min/max
if tree.meta.get("min_minetest_version"): if tree.meta.get("min_minetest_version"):
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None) release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
@ -130,7 +114,7 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
try: try:
with open(os.path.join(tree.baseDir, ".cdb.json"), "r") as f: with open(os.path.join(tree.baseDir, ".cdb.json"), "r") as f:
data = json.loads(f.read()) data = json.loads(f.read())
do_edit_package(package.author, package, False, False, data, "Post release hook") do_edit_package(package.author, package, False, data, "Post release hook")
except LogicError as e: except LogicError as e:
raise TaskError(e.message) raise TaskError(e.message)
except IOError: except IOError:
@ -141,9 +125,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
except MinetestCheckError as err: except MinetestCheckError as err:
db.session.rollback() db.session.rollback()
msg = f"{err}\n\nTask ID: {self.request.id}\n\nRelease: [View Release]({release.getEditURL()})"
post_bot_message(release.package, f"Release {release.title} validation failed", msg)
if "Fails validation" not in release.title: if "Fails validation" not in release.title:
release.title += " (Fails validation)" release.title += " (Fails validation)"
@ -221,10 +202,6 @@ def importRepoScreenshot(id):
ss.package = package ss.package = package
ss.title = "screenshot.png" ss.title = "screenshot.png"
ss.url = "/uploads/" + filename ss.url = "/uploads/" + filename
ss.width, ss.height = get_image_size(destPath)
if ss.is_too_small():
return None
db.session.add(ss) db.session.add(ss)
db.session.commit() db.session.commit()
@ -277,7 +254,7 @@ def check_update_config_impl(package):
db.session.add(rel) db.session.add(rel)
msg = "Created release {} (Git Update Detection)".format(rel.title) msg = "Created release {} (Git Update Detection)".format(rel.title)
addSystemAuditLog(AuditSeverity.NORMAL, msg, package.getURL("packages.view"), package) addSystemAuditLog(AuditSeverity.NORMAL, msg, package.getDetailsURL(), package)
db.session.commit() db.session.commit()
@ -335,7 +312,7 @@ def check_update_config(self, package_id):
.strip() .strip()
msg = "Error: {}.\n\nTask ID: {}\n\n[Change update configuration]({})" \ msg = "Error: {}.\n\nTask ID: {}\n\n[Change update configuration]({})" \
.format(err, self.request.id, package.getURL("packages.update_config")) .format(err, self.request.id, package.getUpdateConfigURL())
post_bot_message(package, "Failed to check git repository", msg) post_bot_message(package, "Failed to check git repository", msg)

View File

@ -23,8 +23,8 @@ def detect_type(path):
elif os.path.isfile(path + "/modpack.txt") or \ elif os.path.isfile(path + "/modpack.txt") or \
os.path.isfile(path + "/modpack.conf"): os.path.isfile(path + "/modpack.conf"):
return ContentType.MODPACK return ContentType.MODPACK
# elif os.path.isdir(path + "/mods"): elif os.path.isdir(path + "/mods"):
# return ContentType.GAME return ContentType.GAME
elif os.path.isfile(path + "/texture_pack.conf"): elif os.path.isfile(path + "/texture_pack.conf"):
return ContentType.TXP return ContentType.TXP
else: else:
@ -140,7 +140,7 @@ class PackageTreeNode:
def checkDependencies(deps): def checkDependencies(deps):
for dep in deps: for dep in result["depends"]:
if not basenamePattern.match(dep): if not basenamePattern.match(dep):
if " " in dep: if " " in dep:
raise MinetestCheckError("Invalid dependency name '{}' for mod at {}, did you forget a comma?" \ raise MinetestCheckError("Invalid dependency name '{}' for mod at {}, did you forget a comma?" \
@ -155,7 +155,7 @@ class PackageTreeNode:
checkDependencies(result["optional_depends"]) checkDependencies(result["optional_depends"])
# Fix games using "name" as "title" # Fix games using "name" as "title"
if self.type == ContentType.GAME and "name" in result: if self.type == ContentType.GAME:
result["title"] = result["name"] result["title"] = result["name"]
del result["name"] del result["name"]

View File

@ -1,31 +0,0 @@
# ContentDB
# Copyright (C) 2021 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from app.models import User, db, UserRank
from app.tasks import celery
@celery.task()
def delete_inactive_users():
threshold = datetime.datetime.now() - datetime.timedelta(hours=5)
users = User.query.filter(User.is_active==False, User.packages==None, User.forum_topics==None, User.created_at<=threshold, User.rank==UserRank.NOT_JOINED).all()
for user in users:
db.session.delete(user)
db.session.commit()

View File

@ -1,40 +0,0 @@
# ContentDB
# Copyright (C) 2021 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional
import requests
from app import app
from app.models import User
from app.tasks import celery
@celery.task()
def post_discord_webhook(username: Optional[str], content: str, is_queue: bool):
discord_url = app.config.get("DISCORD_WEBHOOK_QUEUE" if is_queue else "DISCORD_WEBHOOK_FEED")
if discord_url is None:
return
json = {
"content": content,
}
if username:
json["username"] = username
user = User.query.filter_by(username=username).first()
if user:
json["avatar_url"] = user.getProfilePicURL().replace("/./", "/")
requests.post(discord_url, json=json)

View File

@ -1,12 +1,12 @@
from . import app, utils from . import app, utils
from .models import Permission, Package, PackageState, PackageRelease from .models import Permission, Package, PackageState, PackageRelease
from .utils import abs_url_for, url_set_query, url_set_anchor, url_current from .utils import abs_url_for, url_set_query
from flask_login import current_user from flask_login import current_user
from flask_babel import format_timedelta, gettext from flask_babel import format_timedelta, gettext
from urllib.parse import urlparse from urllib.parse import urlparse
from datetime import datetime as dt from datetime import datetime as dt
from app.markdown import get_headings from .utils.markdown import get_headings
@app.context_processor @app.context_processor
@ -16,8 +16,9 @@ def inject_debug():
@app.context_processor @app.context_processor
def inject_functions(): def inject_functions():
check_global_perm = Permission.checkPerm check_global_perm = Permission.checkPerm
return dict(abs_url_for=abs_url_for, url_set_query=url_set_query, url_set_anchor=url_set_anchor, return dict(abs_url_for=abs_url_for, url_set_query=url_set_query,
check_global_perm=check_global_perm, get_headings=get_headings, url_current=url_current) check_global_perm=check_global_perm,
get_headings=get_headings)
@app.context_processor @app.context_processor
def inject_todo(): def inject_todo():

View File

@ -1,12 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{{ _("Page not found") }} Page not found
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>{{ self.title() }}</h1> <h1>Page not found</h1>
<p> <p>
{{ _("That page could not be found. The link may be broken, the page may have been deleted, or you may not have access to it.") }} That page could not be found. The link may be broken, the page may have been deleted,
or you may not have access to it.
</p> </p>
{% endblock %} {% endblock %}

View File

@ -12,13 +12,12 @@
<a class="btn btn-primary float-right" href="{{ url_for('admin.create_edit_license') }}">New License</a> <a class="btn btn-primary float-right" href="{{ url_for('admin.create_edit_license') }}">New License</a>
<a class="btn btn-secondary mb-4" href="{{ url_for('admin.license_list') }}">Back to list</a> <a class="btn btn-secondary mb-4" href="{{ url_for('admin.license_list') }}">Back to list</a>
{% from "macros/forms.html" import render_field, render_checkbox_field, render_submit_field %} {% from "macros/forms.html" import render_field, render_submit_field %}
<form method="POST" action="" enctype="multipart/form-data"> <form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field(form.name) }} {{ render_field(form.name) }}
{{ render_checkbox_field(form.is_foss) }} {{ render_field(form.is_foss) }}
{{ render_field(form.url) }}
{{ render_submit_field(form.submit) }} {{ render_submit_field(form.submit) }}
</form> </form>
{% endblock %} {% endblock %}

View File

@ -27,11 +27,18 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="row px-3"> <div class="row px-3">
<select name="action" class="custom-select col"> <select name="action" class="custom-select col">
{% for id, action in actions.items() %} <option value="cleanuploads" selected>Delete unreachable uploads</option>
<option value="{{ id }}" {% if loop.first %}selected{% endif %}> <option value="delmetapackages">Delete unused meta packages</option>
{{ action["title"] }} <option value="delstuckreleases">Delete stuck releases</option>
</option> <option value="reimportpackages">Reimport meta</option>
{% endfor %} <option value="recalcscores">Recalculate package scores</option>
<option value="div">------</option>
<option value="checkreleases">Validate all Zip releases</option>
<option value="importmodlist">Import forum topics</option>
<option value="checkusers">Check forum users</option>
<option value="importscreenshots">Import screenshots from VCS</option>
<option value="addupdateconfig">Add update configs</option>
<option value="runupdateconfig">Run update configs</option>
</select> </select>
<input type="submit" value="Perform" class="col-sm-auto btn btn-primary ml-2" /> <input type="submit" value="Perform" class="col-sm-auto btn btn-primary ml-2" />
</div> </div>

View File

@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block title %}
Restore
{% endblock %}
{% block content %}
<div class="card w-50">
<h2 class="card-header">Restore Package</h2>
<form method="post" action="" class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="">
<select name="package" class="custom-select px-3" required>
<option disabled selected>-- please select --</option>
{% for p in deleted_packages %}
<option value="{{ p.id }}">
{{ p.author.username }} / {{ p.name }}&nbsp;&nbsp;&nbsp;&nbsp;| &nbsp;&nbsp;&nbsp;id={{ p.id }}
</option>
{% endfor %}
</select>
<div class="mt-3">
<input type="submit" name="submit" value="To Draft" class="col-sm-auto btn btn-warning" />
<input type="submit" name="submit" value="To Changes Needed" class="col-sm-auto btn btn-danger ml-2" />
<input type="submit" name="submit" value="To Ready for Review" class="col-sm-auto btn btn-success ml-2" />
</div>
</div>
</form>
</div>
{% endblock %}

View File

@ -12,7 +12,7 @@
<a class="btn btn-primary float-right" href="{{ url_for('admin.create_edit_tag') }}">New Tag</a> <a class="btn btn-primary float-right" href="{{ url_for('admin.create_edit_tag') }}">New Tag</a>
<a class="btn btn-secondary mb-4" href="{{ url_for('admin.tag_list') }}">Back to list</a> <a class="btn btn-secondary mb-4" href="{{ url_for('admin.tag_list') }}">Back to list</a>
{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %} {% from "macros/forms.html" import render_field, render_submit_field %}
<form method="POST" action="" enctype="multipart/form-data"> <form method="POST" action="" enctype="multipart/form-data">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
@ -21,10 +21,6 @@
{% if tag %} {% if tag %}
{{ render_field(form.name) }} {{ render_field(form.name) }}
{% endif %} {% endif %}
<div class="form-group my-5">
{{ render_checkbox_field(form.is_protected) }}
<small class="form-text text-muted">Whether non-Editors can add this tag to packages</small>
</div>
{{ render_submit_field(form.submit) }} {{ render_submit_field(form.submit) }}
</form> </form>
{% endblock %} {% endblock %}

View File

@ -14,7 +14,7 @@
{% if token %} {% if token %}
<form class="float-right" method="POST" action="{{ url_for('api.delete_token', username=token.owner.username, id=token.id) }}"> <form class="float-right" method="POST" action="{{ url_for('api.delete_token', username=token.owner.username, id=token.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input class="btn btn-danger" type="submit" value="{{ _('Delete') }}"> <input class="btn btn-danger" type="submit" value="Delete">
</form> </form>
{% endif %} {% endif %}
@ -30,14 +30,15 @@
<div class="card-header">{{ _("Access Token") }}</div> <div class="card-header">{{ _("Access Token") }}</div>
<div class="card-body"> <div class="card-body">
<p> <p>
{{ _("For security reasons, access tokens will only be shown once. Reset the token if it is lost.") }} For security reasons, access tokens will only be shown once.
Reset the token if it is lost.
</p> </p>
{% if access_token %} {% if access_token %}
<input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control"> <input class="form-control my-3" type="text" readonly value="{{ access_token }}" class="form-control">
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}"> <form method="POST" action="{{ url_for('api.reset_token', username=token.owner.username, id=token.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input class="btn btn-primary" type="submit" value="{{ _('Reset') }}"> <input class="btn btn-primary" type="submit" value="Reset">
</form> </form>
</div> </div>
</div> </div>

View File

@ -5,8 +5,8 @@
{% endblock %} {% endblock %}
{% block pane %} {% block pane %}
<a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">{{ _("Create") }}</a> <a class="btn btn-primary float-right" href="{{ url_for('api.create_edit_token', username=user.username) }}">Create</a>
<a class="btn btn-secondary mr-2 float-right" href="/help/api/">{{ _("API Documentation") }}</a> <a class="btn btn-secondary mr-2 float-right" href="/help/api/">API Documentation</a>
<h2 class="mt-0">{{ _("API Tokens") }}</h2> <h2 class="mt-0">{{ _("API Tokens") }}</h2>
<div class="list-group"> <div class="list-group">
@ -16,7 +16,7 @@
</a> </a>
{% else %} {% else %}
<span class="list-group-item"> <span class="list-group-item">
<i>{{ _("No tokens created") }}</i> <i>No tokens created</i>
</span> </span>
{% endfor %} {% endfor %}
</div> </div>

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title> <title>{% block title %}title{% endblock %} - {{ config.USER_APP_NAME }}</title>
<link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css"> <link rel="stylesheet" type="text/css" href="/static/libs/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=34"> <link rel="stylesheet" type="text/css" href="/static/custom.css?v=25">
<link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" /> <link rel="search" type="application/opensearchdescription+xml" href="/static/opensearch.xml" title="ContentDB" />
<link rel="shortcut icon" href="/favicon-16.png" sizes="16x16"> <link rel="shortcut icon" href="/favicon-16.png" sizes="16x16">
<link rel="icon" href="/favicon-128.png" sizes="128x128"> <link rel="icon" href="/favicon-128.png" sizes="128x128">
@ -23,31 +23,34 @@
<div class="collapse navbar-collapse" id="navbarColor01"> <div class="collapse navbar-collapse" id="navbarColor01">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
<li class="nav-item"> {% for item in current_menu.children recursive %}
<a class="nav-link" href="{{ url_for('packages.list_all', type='mod') }}">{{ _("Mods") }}</a> {% if item.visible %}
</li> <li class="nav-item {% if item.children %} dropdown{% endif %}">
<li class="nav-item"> <a class="nav-link" href="{{ item.url }}"
<a class="nav-link" href="{{ url_for('packages.list_all', type='game') }}">{{ _("Games") }}</a> {% if item.children %}
</li> class="dropdown-toggle"
<li class="nav-item"> data-toggle="dropdown"
<a class="nav-link" href="{{ url_for('packages.list_all', type='txp') }}">{{ _("Texture Packs") }}</a> role="button"
</li> aria-expanded="false"
<li class="nav-item"> {% endif %}>
<a class="nav-link" href="{{ url_for('packages.list_all', random=1, lucky=1) }}">{{ _("Random") }}</a> {{ item.text }}
</li> {% if item.children %}
<li class="nav-item"> <span class="caret"></span>
<a class="nav-link" href="{{ url_for('flatpage', path='help') }}">{{ _("Help") }}</a> {% endif %}
</li> </a>
<li class="nav-item"> {% if item.children %}
<a class="nav-link" href="{{ url_for('threads.list_all') }}">{{ _("Threads") }}</a> <ul class="dropdown-menu" role="menu">
</li> {{ loop(item.children) }}
</ul>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul> </ul>
<form class="form-inline my-2 my-lg-0" method="GET" action="/packages/"> <form class="form-inline my-2 my-lg-0" method="GET" action="/packages/">
{% if type %}<input type="hidden" name="type" value="{{ type }}" />{% endif %} {% if type %}<input type="hidden" name="type" value="{{ type }}" />{% endif %}
<input class="form-control" name="q" type="text" <input class="form-control mr-sm-2" name="q" type="text" placeholder="Search {{ title | lower or 'all packages' }}" value="{{ query or ''}}">
placeholder="{% if query_hint %}{{ _('Search %(type)s', type=query_hint | lower) }}{% else %}{{ _('Search all packages') }}{% endif %}" <input class="btn btn-secondary my-2 my-sm-0 mr-sm-2" type="submit" value="Search" />
value="{{ query or ''}}">
<input class="btn btn-secondary my-2 my-sm-0 mr-sm-2" type="submit" value="{{ _('Search') }}" />
<!-- <input class="btn btn-secondary my-2 my-sm-0" <!-- <input class="btn btn-secondary my-2 my-sm-0"
data-toggle="tooltip" data-placement="bottom" data-toggle="tooltip" data-placement="bottom"
title="Go to the first found result for this query." title="Go to the first found result for this query."
@ -62,7 +65,7 @@
title="{{ _('Work Queue') }}"> title="{{ _('Work Queue') }}">
{% if todo_list_count > 0 %} {% if todo_list_count > 0 %}
<i class="fas fa-inbox"></i> <i class="fas fa-inbox"></i>
<span class="badge badge-pill badge-notify">{{ todo_list_count }}</span> <span class="badge badge-pill badge-notify" style="font-size:10px;">{{ todo_list_count }}</span>
{% else %} {% else %}
<i class="fas fa-inbox" ></i> <i class="fas fa-inbox" ></i>
{% endif %} {% endif %}
@ -84,16 +87,7 @@
title="{{ _('Notifications') }}"> title="{{ _('Notifications') }}">
{% if current_user.notifications %} {% if current_user.notifications %}
<i class="fas fa-bell"></i> <i class="fas fa-bell"></i>
{% set num_notifs = current_user.notifications | length %} <span class="badge badge-pill badge-notify" style="font-size:10px;">{{ current_user.notifications | length }}</span>
{% if num_notifs > 60 %}
<span class="badge badge-pill badge-notify badge-emoji">
😢
</span>
{% else %}
<span class="badge badge-pill badge-notify">
{{ num_notifs }}
</span>
{% endif %}
{% else %} {% else %}
<i class="fas fa-bell" ></i> <i class="fas fa-bell" ></i>
{% endif %} {% endif %}
@ -106,57 +100,39 @@
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" <a class="nav-link dropdown-toggle"
data-toggle="dropdown" data-toggle="dropdown"
role="button" role="button"
aria-expanded="false"> aria-expanded="false">{{ current_user.display_name }}
{{ current_user.display_name }} <span class="caret"></span></a>
<span class="caret"></span>
</a>
<ul class="dropdown-menu dropdown-menu-right" role="menu"> <ul class="dropdown-menu dropdown-menu-right" role="menu">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('users.profile', username=current_user.username) }}"> <a class="nav-link" href="{{ url_for('users.profile', username=current_user.username) }}">Profile</a>
{{ _("Profile") }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('todo.view_user', username=current_user.username) }}"> <a class="nav-link" href="{{ url_for('todo.view_user', username=current_user.username) }}">To do list</a>
{{ _("To do list") }}
</a>
</li> </li>
{% if current_user.rank.atLeast(current_user.rank.EDITOR) or check_global_perm(current_user, "CREATE_TAG") %}
<li class="dropdown-divider"></li>
{% endif %}
{% if current_user.rank.atLeast(current_user.rank.MODERATOR) %} {% if current_user.rank.atLeast(current_user.rank.MODERATOR) %}
<li class="nav-item"> <div class="dropdown-divider"></div>
<a class="nav-link" href="{{ url_for('admin.audit') }}"> <li class="nav-item"><a class="nav-link" href="{{ url_for('admin.audit') }}">{{ _("Audit Log") }}</a></li>
{{ _("Audit Log") }}
</a>
</li>
{% if current_user.rank == current_user.rank.ADMIN %} {% if current_user.rank == current_user.rank.ADMIN %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.admin_page') }}">{{ _("Admin") }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('admin.admin_page') }}">{{ _("Admin") }}</a></li>
{% else %} {% else %}
{% if check_global_perm(current_user, "EDIT_TAGS") %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.tag_list') }}">{{ _("Tag Editor") }}</a></li>
{% elif check_global_perm(current_user, "CREATE_TAG") %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.create_edit_tag') }}">{{ _("Create Tag") }}</a></li>
{% endif %}
{% if current_user.rank == current_user.rank.MODERATOR %} {% if current_user.rank == current_user.rank.MODERATOR %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.license_list') }}">{{ _("License Editor") }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('admin.license_list') }}">{{ _("License Editor") }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if current_user.rank.atLeast(current_user.rank.EDITOR) %} <div class="dropdown-divider"></div>
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.restore') }}">{{ _("Restore Package") }}</a></li>
{% endif %}
{% if check_global_perm(current_user, "EDIT_TAGS") %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.tag_list') }}">{{ _("Tag Editor") }}</a></li>
{% elif check_global_perm(current_user, "CREATE_TAG") %}
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.create_edit_tag') }}">{{ _("Create Tag") }}</a></li>
{% endif %}
<li class="dropdown-divider"></li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('users.profile_edit', username=current_user.username) }}"> <a class="nav-link" href="{{ url_for('users.profile_edit', username=current_user.username) }}">Settings</a>
{{ _("Settings") }}
</a>
</li> </li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('users.logout') }}">{{ _("Sign out") }}</a></li> <li class="nav-item"><a class="nav-link" href="{{ url_for('users.logout') }}">{{ _("Sign out") }}</a></li>
</ul> </ul>
@ -164,36 +140,6 @@
{% else %} {% else %}
<li><a class="nav-link" href="{{ url_for('users.login') }}">{{ _("Sign in") }}</a></li> <li><a class="nav-link" href="{{ url_for('users.login') }}">{{ _("Sign in") }}</a></li>
{% endif %} {% endif %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
data-toggle="dropdown"
role="button"
aria-expanded="false">
<i class="fas fa-language"></i>
</a>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
{% for locale, locale_name in config["LANGUAGES"].items() %}
<li class="nav-item">
<form method="POST" action="{{ url_for('set_locale') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="locale" value="{{ locale }}" />
<input type="hidden" name="r" value="{{ url_set_query() }}" />
<input type="submit" class="btn btn-link nav-link" value="{{ locale_name }} ({{ locale }})">
</form>
</li>
{% endfor %}
<li class="dropdown-divider"></li>
<li class="nav-item ">
<a class="nav-link" href="https://hosted.weblate.org/projects/minetest/contentdb/">
<small>
{{ _("Help translate ContentDB") }}
</small>
</a>
</li>
</ul>
</form>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -226,7 +172,7 @@
<footer class="my-5"> <footer class="my-5">
<p class="pt-5 mb-1"> <p class="pt-5 mb-1">
ContentDB &copy; 2018-22 to <a href="https://rubenwardy.com/">rubenwardy</a> ContentDB &copy; 2018-21 to <a href="https://rubenwardy.com/">rubenwardy</a>
</p> </p>
<ul class="list-inline my-1"> <ul class="list-inline my-1">
@ -234,9 +180,7 @@
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("Policy and Guidance") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('flatpage', path='policy_and_guidance') }}">{{ _("Policy and Guidance") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='help/api') }}">{{ _("API") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('flatpage', path='help/api') }}">{{ _("API") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('flatpage', path='privacy_policy') }}">{{ _("Privacy Policy") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('flatpage', path='privacy_policy') }}">{{ _("Privacy Policy") }}</a></li>
{% if request.endpoint != "flatpage" %} <li class="list-inline-item"><a href="{{ url_for('flatpage', path='help/reporting') }}">{{ _("Report / DMCA") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('report.report', url=url_current()) }}">{{ _("Report") }}</a></li>
{% endif %}
<li class="list-inline-item"><a href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">{{ _("Stats / Monitoring") }}</a></li> <li class="list-inline-item"><a href="https://monitor.rubenwardy.com/d/3ELzFy3Wz/contentdb">{{ _("Stats / Monitoring") }}</a></li>
<li class="list-inline-item"><a href="{{ url_for('users.list_all') }}">{{ _("User List") }}</a></li> <li class="list-inline-item"><a href="{{ url_for('users.list_all') }}">{{ _("User List") }}</a></li>
<li class="list-inline-item"><a href="https://github.com/minetest/contentdb">{{ _("Source Code") }}</a></li> <li class="list-inline-item"><a href="https://github.com/minetest/contentdb">{{ _("Source Code") }}</a></li>

View File

@ -16,15 +16,15 @@
<p> <p>
<a class="btn" href="{{ notification.url | abs_url }}"> <a class="btn" href="{{ notification.url | abs_url }}">
{{ _("View Notification") }} View Notification
</a> </a>
</p> </p>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{{ _("You are receiving this email because you are a registered user of ContentDB, and have email notifications enabled.") }} You are receiving this email because you are a registered user of ContentDB,
<br> and have email notifications enabled. <br>
<a href="{{ abs_url_for('users.email_notifications', username=notification.user.username) }}"> <a href="{{ abs_url_for('users.email_notifications', username=notification.user.username) }}">
{{ _("Manage your preferences") }} {{ _("Manage your preferences") }}

View File

@ -2,9 +2,9 @@
{% block content %} {% block content %}
{% for title, group in notifications | selectattr("package") | groupby("package.title") %} {% for type, group in notifications | groupby("package.title") %}
<h2> <h2>
{{ title }} {{ type or _("Other Notifications") }}
</h2> </h2>
<ul> <ul>
@ -17,34 +17,17 @@
</ul> </ul>
{% endfor %} {% endfor %}
{% set other_notifications = notifications | selectattr("package", "none") %}
{% if other_notifications %}
<h2>
{{ _("Other Notifications") }}
</h2>
<ul>
{% for notification in other_notifications %}
<li>
<a href="{{ notification.url | abs_url }}">{{ notification.title }}</a> -
{{ _("from %(username)s.", username=notification.causer.username) }}
</li>
{% endfor %}
</ul>
{% endif %}
<p style="margin-top: 3em;"> <p style="margin-top: 3em;">
<a class="btn" href="{{ abs_url_for('notifications.list_all') }}"> <a class="btn" href="{{ abs_url_for('notifications.list_all') }}">
{{ _("View Notifications") }} View Notifications
</a> </a>
</p> </p>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{{ _("You are receiving this email because you are a registered user of ContentDB, and have email notifications enabled.") }} You are receiving this email because you are a registered user of ContentDB,
<br> and have email notifications enabled. <br>
<a href="{{ abs_url_for('users.email_notifications', username=user.username) }}"> <a href="{{ abs_url_for('users.email_notifications', username=user.username) }}">
{{ _("Manage your preferences") }} {{ _("Manage your preferences") }}

View File

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

View File

@ -1,34 +1,33 @@
{% extends "emails/base.html" %} {% extends "emails/base.html" %}
{% block content %} {% block content %}
<h2 style="margin-top: 0;">{{ _("Hello!") }}</h2> <h2 style="margin-top: 0;">Hello!</h2>
<p> <p>
{{ _("This email has been sent to you because someone (hopefully you) has entered your email address as a user's email.") }} This email has been sent to you because someone (hopefully you)
has entered your email address as a user's email.
</p> </p>
<p> <p>
{{ _("If it wasn't you, then just delete this email.") }} If it wasn't you, then just delete this email.
</p> </p>
<p> <p>
{{ _("If this was you, then please click this link to confirm the address:") }} If this was you, then please click this link to confirm the address:
</p> </p>
<a class="btn" href="{{ abs_url_for('users.verify_email', token=token) }}"> <a class="btn" href="{{ abs_url_for('users.verify_email', token=token) }}">
{{ _("Confirm Email Address") }} Confirm Email Address
</a> </a>
<p style="font-size: 80%;"> <p style="font-size: 80%;">
{{ _("Or paste this into your browser:") }} Or paste this into your browser: <code>{{ abs_url_for('users.verify_email', token=token) }}</code>
<code>{{ abs_url_for('users.verify_email', token=token) }}</code>
<p> <p>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{{ _("You are receiving this email because someone (hopefully you) entered your email address as a user's email.") }} You are receiving this email because someone (hopefully you) entered your email address as a user's email. <br>
<br>
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}"> <a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
{{ _("Unsubscribe") }} {{ _("Unsubscribe") }}
</a> </a>

View File

@ -1,24 +1,22 @@
{% extends "emails/base.html" %} {% extends "emails/base.html" %}
{% block content %} {% block content %}
<h2 style="margin-top: 0;"> <h2 style="margin-top: 0;">Hello!</h2>
{{ _("Hello!") }}
</h2>
<p> <p>
{{ _("We're sorry to see you go. You just need to do one more thing before your email is blacklisted.") }} We're sorry to see you go. You just need to do one more thing before your email is blacklisted.
</p> </p>
<a class="btn" href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}"> <a class="btn" href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
{{ _("Unsubscribe") }} Unsubscribe
</a> </a>
<p style="font-size: 80%;"> <p style="font-size: 80%;">
{{ _("Or paste this into your browser:") }} <code>{{ abs_url_for('users.unsubscribe', token=sub.token) }}</code> Or paste this into your browser: <code>{{ abs_url_for('users.unsubscribe', token=sub.token) }}</code>
<p> <p>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{{ _("You are receiving this email because someone (hopefully you) entered your email address in the unsubscribe form.") }} You are receiving this email because someone (hopefully you) entered your email address in the unsubscribe form.
{% endblock %} {% endblock %}

View File

@ -21,81 +21,7 @@
{% block content %} {% block content %}
{% from "macros/packagegridtile.html" import render_pkggrid %} {% from "macros/packagegridtile.html" import render_pkggrid %}
<div id="featuredCarousel" class="carousel slide my-0" data-ride="carousel" data-interval="7500">
<ol class="carousel-indicators">
{% for package in featured %}
<li data-target="#featuredCarousel" data-slide-to="{{ loop.index - 1 }}" {% if loop.index == 1 %}class="active"{% endif %}></li>
{% endfor %}
</ol>
<div class="carousel-inner">
{% for package in featured %}
{% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %}
{% set tags = package.tags | sort(attribute="views", reverse=True) %}
<div class="carousel-item {% if loop.index == 1 %}active{% endif %}">
<a href="{{ package.getURL("packages.view") }}">
<div class="embed-responsive embed-responsive-16by9">
<img class="embed-responsive-item" src="{{ cover_image }}"
alt="{{ _('%(title)s by %(author)s', title=package.title, author=package.author.display_name) }}">
</div>
<div class="carousel-caption text-shadow">
<h3 class="mt-0 mb-3">
{% if package.author %}
{{ _('<strong>%(title)s</strong> by %(author)s', title=package.title, author=package.author.display_name) }}
{% else %}
<strong>{{ package.title }}</strong>
{% endif %}
</h3>
<p>
{{ package.short_desc }}
</p>
{% if package.author %}
<div class="d-none d-md-block">
<span class="mr-2">
{{ package.type.text }}
</span>
{% for warning in package.content_warnings %}
<span class="badge badge-warning" title="{{ warning.description }}">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ warning.title }}
</span>
{% endfor %}
{% for t in tags[:3] %}
{% if t.name != "featured" %}
<span class="badge badge-primary" title="{{ t.description or '' }}">
{{ t.title }}
</span>
{% endif %}
{% endfor %}
<span class="btn" title="{{ _('Reviews') }}">
<i class="fas fa-star-half-alt"></i>
<span class="count">
+{{ package.reviews | selectattr("recommends") | list | length }}
/
-{{ package.reviews | rejectattr("recommends") | list | length }}
</span>
</span>
</div>
{% endif %}
</div>
</a>
</div>
{% endfor %}
</div>
<a class="carousel-control-prev" href="#featuredCarousel" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">{{ _("Previous") }}</span>
</a>
<a class="carousel-control-next" href="#featuredCarousel" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">{{ _("Next") }}</span>
</a>
</div>
<div class="text-right mb-5 text-muted" style="opacity: 0.4;">
<a href="/help/featured/" class="btn">
<i class="fas fa-question-circle mr-1"></i>
{{ _("Featured") }}
</a>
</div>
<a href="{{ url_for('packages.list_all', sort='approved_at', order='desc') }}" class="btn btn-secondary float-right"> <a href="{{ url_for('packages.list_all', sort='approved_at', order='desc') }}" class="btn btn-secondary float-right">
{{ _("See more") }} {{ _("See more") }}
@ -150,7 +76,7 @@
<a href="{{ url_for('packages.list_all', sort='reviews', order='desc') }}" class="btn btn-secondary float-right"> <a href="{{ url_for('packages.list_all', sort='reviews', order='desc') }}" class="btn btn-secondary float-right">
{{ _("See more") }} {{ _("See more") }}
</a> </a>
<h2 class="my-3">{{ _("Highest Reviewed") }}</h2> <h2 class="my-3">{{ _("Top Reviewed") }}</h2>
{{ render_pkggrid(high_reviewed) }} {{ render_pkggrid(high_reviewed) }}
@ -158,7 +84,7 @@
{{ _("See more") }} {{ _("See more") }}
</a> </a>
<h2 class="my-3">{{ _("Recent Positive Reviews") }}</h2> <h2 class="my-3">{{ _("Recent Positive Reviews") }}</h2>
{% from "macros/reviews.html" import render_reviews with context %} {% from "macros/reviews.html" import render_reviews %}
{{ render_reviews(reviews, current_user, True) }} {{ render_reviews(reviews, current_user, True) }}

View File

@ -3,7 +3,7 @@
{% for entry in log %} {% for entry in log %}
<a class="list-group-item list-group-item-action" <a class="list-group-item list-group-item-action"
{% if entry.description and current_user.rank.atLeast(current_user.rank.MODERATOR) %} {% if entry.description and current_user.rank.atLeast(current_user.rank.MODERATOR) %}
href="{{ url_for('admin.audit_view', id_=entry.id) }}"> href="{{ url_for('admin.audit_view', id=entry.id) }}">
{% else %} {% else %}
href="{{ entry.url }}"> href="{{ entry.url }}">
{% endif %} {% endif %}
@ -29,7 +29,7 @@
<span class="pl-2">{{ entry.causer.username }}</span> <span class="pl-2">{{ entry.causer.username }}</span>
{% else %} {% else %}
<i>{{ _("Deleted User") }}</i> <i>Deleted User</i>
{% endif %} {% endif %}
</div> </div>
@ -61,7 +61,7 @@
</div> </div>
</a> </a>
{% else %} {% else %}
<p class="list-group-item"><i>{{ _("No audit log entries.") }}</i></p> <p class="list-group-item"><i>No audit log entires.</i></p>
{% endfor %} {% endfor %}
</div> </div>
{% endmacro %} {% endmacro %}

View File

@ -49,7 +49,7 @@
</div> </div>
{{ field(class_=fieldclass or 'form-control', **kwargs) }} {{ field(class_=fieldclass or 'form-control', **kwargs) }}
<a class="btn btn-secondary" id="{{ field.name }}-button"> <a class="btn btn-secondary" id="{{ field.name }}-button">
{{ _("View") }} View
</a> </a>
</div> </div>
@ -104,7 +104,7 @@
<label for="{{ field.id }}">{{ label|safe }}</label> <label for="{{ field.id }}">{{ label|safe }}</label>
{% endif %} {% endif %}
<div class="multichoice_selector bulletselector form-control"> <div class="multichoice_selector bulletselector form-control">
<input type="text" placeholder="{{ _('Start typing to see suggestions') }}"> <input type="text" placeholder="Start typing to see suggestions">
<div class="clearboth"></div> <div class="clearboth"></div>
</div> </div>
<div class="invalid-remaining invalid-feedback"></div> <div class="invalid-remaining invalid-feedback"></div>

View File

@ -1,8 +1,8 @@
{% macro render_banners(package, current_user, topic_error, topic_error_lvl, conflicting_modnames) -%} {% macro render_banners(package, current_user, topic_error, topic_error_lvl, similar_topics) -%}
<div class="row mb-4"> <div class="row mb-4">
<span class="col"> <span class="col">
{{ _("State") }}: <strong>{{ package.state.value }}</strong> State: <strong>{{ package.state.value }}</strong>
</span> </span>
{% for state in package.getNextStates(current_user) %} {% for state in package.getNextStates(current_user) %}
@ -14,62 +14,51 @@
</div> </div>
{% set level = "warning" %} {% set level = "warning" %}
{% if package.releases.filter_by(task_id=None).count() == 0 %} {% if package.releases.count() == 0 %}
{% set message %} {% set message %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %} {% if package.checkPerm(current_user, "MAKE_RELEASE") %}
{% if package.update_config %} {% if package.update_config %}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.create_release') }}"> <a class="btn btn-sm btn-warning float-right" href="{{ package.getCreateReleaseURL() }}">Create first release</a>
{{ _("Create release") }}
</a>
{% else %} {% else %}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.setup_releases') }}"> <a class="btn btn-sm btn-warning float-right" href="{{ package.getSetupReleasesURL() }}">Set up releases</a>
{{ _("Set up releases") }}
</a>
{% endif %}
{% if package.releases.count() == 0 %}
{{ _("You need to create a release before this package can be approved.") }}
{% else %}
{{ _("Release is still importing, or has an error.") }}
{% endif %} {% endif %}
{{ _("You need to create a release before this package can be approved.") }}
{% else %} {% else %}
{{ _("A release is required before this package can be approved.") }} {{ _("A release is required before this package can be approved.") }}
{% endif %} {% endif %}
{% endset %} {% endset %}
{% elif (package.type == package.type.GAME or package.type == package.type.TXP) and package.screenshots.count() == 0 %} {% elif (package.type == package.type.GAME or package.type == package.type.TXP) and package.screenshots.count() == 0 %}
{% set message = _("You need to add at least one screenshot.") %} {% set message = "You need to add at least one screenshot." %}
{% elif package.getMissingHardDependenciesQuery().count() > 0 %} {% elif package.getMissingHardDependenciesQuery().count() > 0 %}
{% set deps = package.getMissingHardDependencies() | join(", ") %} {% set deps = package.getMissingHardDependencies() | join(", ") %}
{% set message = _("The following hard dependencies need to be added to ContentDB first: %(deps)s", deps=deps) %} {% set message = "The following hard dependencies need to be added to ContentDB first: " + deps %}
{% elif topic_error_lvl == "danger" %} {% elif topic_error_lvl == "danger" %}
{% elif package.state == package.state.READY_FOR_REVIEW and ("Other" in package.license.name or "Other" in package.media_license.name) %} {% elif package.state == package.state.READY_FOR_REVIEW and ("Other" in package.license.name or "Other" in package.media_license.name) %}
{% set message = _("Please wait for the license to be added to CDB.") %} {% set message = "Please wait for the license to be added to CDB." %}
{% else %} {% else %}
{% set level = "info" %} {% set level = "info" %}
{% set message %} {% set message %}
{% if package.screenshots.count() == 0 %} {% if package.screenshots.count() == 0 %}
<b> <b>You should add at least one screenshot, but this isn't required.</b><br />
{{ _("You should add at least one screenshot, but this isn't required.") }}
</b><br />
{% endif %} {% endif %}
{% if package.state == package.state.READY_FOR_REVIEW %} {% if package.state == package.state.READY_FOR_REVIEW %}
{% if not package.getDownloadRelease() %} {% if not package.getDownloadRelease() %}
{{ _("Please wait for the release to be approved.") }} Please wait for the release to be approved.
{% elif package.checkPerm(current_user, "APPROVE_NEW") %} {% elif package.checkPerm(current_user, "APPROVE_NEW") %}
{{ _("You can now approve this package if you're ready.") }} You can now approve this package if you're ready.
{% else %} {% else %}
{{ _("Please wait for the package to be approved.") }} Please wait for the package to be approved.
{% endif %} {% endif %}
{% else %} {% else %}
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %} {% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
{{ _("You can now submit this package for approval if you're ready.") }} You can now submit this package for approval if you're ready.
{% else %} {% else %}
{{ _("This package can be submitted for approval when ready.") }} This package can be submitted for approval when ready.
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endset %} {% endset %}
@ -93,27 +82,22 @@
</div> </div>
{% endif %} {% endif %}
{% if conflicting_modnames %} {% if similar_topics %}
<div class="alert alert-warning"> <div class="alert alert-warning">
<a class="float-right btn btn-sm btn-warning" href="{{ package.getURL('packages.similar') }}"> Please make sure that this package has the right to
More info the name '{{ package.name }}'.
</a> See the
{% if conflicting_modnames | length > 4 %} <a href="/policy_and_guidance/">Inclusion Policy</a>
{{ _("Please make sure that this package has the right to the names it uses.") }} for more info.
{% else %}
{{ _("Please make sure that this package has the right to the names %(names)s", names=conflicting_modnames | join(", ")) }}.
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% if not package.review_thread and (package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW")) %} {% if not package.review_thread and (package.author == current_user or package.checkPerm(current_user, "APPROVE_NEW")) %}
<div class="alert alert-secondary"> <div class="alert alert-secondary">
<a class="float-right btn btn-sm btn-secondary" href="{{ url_for('threads.new', pid=package.id, title='Package approval comments') }}"> <a class="float-right btn btn-sm btn-secondary" href="{{ url_for('threads.new', pid=package.id, title='Package approval comments') }}">Open Thread</a>
{{ _("Open Thread") }}
</a>
{{ _("Package approval thread") }}: {{ _("Package review thread") }}:
{{ _("You can open a thread if you have a question for the approver or package author.") }} {{ _("You can open a thread if you have a question for the reviewer or package author.") }}
<div style="clear:both;"></div> <div style="clear:both;"></div>
</div> </div>
{% endif %} {% endif %}

View File

@ -1,5 +1,5 @@
{% macro render_pkgtile(package, show_author) -%} {% macro render_pkgtile(package, show_author) -%}
<li class="packagetile flex-fill"><a href="{{ package.getURL("packages.view") }}" <li class="packagetile flex-fill"><a href="{{ package.getDetailsURL() }}"
style="background-image: url({{ package.getThumbnailOrPlaceholder(2) }});"> style="background-image: url({{ package.getThumbnailOrPlaceholder(2) }});">
<div class="packagegridscrub"></div> <div class="packagegridscrub"></div>
<div class="packagegridinfo"> <div class="packagegridinfo">
@ -9,11 +9,6 @@
{% if show_author %}<br /> {% if show_author %}<br />
<small>{{ package.author.display_name }}</small> <small>{{ package.author.display_name }}</small>
{% endif %} {% endif %}
{% if not package.approved %}
<span class="badge ml-1 {% if package.state == package.state.CHANGES_NEEDED %}bg-danger{% else %}bg-warning{% endif %}">
{{ package.state.value }}
</span>
{% endif %}
</h3> </h3>
<p> <p>
@ -22,15 +17,15 @@
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %} {% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
<p style="color:#f33;"> <p style="color:#f33;">
{{ _("<b>Warning:</b> Non-free code and media.") }} <b>Warning:</b> Non-free code and media.
</p> </p>
{% elif not package.license.is_foss and package.type != package.type.TXP %} {% elif not package.license.is_foss and package.type != package.type.TXP %}
<p style="color:#f33;"> <p style="color:#f33;">
{{ _("<b>Warning:</b> Non-free code.") }} <b>Warning:</b> Non-free code.
</p> </p>
{% elif not package.media_license.is_foss %} {% elif not package.media_license.is_foss %}
<p style="color:#f33;"> <p style="color:#f33;">
{{ _("<b>Warning:</b> Non-free media.") }} <b>Warning:</b> Non-free media.
</p> </p>
{% endif %} {% endif %}
</div> </div>
@ -42,7 +37,7 @@
{% for p in packages %} {% for p in packages %}
{{ render_pkgtile(p, show_author) }} {{ render_pkgtile(p, show_author) }}
{% else %} {% else %}
<li class="packagetile flex-fill"><i>{{ _("No packages available") }}</i></li> <li class="packagetile flex-fill"><i>No packages available</i></li>
{% endfor %} {% endfor %}
{% if packages %} {% if packages %}
{% for i in range(4) %} {% for i in range(4) %}

View File

@ -1,35 +1,22 @@
{% macro render_releases_edit(releases, package) %} {% macro render_releases(releases, package, current_user) -%}
{% for rel in releases %}
<a class="list-group-item list-group-item-action" href="{{ rel.getEditURL() }}">
{{ rel.title }}
<span class="text-muted ml-1">
{% if rel.min_rel and rel.max_rel %}
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
{% elif rel.min_rel %}
[MT {{ rel.min_rel.name }}+]
{% elif rel.max_rel %}
[MT &le;{{ rel.max_rel.name }}]
{% endif %}
</span>
<br />
<small style="color:#999;">
{% if rel.commit_hash %}
[{{ rel.commit_hash | truncate(5, end='') }}]
{% endif %}
{{ _("created %(date)s", date=rel.releaseDate | date) }}.
</small>
</a>
{% endfor %}
{% endmacro %}
{% macro render_releases_download(releases, package, current_user) %}
{% for rel in releases %} {% for rel in releases %}
{% if rel.approved or package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE") %} {% if rel.approved or package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE") %}
<a class="list-group-item list-group-item-action" href="{{ rel.getDownloadURL() }}"> <li class="list-group-item">
{{ rel.title }} {% if package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE") %}
<span class="text-muted ml-1"> <a class="btn btn-sm btn-primary float-right" href="{{ rel.getEditURL() }}">Edit
{% if not rel.task_id and not rel.approved and rel.checkPerm(current_user, "APPROVE_RELEASE") %}
/ Approve
{% endif %}
</a>
{% endif %}
{% if not rel.approved %}<i>{% endif %}
<a href="{{ rel.getDownloadURL() }}" rel="nofollow" download="{{ rel.getDownloadFileName() }}">
{{ rel.title }}
</a>
<span style="color:#ddd;">
{% if rel.min_rel and rel.max_rel %} {% if rel.min_rel and rel.max_rel %}
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}] [MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
{% elif rel.min_rel %} {% elif rel.min_rel %}
@ -38,69 +25,27 @@
[MT &le;{{ rel.max_rel.name }}] [MT &le;{{ rel.max_rel.name }}]
{% endif %} {% endif %}
</span> </span>
<br />
<br>
<small style="color:#999;"> <small style="color:#999;">
{% if rel.commit_hash %} {% if rel.commit_hash %}
[{{ rel.commit_hash | truncate(5, end='') }}] [{{ rel.commit_hash | truncate(5, end='') }}]
{% endif %} {% endif %}
{{ _("created %(date)s", date=rel.releaseDate | date) }}. created {{ rel.releaseDate | date }}.
</small> </small>
</a> {% if (package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE")) and rel.task_id %}
<a href="{{ url_for('tasks.check', id=rel.task_id, r=package.getDetailsURL()) }}">Importing...</a>
{% elif not rel.approved %}
Waiting for approval.
{% endif %}
{% if not rel.approved %}</i>{% endif %}
</li>
{% endif %} {% endif %}
{% endfor %}
{% endmacro %}
{% macro render_releases(releases, package, current_user) -%}
{% for rel in releases %}
<div class="list-group-item">
<a class="btn btn-sm btn-primary float-right" href="{{ rel.getEditURL() }}">
{% if not rel.task_id and not rel.approved and rel.checkPerm(current_user, "APPROVE_RELEASE") %}
{{ _("Edit / Approve") }}
{% else %}
{{ _("Edit") }}
{% endif %}
</a>
{% if not rel.approved %}<i>{% endif %}
<a href="{{ rel.getDownloadURL() }}" rel="nofollow" download="{{ rel.getDownloadFileName() }}">
{{ rel.title }}
</a>
<span class="text-muted ml-1">
{% if rel.min_rel and rel.max_rel %}
[MT {{ rel.min_rel.name }}-{{ rel.max_rel.name }}]
{% elif rel.min_rel %}
[MT {{ rel.min_rel.name }}+]
{% elif rel.max_rel %}
[MT &le;{{ rel.max_rel.name }}]
{% endif %}
</span>
<br>
<small style="color:#999;">
{% if rel.commit_hash %}
[{{ rel.commit_hash | truncate(5, end='') }}]
{% endif %}
{{ _("created %(date)s", date=rel.releaseDate | date) }}.
</small>
{% if (package.checkPerm(current_user, "MAKE_RELEASE") or rel.checkPerm(current_user, "APPROVE_RELEASE")) and rel.task_id %}
<a href="{{ url_for('tasks.check', id=rel.task_id, r=package.getURL("packages.view")) }}">
{{ _("Importing...") }}
</a>
{% elif not rel.approved %}
{{ _("Waiting for approval.") }}
{% endif %}
{% if not rel.approved %}</i>{% endif %}
</div>
{% else %} {% else %}
<div class="list-group-item"> <li class="list-group-item">No releases available.</li>
{{ _("No releases available.") }}
</div>
{% endfor %} {% endfor %}
{% endmacro %} {% endmacro %}

View File

@ -1,30 +1,7 @@
{% macro render_review_vote(review, current_user, next_url) %}
{% set (positive, negative, is_positive) = review.get_totals(current_user) %}
<form class="-group" method="post" action="{{ review.getVoteUrl(next_url) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="btn-group">
<button class="btn {% if is_positive == true %}btn-primary{% else %}btn-secondary{% endif %}" name="is_positive" value="yes">
{{ _("Helpful") }}
{% if positive > 0 %}
<span class="badge badge-light ml-1">{{ positive }}</span>
{% endif %}
</button>
<button class="btn {% if is_positive == false %}btn-primary{% else %}btn-secondary{% endif %}" name="is_positive" value="no">
{{ _("Unhelpful") }}
{% if negative > 0 %}
<span class="badge badge-light ml-1">{{ negative }}</span>
{% endif %}
</button>
</div>
</form>
{% endmacro %}
{% macro render_reviews(reviews, current_user, show_package_link=False) -%} {% macro render_reviews(reviews, current_user, show_package_link=False) -%}
<ul class="comments mt-4 mb-0"> <ul class="comments mt-4 mb-0">
{% for review in reviews %} {% for review in reviews %}
{% set review_anchor = "review-" + (review.id | string) %}
<li class="row my-2 mx-0"> <li class="row my-2 mx-0">
<a id="{{ review_anchor }}"></a>
<div class="col-md-1 p-1"> <div class="col-md-1 p-1">
<a href="{{ url_for('users.profile', username=review.author.username) }}"> <a href="{{ url_for('users.profile', username=review.author.username) }}">
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ review.author.getProfilePicURL() }}"> <img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ review.author.getProfilePicURL() }}">
@ -56,7 +33,7 @@
<div class="card-body markdown"> <div class="card-body markdown">
{% if current_user == review.author %} {% if current_user == review.author %}
<a class="btn btn-primary btn-sm ml-1 float-right" <a class="btn btn-primary btn-sm ml-1 float-right"
href="{{ review.package.getURL("packages.review") }}"> href="{{ review.package.getReviewURL() }}">
<i class="fas fa-pen"></i> <i class="fas fa-pen"></i>
</a> </a>
{% endif %} {% endif %}
@ -67,23 +44,21 @@
{{ reply.comment | markdown }} {{ reply.comment | markdown }}
<div class="btn-toolbar mt-2 mb-0"> <p class="mt-2 mb-0">
{% if show_package_link %} {% if show_package_link %}
<a class="btn btn-primary mr-1" href="{{ review.package.getURL("packages.view") }}"> <a class="btn btn-primary mr-1" href="{{ review.package.getDetailsURL() }}">
{{ _("%(title)s by %(author)s", {{ _("%(title)s by %(author)s",
title="<b>" | safe + review.package.title + "</b>" | safe, title="<b>" | safe + review.package.title + "</b>" | safe,
author=review.package.author.display_name) }} author=review.package.author.display_name) }}
</a> </a>
{% endif %} {% endif %}
<a class="btn {% if review.thread.replies.count() > 1 %} btn-primary {% else %} btn-secondary {% endif %} mr-1" <a class="btn {% if review.thread.replies.count() > 1 %} btn-primary {% else %} btn-secondary {% endif %}"
href="{{ url_for('threads.view', id=review.thread.id) }}"> href="{{ url_for('threads.view', id=review.thread.id) }}">
<i class="fas fa-comments mr-2"></i> <i class="fas fa-comments mr-2"></i>
{{ _("%(num)d comments", num=review.thread.replies.count() - 1) }} {{ _("%(num)d comments", num=review.thread.replies.count() - 1) }}
</a> </a>
</p>
{{ render_review_vote(review, current_user, url_set_anchor(review_anchor)) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -103,10 +78,10 @@
<div class="card-header"> <div class="card-header">
{{ _("Review") }} {{ _("Review") }}
</div> </div>
<form method="post" action="{{ package.getURL("packages.review") }}" class="card-body"> <form method="post" action="{{ package.getReviewURL() }}" class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<p> <p>
{{ _("Do you recommend this %(type)s?", type=package.type.text | lower) }} {{ _("Do you recommend this %(type)s?", type=package.type.value | lower) }}
</p> </p>
<div class="btn-group btn-group-toggle" data-toggle="buttons"> <div class="btn-group btn-group-toggle" data-toggle="buttons">
@ -142,10 +117,10 @@
<div class="card-header"> <div class="card-header">
{{ _("Review") }} {{ _("Review") }}
</div> </div>
<form method="post" action="{{ package.getURL("packages.review") }}" class="card-body"> <form method="post" action="{{ package.getReviewURL() }}" class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<p> <p>
{{ _("Do you recommend this %(type)s?", type=package.type.text | lower) }} {{ _("Do you recommend this %(type)s?", type=package.type.value | lower) }}
</p> </p>
<div class="btn-group"> <div class="btn-group">

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