Compare commits

..

1 Commits

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

View File

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

View File

@@ -4,13 +4,7 @@
Content database for Minetest mods, games, and more.\
Developed by rubenwardy, license AGPLv3.0+.
See [Getting Started](docs/getting_started.md) for setting up a development/prodiction environment.
See [Developer Intro](docs/dev_intro.md) for an overview of the code organisation.
## Credits
* `app/public/static/placeholder.png`: erlehmann, Warr1024. License: CC BY-SA 3.0
See [Getting Started](docs/getting_started.md).
## How-tos

View File

@@ -13,7 +13,6 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import *
@@ -27,6 +26,7 @@ from flask_login import logout_user, current_user, LoginManager
import os, redis
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
app = Flask(__name__, static_folder="public/static")
app.config["FLATPAGES_ROOT"] = "flatpages"
app.config["FLATPAGES_EXTENSION"] = ".md"
@@ -39,10 +39,6 @@ app.config["LANGUAGES"] = {
"fr": "Français",
"id": "Bahasa Indonesia",
"ms": "Bahasa Melayu",
"pl": "Język Polski",
"ru": "русский язык",
"sk": "Slovenčina",
"zh_Hans": "汉语",
}
app.config.from_pyfile(os.environ["FLASK_CONFIG"])
@@ -69,7 +65,7 @@ login_manager.init_app(app)
login_manager.login_view = "users.login"
from .sass import init_app as sass
from .sass import sass
sass(app)
@@ -89,39 +85,27 @@ def load_user(user_id):
from .blueprints import create_blueprints
create_blueprints(app)
@app.route("/uploads/<path:path>")
def send_upload(path):
return send_from_directory(app.config["UPLOAD_DIR"], path)
@app.route("/<path:path>/")
def flatpage(path):
page = pages.get_or_404(path)
template = page.meta.get("template", "flatpage.html")
return render_template(template, page=page)
@app.before_request
def check_for_ban():
if current_user.is_authenticated:
if current_user.ban and current_user.ban.has_expired:
models.db.session.delete(current_user.ban)
if current_user.rank == models.UserRank.BANNED:
current_user.rank = models.UserRank.MEMBER
models.db.session.commit()
elif current_user.ban or current_user.rank == models.UserRank.BANNED:
if current_user.ban:
flash(gettext("Banned:") + " " + current_user.ban.message, "danger")
else:
flash(gettext("You have been banned."), "danger")
if current_user.rank == models.UserRank.BANNED:
flash(gettext("You have been banned."), "danger")
logout_user()
return redirect(url_for("users.login"))
elif current_user.rank == models.UserRank.NOT_JOINED:
current_user.rank = models.UserRank.MEMBER
models.db.session.commit()
from .utils import clearNotifications, is_safe_url
@@ -130,40 +114,23 @@ def check_for_notifications():
if current_user.is_authenticated:
clearNotifications(request.path)
@app.errorhandler(404)
def page_not_found(e):
return render_template("404.html"), 404
@app.errorhandler(500)
def server_error(e):
return render_template("500.html"), 500
@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
if request:
locale = request.cookies.get("locale")
if locale in locales:
return locale
locale = request.cookies.get("locale")
if locale not in locales:
locale = request.accept_languages.best_match(locales)
return request.accept_languages.best_match(locales)
if locale and current_user.is_authenticated:
new_session = models.db.create_session({})()
new_session.query(models.User) \
.filter(models.User.username == current_user.username) \
.update({ "locale": locale })
new_session.commit()
new_session.close()
return locale
return None
@app.route("/set-locale/", methods=["POST"])
@@ -185,8 +152,4 @@ def set_locale():
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

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

View File

@@ -14,10 +14,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, render_template, url_for, request, flash
from flask import *
from flask_login import current_user, login_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms import *
from wtforms.validators import InputRequired, Length
from app.utils import rank_required, addAuditLog, addNotification, get_system_user
from . import bp
@@ -48,10 +48,9 @@ def admin_page():
else:
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)
class SwitchUserForm(FlaskForm):
username = StringField("Username")
submit = SubmitField("Switch")
@@ -70,13 +69,14 @@ def switch_user():
else:
flash("Unable to login as user", "danger")
# Process GET or invalid POST
return render_template("admin/switch_user.html", form=form)
class SendNotificationForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(1, 300)])
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
title = StringField("Title", [InputRequired(), Length(1, 300)])
url = StringField("URL", [InputRequired(), Length(1, 100)], default="/")
submit = SubmitField("Send")
@@ -86,7 +86,7 @@ def send_bulk_notification():
form = SendNotificationForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk notification", url_for("admin.admin_page"), None, form.title.data)
"Sent bulk notification", None, None, form.title.data)
users = User.query.filter(User.rank >= UserRank.NEW_MEMBER).all()
addNotification(users, get_system_user(), NotificationType.OTHER, form.title.data, form.url.data, None)
@@ -121,10 +121,5 @@ def restore():
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)
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)
@bp.route("/admin/audit/<int:id_>/")
@bp.route("/admin/audit/<int:id>/")
@rank_required(UserRank.MODERATOR)
def audit_view(id_):
entry = AuditLogEntry.query.get(id_)
def audit_view(id):
entry = AuditLogEntry.query.get(id)
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
# 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_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField, StringField
from wtforms.validators import InputRequired, Length
from wtforms import *
from wtforms.validators import *
from app.markdown import render_markdown
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
from app.models import *
from app.tasks.emails import send_user_email
from app.utils import rank_required, addAuditLog
from . import bp
from ...models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm):
@@ -54,7 +55,7 @@ def send_single_email():
text = form.text.data
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 render_template("admin/send_email.html", form=form, user=user)
@@ -66,11 +67,12 @@ def send_bulk_email():
form = SendEmailForm(request.form)
if form.validate_on_submit():
addAuditLog(AuditSeverity.MODERATION, current_user,
"Sent bulk email", url_for("admin.admin_page"), None, form.text.data)
"Sent bulk email", None, None, form.text.data)
text = form.text.data
html = render_markdown(text)
task_send_bulk.delay(form.subject.data, text, html)
for user in User.query.filter(User.email != None).all():
send_user_email.delay(user.email, form.subject.data, text, html)
return redirect(url_for("admin.admin_page"))

View File

@@ -15,14 +15,15 @@
# 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 wtforms import StringField, BooleanField, SubmitField, URLField
from wtforms.validators import InputRequired, Length, Optional
from wtforms import *
from wtforms.fields.html5 import URLField
from wtforms.validators import *
from app.models import *
from app.utils import rank_required, nonEmptyOrNone
from . import bp
from ...models import UserRank, License, db
@bp.route("/licenses/")
@@ -30,13 +31,11 @@ from ...models import UserRank, License, db
def license_list():
return render_template("admin/licenses/list.html", licenses=License.query.order_by(db.asc(License.name)).all())
class LicenseForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3, 100)])
is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
submit = SubmitField("Save")
name = StringField("Name", [InputRequired(), Length(3,100)])
is_foss = BooleanField("Is FOSS")
url = URLField("URL", [Optional], filters=[nonEmptyOrNone])
submit = SubmitField("Save")
@bp.route("/licenses/new/", methods=["GET", "POST"])
@bp.route("/licenses/<name>/edit/", methods=["GET", "POST"])

View File

@@ -15,14 +15,14 @@
# 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_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from wtforms import *
from wtforms.validators import *
from app.models import *
from . import bp
from ...models import Permission, Tag, db
@bp.route("/tags/")
@@ -40,14 +40,12 @@ def tag_list():
return render_template("admin/tags/list.html", tags=query.all())
class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)])
title = StringField("Title", [InputRequired(), Length(3,100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
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/<name>/edit/", methods=["GET", "POST"])

View File

@@ -15,14 +15,14 @@
# 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 wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import InputRequired, Length
from wtforms import *
from wtforms.validators import *
from app.models import *
from app.utils import rank_required
from . import bp
from ...models import UserRank, MinetestRelease, db
@bp.route("/versions/")
@@ -30,12 +30,10 @@ from ...models import UserRank, MinetestRelease, db
def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm):
name = StringField("Name", [InputRequired(), Length(3, 100)])
name = StringField("Name", [InputRequired(), Length(3,100)])
protocol = IntegerField("Protocol")
submit = SubmitField("Save")
submit = SubmitField("Save")
@bp.route("/versions/new/", 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/>.
from flask import redirect, render_template, abort, url_for, request, flash
from flask import *
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp
from wtforms import *
from wtforms.validators import *
from app.models import *
from app.utils import rank_required
from . import bp
from ...models import UserRank, ContentWarning, db
@bp.route("/admin/warnings/")
@@ -30,14 +30,11 @@ from ...models import UserRank, ContentWarning, db
def warning_list():
return render_template("admin/warnings/list.html", warnings=ContentWarning.query.order_by(db.asc(ContentWarning.title)).all())
class WarningForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)])
title = StringField("Title", [InputRequired(), Length(3,100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20),
Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")])
submit = SubmitField("Save")
@bp.route("/admin/warnings/new/", methods=["GET", "POST"])
@bp.route("/admin/warnings/<name>/edit/", methods=["GET", "POST"])

View File

@@ -13,14 +13,13 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import math
from typing import List
import flask_sqlalchemy
from flask import request, jsonify, current_app
from flask_login import current_user, login_required
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import subqueryload, joinedload
from sqlalchemy.sql.expression import func
from app import csrf
@@ -31,8 +30,7 @@ from app.querybuilder import QueryBuilder
from app.utils import is_package_page, get_int_or_abort, url_set_query, abs_url, isYes
from . import bp
from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
api_order_screenshots, api_edit_package, api_set_cover_image
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package
from functools import wraps
@@ -103,11 +101,10 @@ def resolve_package_deps(out, package, only_hard, depth=1):
elif dep.meta_package:
name = dep.meta_package.name
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages if pkg.state == PackageState.APPROVED]
fulfilled_by = [ pkg.getId() for pkg in dep.meta_package.packages]
if depth == 1 and not dep.optional:
most_likely = next((pkg for pkg in dep.meta_package.packages \
if pkg.type == PackageType.MOD and pkg.state == PackageState.APPROVED), None)
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)
@@ -305,7 +302,7 @@ def create_screenshot(token: APIToken, package: Package):
if file is None:
error(400, "Missing 'file' in multipart body")
return api_create_screenshot(token, package, data["title"], file, isYes(data.get("is_cover_image")))
return api_create_screenshot(token, package, data["title"], file)
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@@ -358,7 +355,7 @@ def order_screenshots(token: APIToken, package: Package):
error(401, "Authentication needed")
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):
error(403, "API token does not have access to the package")
@@ -370,28 +367,6 @@ def order_screenshots(token: APIToken, package: Package):
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
@@ -502,26 +477,6 @@ def homepage():
})
@bp.route("/api/welcome/v1/")
@cors_allowed
def welcome_v1():
featured = Package.query \
.filter(Package.type == PackageType.GAME, Package.state == PackageState.APPROVED,
Package.tags.any(name="featured")) \
.order_by(func.random()) \
.limit(5).all()
mtg = Package.query.filter(Package.author.has(username="Minetest"), Package.name == "minetest_game").one()
featured.insert(2, mtg)
def map_packages(packages: List[Package]):
return [pkg.getAsDictionaryShort(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({
"featured": map_packages(featured),
})
@bp.route("/api/minetest_versions/")
@cors_allowed
def versions():

View File

@@ -19,7 +19,7 @@ from flask import jsonify, abort, make_response, url_for, current_app
from app.logic.packages import do_edit_package
from app.logic.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
@@ -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):
error(403, "API token does not have access to the package")
reason += ", token=" + token.name
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, is_cover_image, reason)
ss : PackageScreenshot = guard(do_create_screenshot)(token.owner, package, title, file, reason)
return jsonify({
"success": True,
@@ -94,17 +94,6 @@ 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"):
if not token.canOperateOnPackage(package):
error(403, "API token does not have access to the package")

View File

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

View File

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

View File

@@ -65,4 +65,4 @@ def get_package_tabs(user: User, package: Package):
]
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

@@ -13,18 +13,18 @@
#
# 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 typing
from urllib.parse import quote as urlescape
from flask import render_template
from flask_babel import lazy_gettext, gettext
from flask_wtf import FlaskForm
from flask_login import login_required
from jinja2 import Markup
from sqlalchemy import or_, func, and_
from sqlalchemy import or_, func
from sqlalchemy.orm import joinedload, subqueryload
from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from wtforms.validators import *
from app.querybuilder import QueryBuilder
@@ -115,6 +115,9 @@ def getReleases(package):
@bp.route("/packages/<author>/<name>/")
@is_package_page
def view(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
show_similar = not package.approved and (
current_user in package.maintainers or
package.checkPerm(current_user, Permission.APPROVE_NEW))
@@ -123,7 +126,7 @@ def view(package):
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(and_(Package.id != package.id, Package.state == PackageState.APPROVED))) \
.filter(MetaPackage.packages.any(Package.id != package.id)) \
.all()
conflicting_modnames += db.session.query(ForumTopic.name) \
@@ -205,6 +208,9 @@ def shield(package, type):
@bp.route("/packages/<author>/<name>/download/")
@is_package_page
def download(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
release = package.getDownloadRelease()
if release is None:
@@ -244,67 +250,10 @@ class PackageForm(FlaskForm):
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"))
def handle_create_edit(package: typing.Optional[Package], form: PackageForm, author: User):
wasNew = False
if package is None:
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.DELETED:
package.review_thread_id = None
db.session.delete(package)
else:
flash(Markup(
f"<a class='btn btn-sm btn-danger float-right' href='{package.getURL('packages.view')}'>View</a>" +
gettext("Package already exists")), "danger")
return None
package = Package()
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
"video_url": form.video_url.data,
})
if wasNew:
msg = f"Created package {author.username}/{form.name.data}"
addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getURL("packages.view"), package)
if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id)
next_url = package.getURL("packages.view")
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.getURL("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
@bp.route("/packages/new/", methods=["GET", "POST"])
@bp.route("/packages/<author>/<name>/edit/", methods=["GET", "POST"])
@login_required
@@ -339,23 +288,65 @@ def create_edit(author=None, name=None):
# Initial form class from post data and default data
if request.method == "GET":
if package is None:
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.name.data = request.args.get("bname")
form.title.data = request.args.get("title")
form.repo.data = request.args.get("repo")
form.forums.data = request.args.get("forums")
form.license.data = None
form.media_license.data = None
else:
form.tags.data = package.tags
form.content_warnings.data = package.content_warnings
form.tags.data = list(package.tags)
form.content_warnings.data = list(package.content_warnings)
if request.method == "POST" and form.type.data == PackageType.TXP:
form.license.data = form.media_license.data
if form.validate_on_submit():
ret = handle_create_edit(package, form, author)
if ret:
return ret
wasNew = False
if not package:
package = Package.query.filter_by(name=form["name"].data, author_id=author.id).first()
if package is not None:
if package.state == PackageState.READY_FOR_REVIEW:
Package.query.filter_by(name=form["name"].data, author_id=author.id).delete()
else:
flash(gettext("Package already exists!"), "danger")
return redirect(url_for("packages.create_edit"))
package = Package()
package.author = author
package.maintainers.append(author)
wasNew = True
try:
do_edit_package(current_user, package, wasNew, True, {
"type": form.type.data,
"title": form.title.data,
"name": form.name.data,
"short_desc": form.short_desc.data,
"dev_state": form.dev_state.data,
"tags": form.tags.raw_data,
"content_warnings": form.content_warnings.raw_data,
"license": form.license.data,
"media_license": form.media_license.data,
"desc": form.desc.data,
"repo": form.repo.data,
"website": form.website.data,
"issueTracker": form.issueTracker.data,
"forums": form.forums.data,
})
if wasNew and package.repo is not None:
importRepoScreenshot.delay(package.id)
next_url = package.getURL("packages.view")
if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name):
next_url = url_for("flatpage", path="help/wtfpl", r=next_url)
elif wasNew:
next_url = package.getURL("packages.setup_releases")
return redirect(next_url)
except LogicError as e:
flash(e.message, "danger")
package_query = Package.query.filter_by(state=PackageState.APPROVED)
if package is not None:
@@ -427,7 +418,7 @@ def remove(package):
if "delete" in request.form:
if not package.checkPerm(current_user, Permission.DELETE_PACKAGE):
flash(gettext("You don't have permission to do that"), "danger")
flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.getURL("packages.view"))
package.state = PackageState.DELETED
@@ -435,7 +426,7 @@ def remove(package):
url = url_for("users.profile", username=package.author.username)
msg = "Deleted {}, reason={}".format(package.title, reason)
addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, url, package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url, package)
addAuditLog(AuditSeverity.EDITOR, current_user, msg, url)
db.session.commit()
flash(gettext("Deleted package"), "success")
@@ -443,7 +434,7 @@ def remove(package):
return redirect(url)
elif "unapprove" in request.form:
if not package.checkPerm(current_user, Permission.UNAPPROVE_PACKAGE):
flash(gettext("You don't have permission to do that"), "danger")
flash(gettext("You don't have permission to do that."), "danger")
return redirect(package.getURL("packages.view"))
package.state = PackageState.WIP
@@ -472,7 +463,7 @@ class PackageMaintainersForm(FlaskForm):
@is_package_page
def edit_maintainers(package):
if not package.checkPerm(current_user, Permission.EDIT_MAINTAINERS):
flash(gettext("You don't have permission to edit maintainers"), "danger")
flash(gettext("You do not have permission to edit maintainers"), "danger")
return redirect(package.getURL("packages.view"))
form = PackageMaintainersForm(formdata=request.form)
@@ -600,6 +591,9 @@ def alias_create_edit(package: Package, alias_id: int = None):
@login_required
@is_package_page
def share(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
return render_template("packages/share.html", package=package,
tabs=get_package_tabs(current_user, package), current_tab="share")
@@ -607,6 +601,9 @@ def share(package):
@bp.route("/packages/<author>/<name>/similar/")
@is_package_page
def similar(package):
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
packages_modnames = {}
for metapackage in package.provides:
packages_modnames[metapackage] = Package.query.filter(Package.id != package.id,

View File

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

View File

@@ -25,8 +25,8 @@ from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
Permission, AuditSeverity
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, addAuditLog
Permission
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required
from app.tasks.webhooktasks import post_discord_webhook
@@ -54,11 +54,10 @@ def review(package):
flash(gettext("You can't review your own package!"), "danger")
return redirect(package.getURL("packages.view"))
review = PackageReview.query.filter_by(package=package, author=current_user).first()
can_review = review is not None or current_user.canReviewRL()
if not package.checkPerm(current_user, Permission.SEE_PACKAGE):
abort(404)
if not can_review:
flash(gettext("You've reviewed too many packages recently. Please wait before trying again, and consider making your reviews more detailed"), "danger")
review = PackageReview.query.filter_by(package=package, author=current_user).first()
form = ReviewForm(formdata=request.form, obj=review)
@@ -69,7 +68,7 @@ def review(package):
form.comment.data = review.thread.replies[0].comment
# Validate and submit
elif can_review and form.validate_on_submit():
elif form.validate_on_submit():
was_new = False
if not review:
was_new = True
@@ -130,41 +129,28 @@ def review(package):
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
@is_package_page
def delete_review(package, reviewer):
review = PackageReview.query \
.filter(PackageReview.package == package, PackageReview.author.has(username=reviewer)) \
.first()
def delete_review(package):
review = PackageReview.query.filter_by(package=package, author=current_user).first()
if review is None or review.package != package:
abort(404)
if not review.checkPerm(current_user, Permission.DELETE_REVIEW):
abort(403)
thread = review.thread
reply = ThreadReply()
reply.thread = thread
reply.author = current_user
reply.comment = "_converted review into a thread_"
reply.is_status_update = True
db.session.add(reply)
thread.review = None
msg = "Converted review by {} to thread".format(review.author.display_name)
addAuditLog(AuditSeverity.MODERATION if current_user.username != reviewer else AuditSeverity.NORMAL,
current_user, msg, thread.getViewURL(), thread.package)
notif_msg = "Deleted review '{}', comments were kept as a thread".format(thread.title)
addNotification(package.maintainers, current_user, NotificationType.OTHER, notif_msg, url_for("threads.view", id=thread.id), package)
db.session.delete(review)
package.recalcScore()
db.session.commit()
return redirect(thread.getViewURL())
@@ -242,4 +228,4 @@ def review_votes(package):
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)
user_biases=user_biases_info)

View File

@@ -20,7 +20,7 @@ from flask_babel import gettext, lazy_gettext
from flask_wtf import FlaskForm
from flask_login import login_required
from wtforms import *
from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.utils import *
@@ -87,7 +87,7 @@ def create_screenshot(package):
form = CreateScreenshotForm()
if form.validate_on_submit():
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"))
except LogicError as e:
flash(e.message, "danger")

View File

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

View File

@@ -25,7 +25,6 @@ from app.utils import *
bp = Blueprint("tasks", __name__)
@csrf.exempt
@bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required
@@ -37,7 +36,6 @@ def start_getmeta():
"poll_url": url_for("tasks.check", id=aresult.id),
})
@bp.route("/tasks/<id>/")
def check(id):
result = celery.AsyncResult(id)
@@ -45,6 +43,7 @@ def check(id):
traceback = result.traceback
result = result.result
None
if isinstance(result, Exception):
info = {
'id': id,

View File

@@ -36,12 +36,10 @@ def list_all():
if not Permission.SEE_THREAD.check(current_user):
query = query.filter_by(private=False)
package = None
pid = request.args.get("pid")
if pid:
pid = get_int_or_abort(pid)
package = Package.query.get(pid)
query = query.filter_by(package=package)
query = query.filter_by(package_id=pid)
query = query.filter_by(review_id=None)
@@ -52,7 +50,7 @@ def list_all():
pagination = query.paginate(page, num, True)
return render_template("threads/list.html", pagination=pagination, threads=pagination.items, package=package)
return render_template("threads/list.html", pagination=pagination, threads=pagination.items)
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@@ -220,51 +218,54 @@ def view(id):
if thread is None or not thread.checkPerm(current_user, Permission.SEE_THREAD):
abort(404)
form = CommentForm(formdata=request.form) if thread.checkPerm(current_user, Permission.COMMENT_THREAD) else None
if current_user.is_authenticated and request.method == "POST":
comment = request.form["comment"]
# Check that title is none to load comments into textarea if redirected from new thread page
if form and form.validate_on_submit() and request.form.get("title") is None:
comment = form.comment.data
if not thread.checkPerm(current_user, Permission.COMMENT_THREAD):
flash(gettext("You cannot comment on this thread"), "danger")
return redirect(thread.getViewURL())
if not current_user.canCommentRL():
flash(gettext("Please wait before commenting again"), "danger")
return redirect(thread.getViewURL())
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
if 2000 >= len(comment) > 3:
reply = ThreadReply()
reply.author = current_user
reply.comment = comment
db.session.add(reply)
thread.replies.append(reply)
if current_user not in thread.watchers:
thread.watchers.append(current_user)
thread.replies.append(reply)
if not current_user in thread.watchers:
thread.watchers.append(current_user)
for mentioned_username in get_user_mentions(render_markdown(comment)):
mentioned = User.query.filter_by(username=mentioned_username).first()
if mentioned is None:
continue
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 = "Mentioned by {} in '{}'".format(current_user.display_name, thread.title)
addNotification(mentioned, current_user, NotificationType.THREAD_REPLY,
msg, thread.getViewURL(), thread.package)
thread.watchers.append(mentioned)
msg = "New comment on '{}'".format(thread.title)
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
msg = "New comment on '{}'".format(thread.title)
addNotification(thread.watchers, current_user, NotificationType.THREAD_REPLY, msg, thread.getViewURL(), thread.package)
if thread.author == get_system_user():
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, msg,
thread.getViewURL(), thread.package)
post_discord_webhook.delay(current_user.username,
"Replied to bot messages: {}".format(thread.getViewURL(absolute=True)), True)
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:
flash(gettext("Comment needs to be between 3 and 2000 characters."), "danger")
return render_template("threads/view.html", thread=thread, form=form)
return render_template("threads/view.html", thread=thread)
class ThreadForm(FlaskForm):
@@ -283,25 +284,27 @@ def new():
if "pid" in request.args:
package = Package.query.get(int(request.args.get("pid")))
if package is None:
abort(404)
flash(gettext("Unable to find that package!"), "danger")
def_is_private = request.args.get("private") or False
if package is None and not current_user.rank.atLeast(UserRank.APPROVER):
abort(404)
# Don't allow making orphan threads on approved packages for now
if package is None:
abort(403)
allow_private_change = not package or package.approved
def_is_private = request.args.get("private") or False
if package is None:
def_is_private = True
allow_change = package and package.approved
is_review_thread = package and not package.approved
# Check that user can make the thread
if package and not package.checkPerm(current_user, Permission.CREATE_THREAD):
if not package.checkPerm(current_user, Permission.CREATE_THREAD):
flash(gettext("Unable to create thread!"), "danger")
return redirect(url_for("homepage.home"))
# Only allow creating one thread when not approved
elif is_review_thread and package.review_thread is not None:
# Redirect submit to `view` page, which checks for `title` in the form data and so won't commit the reply
flash(gettext("An approval thread already exists! Consider replying there instead"), "danger")
return redirect(package.review_thread.getViewURL(), code=307)
flash(gettext("An approval thread already exists!"), "danger")
return redirect(package.review_thread.getViewURL())
elif not current_user.canOpenThreadRL():
flash(gettext("Please wait before opening another thread"), "danger")
@@ -321,12 +324,12 @@ def new():
thread = Thread()
thread.author = current_user
thread.title = form.title.data
thread.private = form.private.data if allow_private_change else def_is_private
thread.private = form.private.data if allow_change else def_is_private
thread.package = package
db.session.add(thread)
thread.watchers.append(current_user)
if package and package.author != current_user:
if package is not None and package.author != current_user:
thread.watchers.append(package.author)
reply = ThreadReply()
@@ -343,7 +346,7 @@ def new():
package.review_thread = thread
for mentioned_username in get_user_mentions(render_markdown(form.comment.data)):
mentioned = User.query.filter_by(username=mentioned_username).first()
mentioned = User.query.filter_by(username=mentioned_username)
if mentioned is None:
continue
@@ -351,8 +354,6 @@ def new():
addNotification(mentioned, current_user, NotificationType.NEW_THREAD,
msg, thread.getViewURL(), thread.package)
thread.watchers.append(mentioned)
notif_msg = "New thread '{}'".format(thread.title)
if package is not None:
addNotification(package.maintainers, current_user, NotificationType.NEW_THREAD, notif_msg, thread.getViewURL(), package)
@@ -360,6 +361,7 @@ def new():
approvers = User.query.filter(User.rank >= UserRank.APPROVER).all()
addNotification(approvers, current_user, NotificationType.EDITOR_MISC, notif_msg, thread.getViewURL(), package)
if is_review_thread:
post_discord_webhook.delay(current_user.username,
"Opened approval thread: {}".format(thread.getViewURL(absolute=True)), True)
@@ -369,7 +371,7 @@ def new():
return redirect(thread.getViewURL())
return render_template("threads/new.html", form=form, allow_private_change=allow_private_change, package=package)
return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package)
@bp.route("/users/<username>/comments/")
@@ -378,4 +380,4 @@ def user_comments(username):
if user is None:
abort(404)
return render_template("threads/user_comments.html", user=user, replies=user.replies)
return render_template("threads/user_comments.html", user=user, replies=user.replies)

View File

@@ -17,7 +17,7 @@
from celery import uuid
from flask import *
from flask_login import current_user, login_required
from sqlalchemy import or_, and_
from sqlalchemy import or_
from app.models import *
from app.querybuilder import QueryBuilder
@@ -74,19 +74,14 @@ def view_editor():
unfulfilled_meta_packages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
.filter(MetaPackage.dependencies.any(Package.state == PackageState.APPROVED, optional=False)) \
.order_by(db.asc(MetaPackage.name)).count()
audit_log = AuditLogEntry.query \
.filter(AuditLogEntry.package.has()) \
.order_by(db.desc(AuditLogEntry.created_at)) \
.limit(20).all()
return render_template("todo/editor.html", current_tab="editor",
packages=packages, wip_packages=wip_packages, releases=releases, screenshots=screenshots,
canApproveNew=canApproveNew, canApproveRel=canApproveRel, canApproveScn=canApproveScn,
license_needed=license_needed, total_packages=total_packages, total_to_tag=total_to_tag,
unfulfilled_meta_packages=unfulfilled_meta_packages, audit_log=audit_log)
unfulfilled_meta_packages=unfulfilled_meta_packages)
@bp.route("/todo/topics/")
@@ -148,7 +143,7 @@ def tags_user():
def metapackages():
mpackages = MetaPackage.query \
.filter(~ MetaPackage.packages.any(state=PackageState.APPROVED)) \
.filter(MetaPackage.dependencies.any(Dependency.depender.has(state=PackageState.APPROVED), optional=False)) \
.filter(MetaPackage.dependencies.any(optional=False)) \
.order_by(db.asc(MetaPackage.name)).all()
return render_template("todo/metapackages.html", mpackages=mpackages)
@@ -173,11 +168,6 @@ def view_user(username=None):
Package.state == PackageState.CHANGES_NEEDED)) \
.order_by(db.asc(Package.created_at)).all()
packages_with_small_screenshots = user.maintained_packages \
.filter(Package.screenshots.any(and_(PackageScreenshot.width < PackageScreenshot.SOFT_MIN_SIZE[0],
PackageScreenshot.height < PackageScreenshot.SOFT_MIN_SIZE[1]))) \
.all()
outdated_packages = user.maintained_packages \
.filter(Package.state != PackageState.DELETED,
Package.update_config.has(PackageUpdateConfig.outdated_at.isnot(None))) \
@@ -190,14 +180,12 @@ def view_user(username=None):
.all()
needs_tags = user.maintained_packages \
.filter(Package.state != PackageState.DELETED, Package.tags==None) \
.order_by(db.asc(Package.title)).all()
.filter(Package.state != PackageState.DELETED) \
.filter_by(tags=None).order_by(db.asc(Package.title)).all()
return render_template("todo/user.html", current_tab="user", user=user,
unapproved_packages=unapproved_packages, outdated_packages=outdated_packages,
needs_tags=needs_tags, topics_to_add=topics_to_add,
packages_with_small_screenshots=packages_with_small_screenshots,
screenshot_min_size=PackageScreenshot.HARD_MIN_SIZE, screenshot_rec_size=PackageScreenshot.SOFT_MIN_SIZE)
needs_tags=needs_tags, topics_to_add=topics_to_add)
@bp.route("/users/<username>/update-configs/apply-all/", methods=["POST"])

View File

@@ -17,7 +17,7 @@
from flask import *
from flask_babel import gettext, lazy_gettext, get_locale
from flask_babel import gettext, lazy_gettext
from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
@@ -102,8 +102,7 @@ def logout():
class RegisterForm(FlaskForm):
display_name = StringField(lazy_gettext("Display Name"), [Optional(), Length(1, 20)], filters=[nonEmptyOrNone])
username = StringField(lazy_gettext("Username"), [InputRequired(),
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext(
"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed"))])
Regexp("^[a-zA-Z0-9._-]+$", message=lazy_gettext("Only a-zA-Z0-9._ allowed"))])
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
password = PasswordField(lazy_gettext("Password"), [InputRequired(), Length(6, 100)])
question = StringField(lazy_gettext("What is the result of the above calculation?"), [InputRequired()])
@@ -143,7 +142,7 @@ def handle_register(form):
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
send_anon_email.delay(form.email.data, gettext("Email already in use"),
gettext("We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
return redirect(url_for("users.email_sent"))
@@ -169,7 +168,7 @@ def handle_register(form):
db.session.add(ver)
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"))
@@ -210,11 +209,25 @@ def forgot_password():
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language)
send_verify_email.delay(form.email.data, token)
else:
html = render_template("emails/unable_to_find_account.html")
send_anon_email.delay(email, get_locale().language, gettext("Unable to find account"),
html, html)
send_anon_email.delay(email, "Unable to find account", """
<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>
""")
return redirect(url_for("users.email_sent"))
@@ -256,7 +269,7 @@ def handle_set_password(form):
user_by_email = User.query.filter_by(email=form.email.data).first()
if user_by_email:
send_anon_email.delay(form.email.data, get_locale().language, gettext("Email already in use"),
send_anon_email.delay(form.email.data, gettext("Email already in use"),
gettext(u"We were unable to create the account as the email is already in use by %(display_name)s. Try a different email address.",
display_name=user_by_email.display_name))
else:
@@ -269,7 +282,7 @@ def handle_set_password(form):
db.session.add(ver)
db.session.commit()
send_verify_email.delay(form.email.data, token, get_locale().language)
send_verify_email.delay(form.email.data, token)
flash(gettext("Your password has been changed successfully."), "success")
return redirect(url_for("users.email_sent"))
@@ -347,7 +360,6 @@ def verify_email():
if user.email:
send_user_email.delay(user.email,
user.locale or "en",
gettext("Email address changed"),
gettext("Your email address has changed. If you didn't request this, please contact an administrator."))
@@ -389,7 +401,7 @@ def unsubscribe_verify():
sub.token = randomString(32)
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"))

View File

@@ -37,7 +37,7 @@ def claim_forums():
method = request.args.get("method")
if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
return redirect(url_for("users.claim_forums"))
user = User.query.filter_by(forums_username=username).first()
@@ -62,7 +62,7 @@ def claim_forums():
username = request.form.get("username")
if not is_username_valid(username):
flash(gettext("Invalid username, Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed. Consider contacting an admin"), "danger")
flash(gettext("Invalid username - must only contain A-Za-z0-9._. Consider contacting an admin"), "danger")
elif ctype == "github":
task = checkForumAccount.delay(username)
return redirect(url_for("tasks.check", id=task.id, r=url_for("users.claim_forums", username=username, method="github")))

View File

@@ -66,9 +66,6 @@ class Medal:
@classmethod
def make_locked(cls, description: str, progress: Tuple[int, int]):
if progress[0] is None or progress[1] is None:
raise Exception("Invalid progress")
return Medal(description=description, progress=progress)
@@ -130,7 +127,7 @@ def get_user_medals(user: User) -> Tuple[List[Medal], List[Medal]]:
unlocked.append(Medal.make_unlocked(
place_to_color(review_idx + 1), "fa-star-half-alt", title, description))
elif review_boundary is not None:
else:
description = gettext(u"Consider writing more helpful reviews to get a medal.")
if review_idx:
description += " " + gettext(u"You are in place %(place)s.", place=review_idx + 1)

View File

@@ -1,5 +1,5 @@
from flask import *
from flask_babel import gettext, lazy_gettext, get_locale
from flask_babel import gettext, lazy_gettext
from flask_login import current_user, login_required, logout_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
@@ -156,7 +156,7 @@ def handle_email_notifications(user, prefs: UserNotificationPreferences, is_new,
db.session.add(ver)
db.session.commit()
send_verify_email.delay(newEmail, token, get_locale().language)
send_verify_email.delay(newEmail, token)
return redirect(url_for("users.email_sent"))
db.session.commit()
@@ -342,7 +342,7 @@ def modtools_set_email(username):
db.session.add(ver)
db.session.commit()
send_verify_email.delay(user.email, token, user.locale or "en")
send_verify_email.delay(user.email, token)
flash(f"Set email and sent a password reset on {user.username}", "success")
return redirect(url_for("users.modtools", username=username))
@@ -358,45 +358,11 @@ def modtools_ban(username):
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
abort(403)
message = request.form["message"]
expires_at = request.form.get("expires_at")
user.rank = UserRank.BANNED
user.ban = UserBan()
user.ban.banned_by = current_user
user.ban.message = message
if expires_at and expires_at != "":
user.ban.expires_at = expires_at
else:
user.rank = UserRank.BANNED
addAuditLog(AuditSeverity.MODERATION, current_user, f"Banned {user.username}, expires {user.ban.expires_at or '-'}, message: {message}",
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))
@bp.route("/users/<username>/modtools/unban/", methods=["POST"])
@rank_required(UserRank.MODERATOR)
def modtools_unban(username):
user: User = User.query.filter_by(username=username).first()
if not user:
abort(404)
if not user.checkPerm(current_user, Permission.CHANGE_RANK):
abort(403)
if user.ban:
db.session.delete(user.ban)
if user.rank == UserRank.BANNED:
user.rank = UserRank.MEMBER
addAuditLog(AuditSeverity.MODERATION, current_user, f"Unbanned {user.username}",
url_for("users.profile", username=user.username), None)
db.session.commit()
flash(f"Unbanned {user.username}", "success")
return redirect(url_for("users.modtools", username=username))
return redirect(url_for("users.modtools", username=username))

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ 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).
* MUST: Have a high resolution cover image on ContentDB (at least 1280x768 pixels).
It may be shown cropped to 16:9 aspect ratio, or shorter.
* MUST: mod.conf/game.conf/texture_pack.conf present with:
* name (if mod or game)

View File

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

View File

@@ -11,8 +11,6 @@ the listings and to combat abuse.
* **Don't use the name of another mod unless your mod is a fork or reimplementation.** <sup>3</sup>
* **Licenses must allow derivatives, redistribution, and must not discriminate.** <sup>4</sup>
* **Don't put promotions or advertisements in any package metadata.** <sup>5</sup>
* **Don't manipulate package placement using reviews or downloads.** <sup>6</sup>
* **Screenshots must not be misleading.** <sup>7</sup>
* **The ContentDB admin reserves the right to remove packages for any reason**,
including ones not covered by this document, and to ban users who abuse
this service. <sup>1</sup>
@@ -48,7 +46,7 @@ but still has value. Note that this doesn't mean that you should add a thing
you started working on yesterday, it's worth adding all the basic stuff to
make your package useful.
You should make sure to mark Work in Progress stuff as such in the "maintenance status" column,
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
@@ -85,6 +83,23 @@ should be possible to use the new mod as a drop-in replacement.
We reserve the right to decide whether a mod counts as a fork or
reimplementation of the mod that owns the name.
### 3.3. Reserved namespaces
A reserved namespace is a prefix to a package name like `abc_`, where any package names that begin with that prefix
need permission from the namespace owner to be used.
You can request a namespace be reserved by opening a thread on a package that uses it. A namespace must be in active
use and must be specific enough to have low risk of conflict - for example, `mobs` cannot be a reserved namespace.
The package must also be deemed "serious" enough to get a reservation - larger and more longterm projects are more
likely to qualify.
Mod names used in packages posted on CDB or the forums before 2022-01-21 are exempt.
List of reserved namespaces:
* `nc_` is reserved by NodeCore.
* `ikea_` is reserved by IKEA.
## 4. Licenses
@@ -153,42 +168,6 @@ You must not attempt to unfairly manipulate your package's ranking, whether by r
Doing so may result in temporary or permanent suspension from ContentDB.
## 7. Screenshots
1. **Screenshots must not violate copyright.** You should have the rights to the
screenshot.
2. **Screenshots must depict the actual content of the package in some way, and
not be misleading.**
Do not use idealized mockups or blender concept renders if they do not
accurately reflect in-game appearance.
Content in screenshots that is prominently displayed or "focal" should be
either present in, or interact with, the package in some way. These can
include things in other packages if they have a dependency relationship
(either way), or if the submitted package in some way enhances, extends, or
alters that content.
Unrelated package content can be allowed to show what the package content
will look like in a typical/realistic game scene, but should be "in the
background" only as far as possible.
3. **Screenshots must only contain content appropriate for the Content Warnings of
the package.**
4. **Screenshots should be MOSTLY in-game screenshots, if applicable.** Some
alterations on in-game screenshots are okay, such as collages, added text,
some reasonable compositing.
Don't just use one of the textures from the package; show it in-situ as it
actually looks in the game.
5. **Packages should have a screenshot when reasonably applicable.**
6. **Screenshots should be of reasonable dimensions.** We recommend using 1920x1080.
## 8. Reporting Violations
## 7. Reporting Violations
Please click "Report" on the package page.

View File

@@ -1,8 +1,5 @@
title: Privacy Policy
Last Updated: 2022-01-23
([View updates](https://github.com/minetest/contentdb/commits/master/app/flatpages/privacy_policy.md))
## What Information is Collected
**All users:**
@@ -12,14 +9,13 @@ Last Updated: 2022-01-23
* IP address
* Page URL
* Response status code
* Preferred language/locale. This defaults to the browser's locale, but can be changed by the user
**With an account:**
* Email address
* Passwords (hashed and salted using BCrypt)
* Profile information, such as website URLs and donation URLs
* Comments, threads, and reviews
* Comments and threads
* Audit log actions (such as edits and logins) and their time stamps
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.
* 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.
* The admin may use ContentDB to send emails when they need to contact a user.
* Passwords are used to authenticate the user.
* The audit log is used to record actions that may be harmful.
* Preferred language/locale is used to translate emails and the ContentDB interface.
* The audit log is used to record actions that may be harmful
* Other information is displayed as part of ContentDB's service.
## 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).
The keys and the backups themselves are given to different people,
requiring at least two staff members to read a backup.
* Email addresses are visible to moderators and the admin.
* Emails are visible to moderators and the admin.
They have access to assist users, and they are not permitted to share email addresses.
* Hashing protects passwords from being read whilst stored in the database or in backups.
* Profile information is public, including URLs and linked accounts.
@@ -58,12 +52,11 @@ Please avoid giving other personal information as we do not want it.
* The complete audit log is visible to moderators.
Users may see their own audit log actions on their account settings page.
Owners, maintainers, and editors may be able to see the actions on a package in the future.
* Preferred language can only be viewed by this with access to the database or a backup.
* We may be required to share information with law enforcement.
## Location
The ContentDB production server is currently located in Germany.
The ContentDB production server is currently located in Canada.
Backups are stored in the UK.
Encrypted backups may be stored in other countries, such as the US or EU.

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

@@ -23,7 +23,6 @@ from app.logic.LogicError import LogicError
from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \
License, UserRank, PackageDevState
from app.utils import addAuditLog
from app.utils.url import clean_youtube_url
def check(cond: bool, msg: str):
@@ -62,7 +61,6 @@ ALLOWED_FIELDS = {
"issue_tracker": str,
"issueTracker": str,
"forums": int,
"video_url": str,
}
ALIASES = {
@@ -106,11 +104,11 @@ def validate(data: dict):
def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, data: dict,
reason: str = None):
if not package.checkPerm(user, Permission.EDIT_PACKAGE):
raise LogicError(403, lazy_gettext("You don't have permission to edit this package"))
raise LogicError(403, lazy_gettext("You do not have permission to edit this package"))
if "name" in data and package.name != data["name"] and \
not package.checkPerm(user, Permission.CHANGE_NAME):
raise LogicError(403, lazy_gettext("You don't have permission to change the package name"))
raise LogicError(403, lazy_gettext("You do not have permission to change the package name"))
for alias, to in ALIASES.items():
if alias in data:
@@ -130,13 +128,8 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
if "media_license" in data:
data["media_license"] = get_license(data["media_license"])
if "video_url" in data and data["video_url"] is not None:
data["video_url"] = clean_youtube_url(data["video_url"]) or data["video_url"]
if "dQw4w9WgXcQ" in data["video_url"]:
raise LogicError(403, "Never gonna give you up / Never gonna let you down / Never gonna run around and desert you")
for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license",
"repo", "website", "issueTracker", "forums", "video_url"]:
"repo", "website", "issueTracker", "forums"]:
if key in data:
setattr(package, key, data[key])
@@ -159,7 +152,7 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool,
raise LogicError(400, "Unknown tag: " + tag_id)
if not was_web and tag.is_protected:
continue
break
if tag.is_protected and tag not in old_tags and not user.rank.atLeast(UserRank.EDITOR):
raise LogicError(400, lazy_gettext("Unable to add protected tag %(title)s to package", title=tag.title))

View File

@@ -29,7 +29,7 @@ from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone
def check_can_create_release(user: User, package: Package):
if not package.checkPerm(user, Permission.MAKE_RELEASE):
raise LogicError(403, lazy_gettext("You don't have permission to make releases"))
raise LogicError(403, lazy_gettext("You do not have permission to make releases"))
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5)
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()

View File

@@ -6,10 +6,9 @@ from app.logic.LogicError import LogicError
from app.logic.uploads import upload_file
from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db, AuditSeverity
from app.utils import addNotification, addAuditLog
from app.utils.image import get_image_size
def do_create_screenshot(user: User, package: Package, title: str, file, is_cover_image: bool, reason: str = None):
def do_create_screenshot(user: User, package: Package, title: str, file, reason: str = None):
thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count()
if count >= 20:
@@ -28,13 +27,6 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
ss.url = uploaded_url
ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT)
ss.order = counter
ss.width, ss.height = get_image_size(uploaded_path)
if ss.is_too_small():
raise LogicError(429,
lazy_gettext("Screenshot is too small, it should be at least %(width)s by %(height)s pixels",
width=PackageScreenshot.HARD_MIN_SIZE[0], height=PackageScreenshot.HARD_MIN_SIZE[1]))
db.session.add(ss)
if reason is None:
@@ -47,10 +39,6 @@ def do_create_screenshot(user: User, package: Package, title: str, file, is_cove
db.session.commit()
if is_cover_image:
package.cover_image = ss
db.session.commit()
return ss
@@ -70,18 +58,3 @@ def do_order_screenshots(_user: User, package: Package, order: [any]):
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(ss_id)))
db.session.commit()
def do_set_cover_image(_user: User, package: Package, cover_image):
try:
cover_image = int(cover_image)
except (ValueError, TypeError) as e:
raise LogicError(400, "Invalid id, not a number: {}".format(json.dumps(cover_image)))
for screenshot in package.screenshots.all():
if screenshot.id == cover_image:
package.cover_image = screenshot
db.session.commit()
return
raise LogicError(400, "Unable to find screenshot")

View File

@@ -70,15 +70,10 @@ class FlaskMailHandler(logging.Handler):
return subject
def emit(self, record):
subject = self.getSubject(record)
text = self.format(record) if self.formatter else None
html = "<pre>{}</pre>".format(text)
if "The recipient has exceeded message rate limit. Try again later" in subject:
return
for email in self.send_to:
send_user_email.delay(email, "en", subject, text, html)
send_user_email.delay(email, self.getSubject(record), text, html)
def build_handler(app):

View File

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

View File

@@ -26,7 +26,6 @@ from sqlalchemy_utils.types import TSVectorType
from . import db
from .users import Permission, UserRank, User
from .. import app
class PackageQuery(BaseQuery, SearchQueryMixin):
@@ -344,25 +343,6 @@ class Dependency(db.Model):
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):
query_class = PackageQuery
@@ -402,26 +382,18 @@ class Package(db.Model):
downloads = db.Column(db.Integer, nullable=False, default=0)
review_thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True, default=None)
review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id],
back_populates="is_review_thread", post_update=True)
review_thread = db.relationship("Thread", uselist=False, foreign_keys=[review_thread_id], back_populates="is_review_thread")
# Downloads
repo = db.Column(db.String(200), nullable=True)
website = db.Column(db.String(200), nullable=True)
issueTracker = db.Column(db.String(200), nullable=True)
forums = db.Column(db.Integer, nullable=True)
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")
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")
content_warnings = db.relationship("ContentWarning", secondary=ContentWarnings, back_populates="packages")
@@ -433,7 +405,7 @@ class Package(db.Model):
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",
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)")
cover_image_id = db.Column(db.Integer, db.ForeignKey("package_screenshot.id"), nullable=True, default=None)
@@ -476,14 +448,6 @@ class Package(db.Model):
for e in PackagePropertyKey:
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):
return "{}/{}".format(self.author.username, self.name)
@@ -505,11 +469,6 @@ class Package(db.Model):
def getSortedOptionalDependencies(self):
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):
return {
"name": self.name,
@@ -568,7 +527,6 @@ class Package(db.Model):
"website": self.website,
"issue_tracker": self.issueTracker,
"forums": self.forums,
"video_url": self.video_url,
"tags": [x.name for x in self.tags],
"content_warnings": [x.name for x in self.content_warnings],
@@ -581,15 +539,7 @@ class Package(db.Model):
"release": release and release.id,
"score": round(self.score * 10) / 10,
"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()
]
"downloads": self.downloads
}
def getThumbnailOrPlaceholder(self, level=2):
@@ -657,7 +607,10 @@ class Package(db.Model):
isMaintainer = isOwner or user.rank.atLeast(UserRank.EDITOR) or user in self.maintainers
isApprover = user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.CREATE_THREAD:
if perm == Permission.SEE_PACKAGE:
return self.state == PackageState.APPROVED or isMaintainer or isApprover
elif perm == Permission.CREATE_THREAD:
return user.rank.atLeast(UserRank.MEMBER)
# Members can edit their own packages, and editors can edit any packages
@@ -722,11 +675,7 @@ class Package(db.Model):
if not (self.checkPerm(user, Permission.APPROVE_NEW) or self.checkPerm(user, Permission.EDIT_PACKAGE)):
return False
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
return False
provides = self.provides
if state == PackageState.APPROVED and len(provides) == 1 and provides[0].name != self.name:
if state == PackageState.APPROVED and ("Other" in self.license.name or "Other" in self.media_license.name):
return False
if self.getMissingHardDependenciesQuery().count() > 0:
@@ -735,8 +684,7 @@ class Package(db.Model):
needsScreenshot = \
(self.type == self.type.GAME or self.type == self.type.TXP) and \
self.screenshots.count() == 0
return self.releases.filter(PackageRelease.task_id.is_(None)).count() > 0 and not needsScreenshot
return self.releases.count() > 0 and not needsScreenshot
elif state == PackageState.CHANGES_NEEDED:
return self.checkPerm(user, Permission.APPROVE_NEW)
@@ -867,13 +815,7 @@ class Tag(db.Model):
def getAsDictionary(self):
description = self.description if self.description != "" else None
return {
"name": self.name,
"title": self.title,
"description": description,
"is_protected": self.is_protected,
"views": self.views,
}
return { "name": self.name, "title": self.title, "description": description }
class MinetestRelease(db.Model):
@@ -941,10 +883,6 @@ class PackageRelease(db.Model):
# 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)")
@property
def file_path(self):
return self.url.replace("/uploads/", app.config["UPLOAD_DIR"])
def getAsDictionary(self):
return {
"id": self.id,
@@ -1046,9 +984,6 @@ class PackageRelease(db.Model):
class PackageScreenshot(db.Model):
HARD_MIN_SIZE = (920, 517)
SOFT_MIN_SIZE = (1280, 720)
id = db.Column(db.Integer, primary_key=True)
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=False)
@@ -1060,22 +995,6 @@ class PackageScreenshot(db.Model):
approved = db.Column(db.Boolean, nullable=False, default=False)
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):
return url_for("packages.edit_screenshot",
author=self.package.author.username,
@@ -1097,11 +1016,8 @@ class PackageScreenshot(db.Model):
"order": self.order,
"title": self.title,
"url": base_url + self.url,
"width": self.width,
"height": self.height,
"approved": self.approved,
"created_at": self.created_at.isoformat(),
"is_cover_image": self.package.cover_image == self,
}

View File

@@ -20,7 +20,7 @@ from typing import Tuple, List
from flask import url_for
from . import db
from .users import Permission, UserRank, User
from .users import Permission, UserRank
from .packages import Package
watchers = db.Table("watchers",
@@ -88,7 +88,7 @@ class Thread(db.Model):
if self.package:
isMaintainer = isMaintainer or user in self.package.maintainers
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER) or user in self.watchers
canSee = not self.private or isMaintainer or user.rank.atLeast(UserRank.APPROVER)
if perm == Permission.SEE_THREAD:
return canSee
@@ -107,20 +107,6 @@ class Thread(db.Model):
else:
raise Exception("Permission {} is not related to threads".format(perm.name))
def get_visible_to(self) -> list[User]:
retval = {
self.author.username: self.author
}
for user in self.watchers:
retval[user.username] = user
if self.package:
for user in self.package.maintainers:
retval[user.username] = user
return list(retval.values())
def get_latest_reply(self):
return ThreadReply.query.filter_by(thread_id=self.id).order_by(db.desc(ThreadReply.id)).first()
@@ -136,8 +122,6 @@ class ThreadReply(db.Model):
author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
author = db.relationship("User", back_populates="replies", foreign_keys=[author_id])
is_status_update = db.Column(db.Boolean, server_default="0", nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def get_url(self):
@@ -216,8 +200,7 @@ class PackageReview(db.Model):
def getDeleteURL(self):
return url_for("packages.delete_review",
author=self.package.author.username,
name=self.package.name,
reviewer=self.author.username)
name=self.package.name)
def getVoteUrl(self, next_url=None):
return url_for("packages.review_vote",
@@ -230,20 +213,6 @@ class PackageReview(db.Model):
(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)

View File

@@ -59,6 +59,7 @@ class UserRank(enum.Enum):
class Permission(enum.Enum):
SEE_PACKAGE = "SEE_PACKAGE"
EDIT_PACKAGE = "EDIT_PACKAGE"
DELETE_PACKAGE = "DELETE_PACKAGE"
CHANGE_AUTHOR = "CHANGE_AUTHOR"
@@ -86,7 +87,6 @@ class Permission(enum.Enum):
TOPIC_DISCARD = "TOPIC_DISCARD"
CREATE_TOKEN = "CREATE_TOKEN"
EDIT_MAINTAINERS = "EDIT_MAINTAINERS"
DELETE_REVIEW = "DELETE_REVIEW"
CHANGE_PROFILE_URLS = "CHANGE_PROFILE_URLS"
CHANGE_DISPLAY_NAME = "CHANGE_DISPLAY_NAME"
@@ -148,8 +148,6 @@ class User(db.Model, UserMixin):
email = db.Column(db.String(255), nullable=True, unique=True)
email_confirmed_at = db.Column(db.DateTime(), nullable=True, server_default=None)
locale = db.Column(db.String(10), nullable=True, default=None)
# User information
profile_pic = db.Column(db.String(255), nullable=True, server_default=None)
is_active = db.Column("is_active", db.Boolean, nullable=False, server_default="0")
@@ -183,8 +181,6 @@ class User(db.Model, UserMixin):
replies = db.relationship("ThreadReply", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.desc("created_at"))
forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan")
ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False)
def __init__(self, username=None, active=False, email=None, password=None):
self.username = username
self.display_name = username
@@ -270,25 +266,6 @@ class User(db.Model, UserMixin):
return Thread.query.filter_by(author=self) \
.filter(Thread.created_at > hour_ago).count() < 2 * factor
def canReviewRL(self):
from app.models import PackageReview
factor = 1
if self.rank.atLeast(UserRank.ADMIN):
return True
elif self.rank.atLeast(UserRank.TRUSTED_MEMBER):
factor *= 5
five_mins_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=5)
if PackageReview.query.filter_by(author=self) \
.filter(PackageReview.created_at > five_mins_ago).count() > 2 * factor:
return False
hour_ago = datetime.datetime.utcnow() - datetime.timedelta(hours=1)
return PackageReview.query.filter_by(author=self) \
.filter(PackageReview.created_at > hour_ago).count() < 10 * factor
def __eq__(self, other):
if other is None:
return False
@@ -330,11 +307,6 @@ class EmailSubscription(db.Model):
self.blacklisted = False
self.token = None
@property
def url(self):
from ..utils import abs_url_for
return abs_url_for('users.unsubscribe', token=self.token)
class NotificationType(enum.Enum):
# Package / release / etc
@@ -503,21 +475,3 @@ class UserNotificationPreferences(db.Model):
value = 1 if value else 0
setattr(self, "pref_" + notification_type.toName(), value)
class UserBan(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
user = db.relationship("User", foreign_keys=[user_id], back_populates="ban")
message = db.Column(db.UnicodeText, nullable=False)
banned_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
banned_by = db.relationship("User", foreign_keys=[banned_by_id])
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
expires_at = db.Column(db.DateTime, nullable=True, default=None)
@property
def has_expired(self):
return self.expires_at and datetime.datetime.now() > self.expires_at

View File

@@ -1,17 +0,0 @@
// @author rubenwardy
// @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
window.addEventListener("load", event => {
document.querySelectorAll(".gallery").forEach(gallery => {
const primary = gallery.querySelector(".primary-image img");
const images = gallery.querySelectorAll("a[data-image]");
images.forEach(image => {
const imageFullUrl = image.getAttribute("data-image");
image.removeAttribute("href");
image.addEventListener("click", event => {
primary.src = imageFullUrl;
})
});
});
});

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() {
async function render(plainText, preview) {
const response = await fetch(new Request("/api/markdown/", {

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 max = $("#max_rel");
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() {
const ele = $(this);
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

@@ -75,10 +75,6 @@ class QueryBuilder:
if self.search is not None and self.search.strip() == "":
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"):
if self.order_by is None:
self.order_by = name
@@ -136,9 +132,6 @@ class QueryBuilder:
query = query.filter_by(author=author)
if self.game:
query = query.filter(Package.supported_games.any(game=self.game))
for tag in self.tags:
query = query.filter(Package.tags.any(Tag.id == tag.id))

View File

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

@@ -32,8 +32,4 @@
height: 60px;
object-fit: cover;
}
.status-update p {
margin: 0;
}
}

View File

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

View File

@@ -1,79 +0,0 @@
.gallery-thumbnails {
list-style: none;
margin: 0;
padding: 0.5rem 0;
overflow: auto hidden;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
li {
display: block;
list-style: none;
margin: 0;
padding: 0;
position: relative;
&:hover img, .active 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,3 +1,32 @@
.screenshot_list {
list-style: none;
padding: 0;
margin: 0 0 2em;
li, li a {
list-style: none;
margin: 0;
padding: 0;
}
li {
display: inline-block;
vertical-align: middle;
margin: 5px;
padding: 0;
a {
display: block;
}
}
img {
width: 200px;
height: 133px;
object-fit: cover;
}
}
.badge-tr {
position: absolute;
top: 5px;
@@ -5,6 +34,23 @@
color: #ccc !important;;
}
.screenshot-add {
display: block !important;
width: 200px;
height: 133px;
background: #444;
color: #666;
text-align: center;
line-height: 133px !important;
font-size: 80px;
&:hover {
background: #555;
color: #999;
text-decoration: none;
}
}
.info-row {
vertical-align: middle;

View File

@@ -14,10 +14,8 @@
# 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 Dict
from flask import render_template, escape
from flask_babel import force_locale, gettext, lazy_gettext
from flask_mail import Message
from app import mail
from app.models import Notification, db, EmailSubscription, User
@@ -37,131 +35,113 @@ def get_email_subscription(email):
return ret
def gen_headers(sub: EmailSubscription, is_bulk: bool) -> Dict[str,str]:
headers = {"List-Help": f"<{abs_url_for('flatpage', path='help/faq/')}>", "List-Unsubscribe": f"<{sub.url}>"}
@celery.task()
def send_verify_email(email, token):
sub = get_email_subscription(email)
if sub.blacklisted:
return
if is_bulk:
headers["Precedence"] = "Bulk"
msg = Message("Confirm email address", recipients=[email])
return headers
msg.body = """
This email has been sent to you because someone (hopefully you)
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)
mail.send(msg)
@celery.task()
def send_verify_email(email, token, locale):
def send_unsubscribe_verify(email):
sub = get_email_subscription(email)
if sub.blacklisted:
return
with force_locale(locale or "en"):
msg = Message("Confirm email address", recipients=[email], extra_headers=gen_headers(sub, False))
msg = Message("Confirm unsubscribe", recipients=[email])
msg.body = """
This email has been sent to you because someone (hopefully you)
has entered your email address as a user's email.
msg.body = """
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: {}
""".format(abs_url_for('users.unsubscribe', token=sub.token))
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)
mail.send(msg)
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
mail.send(msg)
@celery.task()
def send_unsubscribe_verify(email, locale):
def send_email_with_reason(email, subject, text, html, reason):
sub = get_email_subscription(email)
if sub.blacklisted:
return
with force_locale(locale or "en"):
msg = Message("Confirm unsubscribe", recipients=[email], extra_headers=gen_headers(sub, False))
from flask_mail import Message
msg = Message(subject, recipients=[email])
msg.body = """
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: {}
""".format(abs_url_for('users.unsubscribe', token=sub.token))
msg.html = render_template("emails/verify_unsubscribe.html", sub=sub)
mail.send(msg)
msg.body = text
html = html or f"<pre>{escape(text)}</pre>"
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
mail.send(msg)
@celery.task(rate_limit="25/m")
def send_email_with_reason(email: str, locale: str, subject: str, text: str, html: str, reason: str, conn: any):
sub = get_email_subscription(email)
if sub.blacklisted:
return
with force_locale(locale or "en"):
msg = Message(subject, recipients=[email], extra_headers=gen_headers(sub, conn is not None))
msg.body = text
html = html or f"<pre>{escape(text)}</pre>"
msg.html = render_template("emails/base.html", subject=subject, content=html, reason=reason, sub=sub)
if conn:
conn.send(msg)
else:
mail.send(msg)
@celery.task()
def send_user_email(email: str, subject: str, text: str, html=None):
return send_email_with_reason(email, subject, text, html,
"You are receiving this email because you are a registered user of ContentDB.")
@celery.task(rate_limit="25/m")
def send_user_email(email: str, locale: str, subject: str, text: str, html=None, conn=None):
return send_email_with_reason(email, locale, subject, text, html,
lazy_gettext("You are receiving this email because you are a registered user of ContentDB."), conn)
@celery.task()
def send_anon_email(email: str, subject: str, text: str, html=None):
return send_email_with_reason(email, subject, text, html,
"You are receiving this email because someone (hopefully you) entered your email address as a user's email.")
@celery.task(rate_limit="25/m")
def send_anon_email(email: str, locale: str, subject: str, text: str, html=None):
return send_email_with_reason(email, locale, subject, text, html,
lazy_gettext("You are receiving this email because someone (hopefully you) entered your email address as a user's email."), None)
def send_single_email(notification, locale):
def send_single_email(notification):
sub = get_email_subscription(notification.user.email)
if sub.blacklisted:
return
with force_locale(locale or "en"):
msg = Message(notification.title, recipients=[notification.user.email], extra_headers=gen_headers(sub, False))
msg = Message(notification.title, recipients=[notification.user.email])
msg.body = """
New notification: {}
View: {}
Manage email settings: {}
Unsubscribe: {}
""".format(notification.title, abs_url(notification.url),
abs_url_for("users.email_notifications", username=notification.user.username),
abs_url_for("users.unsubscribe", token=sub.token))
msg.body = """
New notification: {}
View: {}
Manage email settings: {}
Unsubscribe: {}
""".format(notification.title, abs_url(notification.url),
abs_url_for("users.email_notifications", username=notification.user.username),
abs_url_for("users.unsubscribe", token=sub.token))
msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
mail.send(msg)
msg.html = render_template("emails/notification.html", notification=notification, sub=sub)
mail.send(msg)
def send_notification_digest(notifications: [Notification], locale):
def send_notification_digest(notifications: [Notification]):
user = notifications[0].user
sub = get_email_subscription(user.email)
if sub.blacklisted:
return
with force_locale(locale or "en"):
msg = Message(gettext("%(num)d new notifications", num=len(notifications)), recipients=[user.email])
msg = Message("{} new notifications".format(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(
gettext("Manage email settings"),
abs_url_for("users.email_notifications", username=user.username),
gettext("Unsubscribe"),
abs_url_for("users.unsubscribe", token=sub.token))
msg.body += "Manage email settings: {}\nUnsubscribe: {}".format(
abs_url_for("users.email_notifications", username=user.username),
abs_url_for("users.unsubscribe", token=sub.token))
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
mail.send(msg)
msg.html = render_template("emails/notification_digest.html", notifications=notifications, user=user, sub=sub)
mail.send(msg)
@celery.task()
@@ -174,7 +154,7 @@ def send_pending_digests():
notification.emailed = True
if len(to_send) > 0:
send_notification_digest(to_send, user.locale or "en")
send_notification_digest(to_send)
db.session.commit()
@@ -194,13 +174,6 @@ def send_pending_notifications():
db.session.commit()
if len(to_send) > 1:
send_notification_digest(to_send, user.locale or "en")
send_notification_digest(to_send)
elif len(to_send) > 0:
send_single_email(to_send[0], user.locale or "en")
@celery.task()
def send_bulk_email(subject: str, text: str, html=None):
with mail.connect() as conn:
for user in User.query.filter(User.email.isnot(None)).all():
send_user_email(user.email, user.locale or "en", subject, text, html, conn)
send_single_email(to_send[0])

View File

@@ -13,7 +13,6 @@
#
# 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 json
import os, shutil, gitdb
from zipfile import ZipFile
@@ -23,13 +22,11 @@ from kombu import uuid
from app.models import *
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 .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError
from ..logic.game_support import GameSupportResolver
from ..logic.packages import do_edit_package, ALIASES
from ..utils.image import get_image_size
@celery.task()
@@ -115,11 +112,6 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
for meta in getMetaPackages(optional_depends):
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
if tree.meta.get("min_minetest_version"):
release.min_rel = MinetestRelease.get(tree.meta["min_minetest_version"], None)
@@ -221,10 +213,6 @@ def importRepoScreenshot(id):
ss.package = package
ss.title = "screenshot.png"
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.commit()

View File

@@ -1,62 +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 subprocess
from subprocess import Popen, PIPE
from typing import Optional
from app.models import Package, PackageState, PackageRelease
from app.tasks import celery
@celery.task()
def search_in_releases(query: str, file_filter: str):
packages = list(Package.query.filter(Package.state == PackageState.APPROVED).all())
running = []
results = []
while len(packages) > 0 or len(running) > 0:
# Check running
for i in range(len(running) - 1, -1, -1):
package: Package = running[i][0]
handle: subprocess.Popen[str] = running[i][1]
exit_code = handle.poll()
if exit_code is None:
continue
elif exit_code == 0:
results.append({
"package": package.getAsDictionaryKey(),
"lines": handle.stdout.read(),
})
del running[i]
# Create new
while len(running) < 1 and len(packages) > 0:
package = packages.pop()
release: Optional[PackageRelease] = package.getDownloadRelease()
if release:
handle = Popen(["zipgrep", query, release.file_path, file_filter], stdout=PIPE, encoding="UTF-8")
running.append([package, handle])
if len(running) > 0:
running[0][1].wait()
return {
"query": query,
"matches": results,
}

View File

@@ -1,12 +0,0 @@
{% extends "base.html" %}
{% block title %}
500 - Internal Server Error
{% endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
<p>
Don't worry, this error will have been automatically reported.
</p>
{% endblock %}

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<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/custom.css?v=35">
<link rel="stylesheet" type="text/css" href="/static/custom.css?v=32">
<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="icon" href="/favicon-128.png" sizes="128x128">

View File

@@ -59,7 +59,7 @@
<p>
{% block footer %}
{{ reason }} <br>
<a href="{{ sub.url }}">
<a href="{{ abs_url_for('users.unsubscribe', token=sub.token) }}">
{{ _("Unsubscribe") }}
</a>
{% endblock %}

View File

@@ -2,9 +2,9 @@
{% block content %}
{% for title, group in notifications | selectattr("package") | groupby("package.title") %}
{% for type, group in notifications | groupby("package.title") %}
<h2>
{{ title }}
{{ type or _("Other Notifications") }}
</h2>
<ul>
@@ -17,23 +17,6 @@
</ul>
{% 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;">
<a class="btn" href="{{ abs_url_for('notifications.list_all') }}">
{{ _("View Notifications") }}

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

@@ -3,7 +3,7 @@
{% for entry in log %}
<a class="list-group-item list-group-item-action"
{% 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 %}
href="{{ entry.url }}">
{% endif %}

View File

@@ -14,24 +14,19 @@
</div>
{% set level = "warning" %}
{% if package.releases.filter_by(task_id=None).count() == 0 %}
{% if package.releases.count() == 0 %}
{% set message %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
{% if package.update_config %}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.create_release') }}">
{{ _("Create release") }}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.create_release") }}">
{{ _("Create first release") }}
</a>
{% else %}
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL('packages.setup_releases') }}">
<a class="btn btn-sm btn-warning float-right" href="{{ package.getURL("packages.setup_releases") }}">
{{ _("Set up releases") }}
</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 %}
{{ _("You need to create a release before this package can be approved.") }}
{% else %}
{{ _("A release is required before this package can be approved.") }}
{% endif %}
@@ -48,10 +43,6 @@
{% 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.") %}
{% elif package.state == package.state.READY_FOR_REVIEW and (package.provides | length) == 1 and package.provides[0].name != package.name %}
{% set level = "danger" %}
{% set message = _("Mod name does not match package name.") %}
{% else %}
{% set level = "info" %}
{% set message %}

View File

@@ -1,6 +1,9 @@
{% macro render_reply(r, thread, current_user) -%}
{% from "macros/reviews.html" import render_review_vote %}
{% macro render_thread(thread, current_user) -%}
{% from "macros/reviews.html" import render_review_vote %}
<ul class="comments mt-4 mb-0">
{% for r in thread.replies %}
<li class="row my-2 mx-0">
<div class="col-md-1 p-1">
<a href="{{ url_for('users.profile', username=r.author.username) }}">
@@ -74,43 +77,6 @@
</div>
</div>
</li>
{% endmacro %}
{% macro render_status_update(r, thread, current_user) -%}
<li class="row my-2 mx-0 align-items-center">
<div class="col-md-1 p-1">
<a href="{{ url_for('users.profile', username=r.author.username) }}">
<img class="img-fluid user-photo img-thumbnail img-thumbnail-1" src="{{ r.author.getProfilePicURL() }}">
</a>
</div>
<div class="col-auto">
<a class="author {{ r.author.rank.name }}"
href="{{ url_for('users.profile', username=r.author.username) }}">
{{ r.author.display_name }}
</a>
</div>
<div class="col pr-0 status-update">
{{ r.comment | markdown }}
</div>
<div class="col-auto">
<a name="reply-{{ r.id }}" class="text-muted float-right"
href="{{ r.get_url() }}">
{{ r.created_at | datetime }}
</a>
</div>
</li>
{% endmacro %}
{% macro render_thread(thread, current_user, form=None) -%}
<ul class="comments mt-4 mb-0">
{% for r in thread.replies %}
{% if r.is_status_update %}
{{ render_status_update(r, thread, current_user) }}
{% else %}
{{ render_reply(r, thread, current_user) }}
{% endif %}
{% endfor %}
</ul>
@@ -148,13 +114,6 @@
{% endif %}
<input class="btn btn-primary" type="submit" disabled value="Comment" />
</div>
{% elif form %}
{% from "macros/forms.html" import render_field, render_submit_field %}
<form method="post" action="{{ url_for('threads.view', id=thread.id)}}" class="card-body">
{{ form.hidden_tag() }}
{{ render_field(form.comment, fieldclass="form-control markdown", label="") }}
{{ render_submit_field(form.submit) }}
</form>
{% else %}
<form method="post" action="{{ url_for('threads.view', id=thread.id)}}" class="card-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
@@ -162,11 +121,6 @@
<input class="btn btn-primary" type="submit" value="Comment" />
</form>
{% endif %}
{% if thread.private %}
<p class="text-muted card-body my-0 pt-0">
{{ _("You can add someone to a private thread by writing @username.") }}
</p>
{% endif %}
</div>
</div>
</div>
@@ -247,8 +201,8 @@
{% endif %}
</div>
<div class="col-md-2 text-muted text-right">
{% if t.package %}
{% if t.package %}
<div class="col-md-2 text-muted text-right">
<img
class="img-fluid"
style="max-height: 22px; max-width: 22px;"
@@ -257,8 +211,8 @@
<span class="pl-2">
{{ t.package.title }}
</span>
{% endif %}
</div>
</div>
{% endif %}
</div>
</a>
{% else %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
{% set query=package.name %}
{% set release = package.getDownloadRelease() %}
{% extends "base.html" %}
@@ -11,17 +10,12 @@
<meta name="og:title" content="{{ package.title }}"/>
<meta name="og:description" content="{{ package.short_desc }}"/>
<meta name="description" content="{{ package.short_desc }}"/>
<meta name="og:url" content="{{ package.getURL('packages.view', absolute=True) }}"/>
<meta name="og:url" content="{{ package.getURL("packages.view", absolute=True) }}"/>
{% if package.getMainScreenshotURL() %}
<meta name="og:image" content="{{ package.getMainScreenshotURL(absolute=True) }}"/>
{% endif %}
{% endblock %}
{% block scriptextra %}
<script async src="/static/video_embed.js"></script>
<script async src="/static/gallery.js"></script>
{% endblock %}
{% macro render_license(license) %}
{% if license.url %}
<a href="{{ license.url }}">{{ license.name }}</a>
@@ -30,52 +24,6 @@
{% endif %}
{% endmacro %}
{% block download_btn %}
{% if release %}
<a class="btn btn-block btn-lg btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
href="{{ package.getURL('packages.download') }}">
<div>
{{ _("Download") }}
</div>
{% if release and (release.min_rel or release.max_rel) %}
<small class="count display-block">
{% if release.min_rel and release.max_rel %}
{{ _("Minetest %(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
{% elif release.min_rel %}
{{ _("For Minetest %(min)s and above", min=release.min_rel.name) }}
{% elif release.max_rel %}
{{ _("Minetest %(max)s and below", max=release.max_rel.name) }}
{% endif %}
</small>
{% endif %}
</a>
{% if package.type == package.type.MOD %}
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
{% elif package.type == package.type.GAME %}
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
{% elif package.type == package.type.TXP %}
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
{% else %}
{{ 0 / 0 }}
{% endif %}
<p class="text-center mt-1 mb-0">
<a href="{{ installing_url }}">
<small>
<i class="fas fa-question-circle mr-1"></i>
{{ _("How do I install this?") }}
</small>
</a>
</p>
{% else %}
<i>
{{ _("No downloads available") }}
</i>
{% endif %}
{% endblock %}
{% block container %}
{% if not package.license.is_foss and not package.media_license.is_foss and package.type != package.type.TXP %}
{% set package_warning=_("Non-free code and media") %}
@@ -84,41 +32,171 @@
{% elif not package.media_license.is_foss %}
{% set package_warning=_("Non-free media") %}
{% endif %}
<style>
.bg-banner {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 65vh;
z-index: -1;
}
</style>
{% set release = package.getDownloadRelease() %}
<main>
{% set cover_image = package.cover_image.url or package.getMainScreenshotURL() %}
{% if cover_image %}
<div style="position:relative;">
<div class="bg-banner" style="background: linear-gradient(rgba(34, 34, 34, 0.7), rgba(34, 34, 34, 1)), url('{{ cover_image }}');
background-size: cover;
background-repeat: no-repeat;
background-position: center;"></div>
</div>
{% endif %}
<header class="container pt-3 mb-4">
<div class="row align-items-center">
<h1 class="col my-0 display-4">
<header class="jumbotron pb-3"
style="background: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7)), url('{{ cover_image }}');
background-size: cover;
background-repeat: no-repeat;
background-position: center;">
<div class="container">
<div class="btn-group float-right mb-4">
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
<a class="btn btn-primary" href="{{ package.getURL("packages.create_edit") }}">
<i class="fas fa-pen mr-1"></i>
{{ _("Edit") }}
</a>
{% endif %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
<a class="btn btn-primary" href="{{ package.getURL("packages.create_release") }}">
<i class="fas fa-plus mr-1"></i>
{{ _("Release") }}
</a>
{% endif %}
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
<a class="btn btn-danger" href="{{ package.getURL("packages.remove") }}">
<i class="fas fa-trash mr-1"></i>
{{ _("Remove") }}
</a>
{% endif %}
</div>
<h1 class="display-3">
{{ package.title }}
</h1>
<div class="col-md-3 text-right">
{% if package.type == package.type.GAME %}
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-block btn-primary">
{{ _("View content for game") }}
<p class="lead">
{{ package.short_desc }}
</p>
<p>
{% if package.dev_state.name == "LOOKING_FOR_MAINTAINER" or package.dev_state.name == "DEPRECATED" %}
<span class="badge badge-warning" title="{{ package.dev_state.get_desc() }}">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ package.dev_state.value }}
</span>
{% endif %}
{% if package_warning %}
<a class="badge badge-danger" href="/help/non_free/">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ package_warning }}
</a>
{% endif %}
{% for warning in package.content_warnings %}
<a class="badge badge-warning" rel="nofollow" href="/help/content_flags/"
title="{{ warning.description }}">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ warning.title }}
</a>
{% endfor %}
{% if package.dev_state.name == "WIP" %}
<span class="badge badge-info" title="{{ package.dev_state.get_desc() }}">
<i class="fas fa-tools" style="margin-right: 0.3em;"></i>
{{ _("Work in Progress") }}
</span>
{% endif %}
{% for t in package.tags %}
<a class="badge badge-primary" rel="nofollow"
title="{{ t.description or '' }}"
href="{{ url_for('packages.list_all', tag=t.name) }}">
{{ t.title }}
</a>
{% endfor %}
</p>
<div class="info-row row" style="margin-top: 2rem;">
<div class="btn-group-horizontal col">
<a class="btn" href="{{ url_for('users.profile', username=package.author.username) }}" title="{{ _("Author") }}">
<img src="{{ package.author.getProfilePicURL() }}" style="max-height: 1em; filter: none">
<span class="count">
{{ package.author.display_name }}
</span>
</a>
{% if release %}
<a class="btn" rel="nofollow" href="{{ package.getURL("packages.download") }}" title="{{ _("Downloads") }}"
download="{{ release.getDownloadFileName() }}">
<i class="fas fa-download"></i>
<span class="count">{{ package.downloads }}</span>
</a>
{% endif %}
<a class="btn" href="{{ url_for('threads.list_all', pid=package.id) }}" title="{{ _("Threads") }}">
<i class="fas fa-comment-alt"></i>
<span class="count">{{ threads | length }}</span>
</a>
<a class="btn" href="#reviews" 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>
</a>
{% if package.website %}
<a class="btn" href="{{ package.website }}">
<i class="fas fa-globe-europe"></i>
<span class="count">{{ _("Website") }}</span>
</a>
{% endif %}
{% if package.repo %}
<a class="btn" href="{{ package.repo }}">
<i class="fas fa-code"></i>
<span class="count">{{ _("Source") }}</span>
</a>
{% endif %}
{% if package.forums %}
<a class="btn" href="https://forum.minetest.net/viewtopic.php?t={{ package.forums }}">
<i class="fas fa-comments"></i>
<span class="count">{{ _("Forums") }}</span>
</a>
{% endif %}
{% if package.issueTracker %}
<a class="btn" href="{{ package.issueTracker }}">
<i class="fas fa-bug"></i>
<span class="count">{{ _("Issue Tracker") }}</span>
</a>
{% endif %}
</div>
{% if release and (release.min_rel or release.max_rel) %}
<div class="btn col-md-auto">
<img src="https://www.minetest.net/media/icon.svg" style="max-height: 1.2em;">
<span class="count">
{% if release.min_rel and release.max_rel %}
{{ _("%(min)s - %(max)s", min=release.min_rel.name, max=release.max_rel.name) }}
{% elif release.min_rel %}
{{ _("%(min)s and above", min=release.min_rel.name) }}
{% elif release.max_rel %}
{{ _("%(max)s and below", max=release.max_rel.name) }}
{% endif %}
</span>
</div>
{% endif %}
<div class="btn-group btn-group-horizontal col-md-auto">
{% if release %}
<a class="btn btn-download" rel="nofollow" download="{{ release.getDownloadFileName() }}"
href="{{ package.getURL("packages.download") }}">
{{ _("Download") }}
</a>
{% if package.type == package.type.MOD %}
{% set installing_url = "https://wiki.minetest.net/Installing_Mods" %}
{% elif package.type == package.type.GAME %}
{% set installing_url = "https://wiki.minetest.net/Games#Installing_games" %}
{% elif package.type == package.type.TXP %}
{% set installing_url = "https://wiki.minetest.net/Installing_Texture_Packs" %}
{% else %}
{{ 0 / 0 }}
{% endif %}
<a href="{{ installing_url }}" class="btn btn-download">
<i class="fas fa-question-circle"></i>
</a>
{% else %}
<i>
{{ _("No downloads available") }}
</i>
{% endif %}
</div>
</div>
</div>
</header>
@@ -133,7 +211,7 @@
<h2>{% if review_thread.private %}&#x1f512;{% endif %} {{ review_thread.title }}</h2>
{% if review_thread.private %}
<p><i>
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
{{ _("This thread is only visible to the package owner and users of Approver rank or above.") }}
</i></p>
{% endif %}
@@ -144,134 +222,40 @@
</section>
{% endif %}
<section class="container mb-4">
<section class="container mt-4">
<div class="row">
{% set screenshots = package.screenshots.all() %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") or package.video_url %}
<div id="packageGallery" class="col-md-9 carousel slide my-0" data-ride="carousel" data-interval="7500">
<div class="carousel-inner">
{% for ss in screenshots %}
<div class="carousel-item {% if loop.index == 1 %}active{% endif %}">
<a href="{{ ss.url }}">
<div class="embed-responsive embed-responsive-16by9">
<img class="embed-responsive-item" src="{{ ss.url }}"
alt="{{ ss.title }}">
</div>
<div class="carousel-caption text-shadow">
<h3 class="my-0">
{{ ss.title }}
</h3>
</div>
</a>
</div>
{% endfor %}
<a class="carousel-control-prev" href="#packageGallery" 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="#packageGallery" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">{{ _("Next") }}</span>
</a>
</div>
<ol class="gallery-thumbnails">
{% if package.video_url %}
<li>
<a href="{{ package.video_url }}" class="video-embed">
<i class="fas fa-play"></i>
<div class="label">
<i class="fas fa-external-link-square-alt"></i>
</div>
</a>
</li>
{% endif %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% for ss in screenshots %}
<li data-target="#packageGallery" data-slide-to="{{ loop.index - 1 }}" {% if loop.index == 1 %}class="active"{% endif %}>
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
{% if not ss.approved %}
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
{% endif %}
</li>
{% else %}
<li>
<a href="{{ package.getURL('packages.create_screenshot') }}">
<i class="fas fa-plus screenshot-add"></i>
</a>
</li>
{% endfor %}
{% endif %}
</ol>
</div>
<div class="col-md-3">
{% else %}
<div class="col">
{% endif %}
<div class="btn-group btn-group-sm mb-3">
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %}
<a class="btn btn-primary" href="{{ package.getURL('packages.create_edit') }}">
<i class="fas fa-pen mr-1"></i>
<div class="col-md-9" style="padding-right: 45px;">
{% set screenshots = package.screenshots.all() %}
{% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
{% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<a href="{{ package.getURL("packages.screenshots") }}" class="btn btn-primary float-right">
<i class="fas fa-images mr-1"></i>
{{ _("Edit") }}
</a>
{% endif %}
{% if package.checkPerm(current_user, "MAKE_RELEASE") %}
<a class="btn btn-primary" href="{{ package.getURL('packages.create_release') }}">
<i class="fas fa-plus mr-1"></i>
{{ _("Release") }}
</a>
{% endif %}
{% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %}
<a class="btn btn-danger" href="{{ package.getURL('packages.remove') }}">
<i class="fas fa-trash mr-1"></i>
{{ _("Remove") }}
</a>
{% endif %}
</div>
<p class="lead">
{{ package.short_desc }}
</p>
<p>
{% if package.dev_state.name == "LOOKING_FOR_MAINTAINER" or package.dev_state.name == "DEPRECATED" %}
<span class="badge badge-warning" title="{{ package.dev_state.get_desc() }}">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ package.dev_state.value }}
</span>
{% endif %}
{% if package_warning %}
<a class="badge badge-danger" href="/help/non_free/">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ package_warning }}
</a>
{% endif %}
{% for warning in package.content_warnings %}
<a class="badge badge-warning" rel="nofollow" href="/help/content_flags/"
title="{{ warning.description }}">
<i class="fas fa-exclamation-circle" style="margin-right: 0.3em;"></i>
{{ warning.title }}
</a>
{% endfor %}
{% if package.dev_state.name == "WIP" %}
<span class="badge badge-info" title="{{ package.dev_state.get_desc() }}">
<i class="fas fa-tools" style="margin-right: 0.3em;"></i>
{{ _("Work in Progress") }}
</span>
{% endif %}
{% for t in package.tags %}
<a class="badge badge-primary" rel="nofollow"
title="{{ t.description or '' }}"
href="{{ url_for('packages.list_all', tag=t.name) }}">
{{ t.title }}
</a>
{% endfor %}
</p>
</div>
</div>
</section>
<section class="container">
<div class="row">
<div class="col-md-9" style="padding-right: 45px;">
<ul class="screenshot_list">
{% for ss in screenshots %}
{% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %}
<li>
<a href="{{ ss.url }}" class="position-relative">
<img src="{{ ss.getThumbnailURL() }}" alt="{{ ss.title }}" />
{% if not ss.approved %}
<span class="badge bg-dark badge-tr">{{ _("Awaiting review") }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% else %}
<li>
<a href="{{ package.getURL("packages.create_screenshot") }}">
<i class="fas fa-plus screenshot-add"></i>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% if package.desc %}
<article class="markdown panel mb-5">
{{ package.desc | markdown }}
@@ -284,7 +268,7 @@
{% if current_user.is_authenticated %}
{% if has_review %}
<p>
<a class="btn btn-primary" href="{{ package.getURL('packages.review') }}">
<a class="btn btn-primary" href="{{ package.getURL("packages.review") }}">
{{ _("Edit Review") }}
</a>
</p>
@@ -310,20 +294,9 @@
{% from "macros/packagegridtile.html" import render_pkggrid %}
{{ render_pkggrid(packages_uses) }}
{% endif %}
{% if false %}
<h2>{{ _("Content") }}</h2>
<a href="{{ package.getURL('packages.game_hub') }}" class="btn btn-lg btn-primary">
{{ _("View content for game") }}
</a>
{% endif %}
</div>
<aside class="col-md-3 info-sidebar">
<div class="mb-4">
{{ self.download_btn() }}
</div>
{% if package.checkPerm(current_user, "MAKE_RELEASE") and package.update_config and package.update_config.outdated_at %}
{% set config = package.update_config %}
<div class="alert alert-warning">
@@ -414,26 +387,6 @@
</dl>
{% endif %}
{% if false %}
<h3>{{ _("Compatible Games") }}</h3>
<div style="max-height: 300px; overflow: hidden auto;">
{% for support in package.getSortedSupportedGames() %}
<a class="badge badge-secondary"
href="{{ support.game.getURL('packages.view') }}">
{{ _("%(title)s by %(display_name)s",
title=support.game.title, display_name=support.game.author.display_name) }}
</a>
{% else %}
{{ _("No specific game is required") }}
{% endfor %}
</div>
<p class="text-muted small mt-2 mb-0">
{{ _("This is an experimental feature.") }}
{{ _("Supported games are determined by an algorithm, and may not be correct.") }}
</p>
{% endif %}
<h3>
{{ _("Information") }}
</h3>
@@ -529,7 +482,7 @@
{% if package.approved and current_user != package.author %}
|
{% endif %}
<a href="{{ package.getURL('packages.audit') }}">
<a href="{{ package.getURL("packages.audit") }}">
{{ _("See audit log") }}
</a>
{% endif %}

View File

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

View File

@@ -2,15 +2,9 @@
{% block title %}
{{ _("Threads") }}
{% if package %}
- {{ package.title }}
{% endif %}
{% endblock %}
{% block content %}
{% if current_user.is_authenticated and package %}
<a href="{{ url_for('threads.new', pid=package and package.id) }}" class="btn btn-primary float-right">{{ _("New Thread") }}</a>
{% endif %}
<h1>{{ self.title() }}</h1>
{% from "macros/pagination.html" import render_pagination %}

View File

@@ -35,18 +35,10 @@
</div>
</div>
{% if allow_private_change %}
{{ render_checkbox_field(form.private, class_="my-3") }}
{% elif form.private.data %}
<p>
Private.
</p>
{% endif %}
{% if allow_private_change or form.private.data %}
<p>
{{ _("Only you, the package author, and users of Approver rank and above can read private threads.") }}
</p>
{% endif %}
{{ render_checkbox_field(form.private, class_="my-3") }}
<p>
{{ _("Only you, the package author, and users of Approver rank and above can read private threads.") }}
</p>
{{ render_submit_field(form.submit) }}
</form>

View File

@@ -36,16 +36,10 @@
<input type="submit" class="btn btn-primary" value="{{ _('Subscribe') }}" />
</form>
{% endif %}
{% if thread.checkPerm(current_user, "DELETE_THREAD") %}
{% if thread and thread.checkPerm(current_user, "DELETE_THREAD") %}
<a href="{{ url_for('threads.delete_thread', id=thread.id) }}" class="float-right mr-2 btn btn-danger">{{ _('Delete') }}</a>
{% endif %}
{% if thread.review and thread.review.checkPerm(current_user, "DELETE_REVIEW") and current_user.username != thread.review.author.username %}
<form method="post" action="{{ thread.review.getDeleteURL() }}" class="float-right mr-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" class="btn btn-danger" value="{{ _('Convert to Thread') }}" />
</form>
{% endif %}
{% if thread.checkPerm(current_user, "LOCK_THREAD") %}
{% if thread and thread.checkPerm(current_user, "LOCK_THREAD") %}
{% if thread.locked %}
<form method="post" action="{{ url_for('threads.set_lock', id=thread.id, lock=0) }}" class="float-right mr-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
@@ -81,35 +75,16 @@
{% if thread.package %}
<p>
{{ _("Package") }}: <a href="{{ thread.package.getURL('packages.view') }}">{{ thread.package.title }}</a>
{{ _("Package") }}: <a href="{{ thread.package.getURL("packages.view") }}">{{ thread.package.title }}</a>
</p>
{% endif %}
{% if thread.private %}
<aside class="row">
<div class="col-md-9">
<i>
{{ _("This thread is only visible to its creator, package maintainers, users of Approver rank or above, and @mentioned users.") }}
</i>
</div>
<div class="col-md-3">
<div class="d-flex flex-row justify-content-end flex-wrap align-items-center" style="gap: 0.5em;">
<span class="text-muted mr-2" title="{{ _('This thread is visible to the following users') }}">
{{ _("Visible to:") }}
</span>
{% for viewer in thread.get_visible_to() %}
<a href="{{ url_for('users.profile', username=viewer.username) }}" title="{{ viewer.display_name }}">
<img style="max-height: 2em;" src="{{ viewer.getProfilePicURL() }}" alt="{{ viewer.display_name }}" />
</a>
{% endfor %}
<a href="{{ url_for('users.list_all') }}" title="{{ _('Plus approvers and editors') }}">
+ <i class="fas fa-user-check"></i>
</a>
</div>
</div>
</aside>
<i>
{{ _("This thread is only visible to its creator, the package owner, and users of Approver rank or above.") }}
</i>
{% endif %}
{% from "macros/threads.html" import render_thread %}
{{ render_thread(thread, current_user, form) }}
{{ render_thread(thread, current_user) }}
{% endblock %}

View File

@@ -123,22 +123,22 @@
</div>
{% endif %}
{% if total_to_tag != 0 %}
<h2 class="mt-5">{{ _("Tag Packages") }}</h2>
<p>
{{ _("%(total_to_tag)d / %(total_packages)d packages don't have any tags.",
total_to_tag=total_to_tag, total_packages=total_packages) }}
</p>
<h2 class="mt-5">{{ _("Tag Packages") }}</h2>
<div class="progress my-4">
{% set perc = 100 * (total_packages - total_to_tag) / total_packages %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<p>
{{ _("%(total_to_tag)d / %(total_packages)d packages don't have any tags.",
total_to_tag=total_to_tag, total_packages=total_packages) }}
</p>
<div class="progress my-4">
{% set perc = 100 * (total_packages - total_to_tag) / total_packages %}
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ perc }}%" aria-valuenow="{{ perc }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<a class="btn btn-primary" href="{{ url_for('todo.tags') }}">{{ _("View Tags") }}</a>
<a class="btn btn-primary" href="{{ url_for('todo.tags') }}">{{ _("View Tags") }}</a>
{% endif %}
{% if unfulfilled_meta_packages %}
<h2 class="mt-5">
@@ -185,17 +185,4 @@
</div>
</div>
{% endif %}
<div class="mt-5"></div>
{% if current_user.rank.atLeast(current_user.rank.MODERATOR) %}
<a class="btn btn-secondary float-right" href="{{ url_for('admin.audit') }}">
{{ _("View All") }}
</a>
{% endif %}
<h2>{{ _("Recent Actions") }}</h2>
{% from "macros/audit_log.html" import render_audit_log %}
{{ render_audit_log(audit_log, current_user) }}
{% endblock %}

View File

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

View File

@@ -41,37 +41,13 @@
{% if not user.rank.atLeast(current_user.rank) %}
<h3>{{ _("Ban") }}</h3>
{% if user.ban %}
{% if user.rank.name == "BANNED" %}
<p>
Banned by {{ user.ban.banned_by.display_name }} at {{ user.ban.created_at | full_datetime }}
{% if user.ban.expires_at %}
until {{ user.ban.expires_at | date }}
{% endif %}
Banned.
</p>
<blockquote>
{{ user.ban.message }}
</blockquote>
<form method="POST" action="{{ url_for('users.modtools_unban', username=user.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="submit" value="{{ _('Unban') }}" class="btn btn-primary" />
</form>
{% else %}
<form method="POST" action="{{ url_for('users.modtools_ban', username=user.username) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="form-group">
<label for="message">{{ _("Message") }}</label>
<input id="message" class="form-control" type="text" name="message" required minlength="5">
<small class="form-text text-muted">
{{ _("Message to display to banned user") }}
</small>
</div>
<div class="form-group">
<label for="expires_at">{{ _("Expires At") }}</label>
<input id="expires_at" class="form-control" type="date" name="expires_at">
<small class="form-text text-muted">
{{ _("Expiry date. Leave blank for permanent ban") }}
</small>
</div>
<input type="submit" value="{{ _('Ban') }}" class="btn btn-danger" />
</form>
{% endif %}

View File

@@ -100,7 +100,7 @@
<a class="btn" href="{{ url_for('packages.list_all', author=user.username) }}">
<i class="fas fa-box"></i>
<span class="count">
<strong>{{ user.packages.filter_by(state='APPROVED').count() }}</strong>
<strong>{{ user.packages.count() }}</strong>
{{ _("packages") }}
</span>
</a>

View File

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

View File

@@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block title %}
{{ _("Search in Package Releases") }}
{% endblock %}
{% block query_hint %}
<a href="https://www.digitalocean.com/community/tutorials/using-grep-regular-expressions-to-search-for-text-patterns-in-linux#extended-regular-expressions">
POSIX Extended Regular Expressions
</a>
{% endblock %}
{% block content %}
<h1>{{ self.title() }}</h1>
{% from "macros/forms.html" import render_field, render_submit_field %}
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{{ render_field(form.query, hint=self.query_hint()) }}
{{ render_field(form.file_filter, hint="Supports wildcards and regex") }}
{{ render_submit_field(form.submit, tabindex=180) }}
</form>
<p class="mt-5">
For more information, see <a href="https://linux.die.net/man/1/zipgrep">ZipGrep's man page</a>.
</p>
{% endblock %}

View File

@@ -1,47 +0,0 @@
{% extends "base.html" %}
{% block title %}
{{ _("'%(query)s' - Search Package Releases", query=query) }}
{% endblock %}
{% block content %}
<a class="btn btn-secondary float-right" href="{{ url_for('zipgrep.zipgrep_search') }}">New Query</a>
<h1>{{ _("Search in Package Releases") }}</h1>
<h2>{{ query }}</h2>
<p class="text-muted">
Found in {{ matches | count }} package(s).
</p>
<div class="list-group">
{% for match in matches %}
<div class="list-group-item">
<div class="row">
<div class="col-sm-2 text-muted">
<img
class="img-fluid"
src="{{ match.package.getThumbnailOrPlaceholder() }}" />
<div class="mt-2">
<a href="{{ match.package.getURL('packages.view') }}">
{{ match.package.title }}
</a>
by {{ match.package.author.display_name }}
</div>
<p class="mt-4">
{{ match.lines.split("\n") | select | list | count }} match(es)
</p>
<a class="mt-4 btn btn-secondary" href="{{ match.package.getDownloadRelease().getDownloadURL() }}">
Download
</a>
</div>
<div class="col-sm-10">
<pre style="max-height: 300px;" class="m-0">{{ match.lines }}</pre>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -53,7 +53,7 @@ def test_register(client):
rv = register(client, "££££!!!", "Test User", "password", "test@example.com", "13")
assert b"invalid-feedback" in rv.data
assert b"Only alphabetic letters (A-Za-z), numbers (0-9), underscores (_), minuses (-), and periods (.) allowed</p>" in rv.data
assert b"Only a-zA-Z0-9._ allowed</p>" in rv.data
def test_register_flow(client):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
"""empty message
Revision ID: 01f8d5de29e1
Revises: e571b3498f9e
Create Date: 2022-02-13 10:12:20.150232
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '01f8d5de29e1'
down_revision = 'e571b3498f9e'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('user_ban',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('message', sa.UnicodeText(), nullable=False),
sa.Column('banned_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['banned_by_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id')
)
def downgrade():
op.drop_table('user_ban')

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