Optimise imports and fix linter issues

This commit is contained in:
rubenwardy 2023-06-19 19:32:36 +01:00
parent 0ddf498285
commit 16f93b3e13
70 changed files with 761 additions and 276 deletions

@ -15,16 +15,18 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime import datetime
import os
import redis
from flask import * from flask import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response
from flask_gravatar import Gravatar
from flask_mail import Mail
from flask_github import GitHub
from flask_wtf.csrf import CSRFProtect
from flask_flatpages import FlatPages
from flask_babel import Babel, gettext from flask_babel import Babel, gettext
from flask_flatpages import FlatPages
from flask_github import GitHub
from flask_gravatar import Gravatar
from flask_login import logout_user, current_user, LoginManager from flask_login import logout_user, current_user, LoginManager
import os, redis from flask_mail import Mail
from flask_wtf.csrf import CSRFProtect
from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG from app.markdown import init_markdown, MARKDOWN_EXTENSIONS, MARKDOWN_EXTENSION_CONFIG
app = Flask(__name__, static_folder="public/static") app = Flask(__name__, static_folder="public/static")

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

@ -129,12 +129,13 @@ def _package_list(packages: List[str]):
@action("Send WIP package notification") @action("Send WIP package notification")
def remind_wip(): def remind_wip():
users = User.query.filter(User.packages.any(or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED))) users = User.query.filter(User.packages.any(or_(
Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)))
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = db.session.query(Package.title).filter( packages = db.session.query(Package.title).filter(
Package.author_id == user.id, Package.author_id == user.id,
or_(Package.state == PackageState.WIP, Package.state==PackageState.CHANGES_NEEDED)) \ or_(Package.state == PackageState.WIP, Package.state == PackageState.CHANGES_NEEDED)) \
.all() .all()
packages = [pkg[0] for pkg in packages] packages = [pkg[0] for pkg in packages]
@ -200,17 +201,16 @@ def import_licenses():
licenses = r.json()["licenses"] licenses = r.json()["licenses"]
existing_licenses = {} existing_licenses = {}
for license in License.query.all(): for license_data in License.query.all():
assert license.name not in renames.keys() assert license_data.name not in renames.keys()
existing_licenses[license.name.lower()] = license existing_licenses[license_data.name.lower()] = license_data
for license in licenses: for license_data in licenses:
obj = existing_licenses.get(license["licenseId"].lower()) obj = existing_licenses.get(license_data["licenseId"].lower())
if obj: if obj:
obj.url = license["reference"] obj.url = license_data["reference"]
elif license.get("isOsiApproved") and license.get("isFsfLibre") and \ elif license_data.get("isOsiApproved") and license_data.get("isFsfLibre") and not license_data["isDeprecatedLicenseId"]:
not license["isDeprecatedLicenseId"]: obj = License(license_data["licenseId"], True, license_data["reference"])
obj = License(license["licenseId"], True, license["reference"])
db.session.add(obj) db.session.add(obj)
db.session.commit() db.session.commit()
@ -228,12 +228,12 @@ def delete_inactive_users():
@action("Send Video URL notification") @action("Send Video URL notification")
def remind_video_url(): def remind_video_url():
users = User.query.filter(User.maintained_packages.any( users = User.query.filter(User.maintained_packages.any(
and_(Package.video_url==None, Package.type==PackageType.GAME, Package.state==PackageState.APPROVED))) and_(Package.video_url == None, Package.type == PackageType.GAME, Package.state == PackageState.APPROVED)))
system_user = get_system_user() system_user = get_system_user()
for user in users: for user in users:
packages = db.session.query(Package.title).filter( packages = db.session.query(Package.title).filter(
or_(Package.author==user, Package.maintainers.contains(user)), or_(Package.author == user, Package.maintainers.contains(user)),
Package.video_url==None, Package.video_url == None,
Package.type == PackageType.GAME, Package.type == PackageType.GAME,
Package.state == PackageState.APPROVED) \ Package.state == PackageState.APPROVED) \
.all() .all()
@ -341,7 +341,7 @@ def import_screenshots():
packages = Package.query \ packages = Package.query \
.filter(Package.state != PackageState.DELETED) \ .filter(Package.state != PackageState.DELETED) \
.outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \ .outerjoin(PackageScreenshot, Package.id == PackageScreenshot.package_id) \
.filter(PackageScreenshot.id==None) \ .filter(PackageScreenshot.id == None) \
.all() .all()
for package in packages: for package in packages:
importRepoScreenshot.delay(package.id) importRepoScreenshot.delay(package.id)

@ -22,7 +22,7 @@ from wtforms.validators import InputRequired, Length
from app.utils import rank_required, addAuditLog, addNotification, get_system_user from app.utils import rank_required, addAuditLog, addNotification, get_system_user
from . import bp from . import bp
from .actions import actions from .actions import actions
from ...models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType from app.models import UserRank, Package, db, PackageState, User, AuditSeverity, NotificationType
@bp.route("/admin/", methods=["GET", "POST"]) @bp.route("/admin/", methods=["GET", "POST"])

@ -24,7 +24,7 @@ from app.markdown import render_markdown
from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk from app.tasks.emails import send_user_email, send_bulk_email as task_send_bulk
from app.utils import rank_required, addAuditLog from app.utils import rank_required, addAuditLog
from . import bp from . import bp
from ...models import UserRank, User, AuditSeverity from app.models import UserRank, User, AuditSeverity
class SendEmailForm(FlaskForm): class SendEmailForm(FlaskForm):
@ -54,7 +54,7 @@ def send_single_email():
text = form.text.data text = form.text.data
html = render_markdown(text) html = render_markdown(text)
task = send_user_email.delay(user.email, user.locale or "en",form.subject.data, text, html) task = send_user_email.delay(user.email, user.locale or "en", form.subject.data, text, html)
return redirect(url_for("tasks.check", id=task.id, r=next_url)) return redirect(url_for("tasks.check", id=task.id, r=next_url))
return render_template("admin/send_email.html", form=form, user=user) return render_template("admin/send_email.html", form=form, user=user)

@ -23,7 +23,7 @@ from wtforms.validators import InputRequired, Length, Optional
from app.utils import rank_required, nonEmptyOrNone, addAuditLog from app.utils import rank_required, nonEmptyOrNone, addAuditLog
from . import bp from . import bp
from ...models import UserRank, License, db, AuditSeverity from app.models import UserRank, License, db, AuditSeverity
@bp.route("/licenses/") @bp.route("/licenses/")

@ -22,8 +22,8 @@ from wtforms import StringField, TextAreaField, BooleanField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp from wtforms.validators import InputRequired, Length, Optional, Regexp
from . import bp from . import bp
from ...models import Permission, Tag, db, AuditSeverity from app.models import Permission, Tag, db, AuditSeverity
from ...utils import addAuditLog from app.utils import addAuditLog
@bp.route("/tags/") @bp.route("/tags/")
@ -45,7 +45,8 @@ def tag_list():
class TagForm(FlaskForm): class TagForm(FlaskForm):
title = StringField("Title", [InputRequired(), Length(3, 100)]) title = StringField("Title", [InputRequired(), Length(3, 100)])
description = TextAreaField("Description", [Optional(), Length(0, 500)]) description = TextAreaField("Description", [Optional(), Length(0, 500)])
name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0, "Lower case letters (a-z), digits (0-9), and underscores (_) only")]) name = StringField("Name", [Optional(), Length(1, 20), Regexp("^[a-z0-9_]", 0,
"Lower case letters (a-z), digits (0-9), and underscores (_) only")])
is_protected = BooleanField("Is Protected") is_protected = BooleanField("Is Protected")
submit = SubmitField("Save") submit = SubmitField("Save")
@ -63,7 +64,7 @@ def create_edit_tag(name=None):
if not Permission.check_perm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG): if not Permission.check_perm(current_user, Permission.EDIT_TAGS if tag else Permission.CREATE_TAG):
abort(403) abort(403)
form = TagForm( obj=tag) form = TagForm(obj=tag)
if form.validate_on_submit(): if form.validate_on_submit():
if tag is None: if tag is None:
tag = Tag(form.title.data) tag = Tag(form.title.data)

@ -23,13 +23,14 @@ from wtforms.validators import InputRequired, Length
from app.utils import rank_required, addAuditLog from app.utils import rank_required, addAuditLog
from . import bp from . import bp
from ...models import UserRank, MinetestRelease, db, AuditSeverity from app.models import UserRank, MinetestRelease, db, AuditSeverity
@bp.route("/versions/") @bp.route("/versions/")
@rank_required(UserRank.MODERATOR) @rank_required(UserRank.MODERATOR)
def version_list(): def version_list():
return render_template("admin/versions/list.html", versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all()) return render_template("admin/versions/list.html",
versions=MinetestRelease.query.order_by(db.asc(MinetestRelease.id)).all())
class VersionForm(FlaskForm): class VersionForm(FlaskForm):

@ -15,14 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, render_template, abort, url_for, request, flash from flask import redirect, render_template, abort, url_for, request
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import InputRequired, Length, Optional, Regexp from wtforms.validators import InputRequired, Length, Optional, Regexp
from app.utils import rank_required from app.utils import rank_required
from . import bp from . import bp
from ...models import UserRank, ContentWarning, db from app.models import UserRank, ContentWarning, db
@bp.route("/admin/warnings/") @bp.route("/admin/warnings/")

@ -35,7 +35,7 @@ from . import bp
from .auth import is_api_authd from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \ from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
api_order_screenshots, api_edit_package, api_set_cover_image api_order_screenshots, api_edit_package, api_set_cover_image
from ...utils.minetest_hypertext import html_to_minetest from app.utils.minetest_hypertext import html_to_minetest
def cors_allowed(f): def cors_allowed(f):
@ -65,15 +65,15 @@ def cached(max_age: int):
@cors_allowed @cors_allowed
@cached(300) @cached(300)
def packages(): def packages():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
if request.args.get("fmt") == "keys": if request.args.get("fmt") == "keys":
return jsonify([package.as_key_dict() for package in query.all()]) return jsonify([pkg.as_key_dict() for pkg in query.all()])
pkgs = qb.convertToDictionary(query.all()) pkgs = qb.convertToDictionary(query.all())
if "engine_version" in request.args or "protocol_version" in request.args: if "engine_version" in request.args or "protocol_version" in request.args:
pkgs = [package for package in pkgs if package.get("release")] pkgs = [pkg for pkg in pkgs if pkg.get("release")]
# Promote featured packages # Promote featured packages
if "sort" not in request.args and "order" not in request.args and "q" not in request.args: if "sort" not in request.args and "order" not in request.args and "q" not in request.args:
@ -92,7 +92,7 @@ def packages():
@bp.route("/api/packages/<author>/<name>/") @bp.route("/api/packages/<author>/<name>/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def package(package): def package_view(package):
return jsonify(package.as_dict(current_app.config["BASE_URL"])) return jsonify(package.as_dict(current_app.config["BASE_URL"]))
@ -119,12 +119,12 @@ def edit_package(token, package):
def resolve_package_deps(out, package, only_hard, depth=1): def resolve_package_deps(out, package, only_hard, depth=1):
id = package.get_id() id_ = package.get_id()
if id in out: if id_ in out:
return return
ret = [] ret = []
out[id] = ret out[id_] = ret
if package.type != PackageType.MOD: if package.type != PackageType.MOD:
return return
@ -173,8 +173,8 @@ def package_dependencies(package):
@bp.route("/api/topics/") @bp.route("/api/topics/")
@cors_allowed @cors_allowed
def topics(): def topics():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True) query = qb.buildTopicQuery(show_added=True)
return jsonify([t.as_dict() for t in query.all()]) return jsonify([t.as_dict() for t in query.all()])
@ -285,8 +285,8 @@ def create_release(token, package):
@bp.route("/api/packages/<author>/<name>/releases/<int:id>/") @bp.route("/api/packages/<author>/<name>/releases/<int:id>/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def release(package: Package, id: int): def release_view(package: Package, id_: int):
release = PackageRelease.query.get(id) release = PackageRelease.query.get(id_)
if release is None or release.package != package: if release is None or release.package != package:
error(404, "Release not found") error(404, "Release not found")
@ -298,15 +298,15 @@ def release(package: Package, id: int):
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed @cors_allowed
def delete_release(token: APIToken, package: Package, id: int): def delete_release(token: APIToken, package: Package, id_: int):
release = PackageRelease.query.get(id) release = PackageRelease.query.get(id_)
if release is None or release.package != package: if release is None or release.package != package:
error(404, "Release not found") error(404, "Release not found")
if not token: if not token:
error(401, "Authentication needed") error(401, "Authentication needed")
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
if not release.check_perm(token.owner, Permission.DELETE_RELEASE): if not release.check_perm(token.owner, Permission.DELETE_RELEASE):
@ -352,8 +352,8 @@ def create_screenshot(token: APIToken, package: Package):
@bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/") @bp.route("/api/packages/<author>/<name>/screenshots/<int:id>/")
@is_package_page @is_package_page
@cors_allowed @cors_allowed
def screenshot(package, id): def screenshot(package, id_):
ss = PackageScreenshot.query.get(id) ss = PackageScreenshot.query.get(id_)
if ss is None or ss.package != package: if ss is None or ss.package != package:
error(404, "Screenshot not found") error(404, "Screenshot not found")
@ -365,8 +365,8 @@ def screenshot(package, id):
@is_package_page @is_package_page
@is_api_authd @is_api_authd
@cors_allowed @cors_allowed
def delete_screenshot(token: APIToken, package: Package, id: int): def delete_screenshot(token: APIToken, package: Package, id_: int):
ss = PackageScreenshot.query.get(id) ss = PackageScreenshot.query.get(id_)
if ss is None or ss.package != package: if ss is None or ss.package != package:
error(404, "Screenshot not found") error(404, "Screenshot not found")
@ -376,7 +376,7 @@ def delete_screenshot(token: APIToken, package: Package, id: int):
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS): if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS):
error(403, "You do not have the permission to delete screenshots") error(403, "You do not have the permission to delete screenshots")
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
if package.cover_image == ss: if package.cover_image == ss:
@ -401,7 +401,7 @@ def order_screenshots(token: APIToken, package: Package):
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS): if not package.check_perm(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 change screenshots")
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
json = request.json json = request.json
@ -423,7 +423,7 @@ def set_cover_image(token: APIToken, package: Package):
if not package.check_perm(token.owner, Permission.ADD_SCREENSHOTS): if not package.check_perm(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 change screenshots")
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
json = request.json json = request.json
@ -498,7 +498,7 @@ def all_package_stats():
@cors_allowed @cors_allowed
@cached(300) @cached(300)
def package_scores(): def package_scores():
qb = QueryBuilder(request.args) qb = QueryBuilder(request.args)
query = qb.buildPackageQuery() query = qb.buildPackageQuery()
pkgs = [package.as_score_dict() for package in query.all()] pkgs = [package.as_score_dict() for package in query.all()]
@ -520,19 +520,19 @@ def content_warnings():
@bp.route("/api/licenses/") @bp.route("/api/licenses/")
@cors_allowed @cors_allowed
def licenses(): def licenses():
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \ all_licenses = License.query.order_by(db.asc(License.name)).all()
for license in License.query.order_by(db.asc(License.name)).all() ]) return jsonify([{"name": license.name, "is_foss": license.is_foss} for license in all_licenses])
@bp.route("/api/homepage/") @bp.route("/api/homepage/")
@cors_allowed @cors_allowed
def homepage(): def homepage():
query = Package.query.filter_by(state=PackageState.APPROVED) query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count() count = query.count()
spotlight = query.filter(Package.tags.any(name="spotlight")).order_by( spotlight = query.filter(Package.tags.any(name="spotlight")).order_by(
func.random()).limit(6).all() func.random()).limit(6).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all() new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all() pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all() pop_gam = query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score)).limit(8).all()
pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all() pop_txp = query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score)).limit(8).all()
@ -548,19 +548,19 @@ def homepage():
downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none() downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none()
downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0] downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0]
def mapPackages(packages: List[Package]): def map_packages(packages: List[Package]):
return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages] return [pkg.as_short_dict(current_app.config["BASE_URL"]) for pkg in packages]
return jsonify({ return jsonify({
"count": count, "count": count,
"downloads": downloads, "downloads": downloads,
"spotlight": mapPackages(spotlight), "spotlight": map_packages(spotlight),
"new": mapPackages(new), "new": map_packages(new),
"updated": mapPackages(updated), "updated": map_packages(updated),
"pop_mod": mapPackages(pop_mod), "pop_mod": map_packages(pop_mod),
"pop_txp": mapPackages(pop_txp), "pop_txp": map_packages(pop_txp),
"pop_game": mapPackages(pop_gam), "pop_game": map_packages(pop_gam),
"high_reviewed": mapPackages(high_reviewed) "high_reviewed": map_packages(high_reviewed)
}) })

@ -26,6 +26,7 @@ from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
def error(code: int, msg: str): def error(code: int, msg: str):
abort(make_response(jsonify({ "success": False, "error": msg }), code)) abort(make_response(jsonify({ "success": False, "error": msg }), code))
# Catches LogicErrors and aborts with JSON error # Catches LogicErrors and aborts with JSON error
def guard(f): def guard(f):
def ret(*args, **kwargs): def ret(*args, **kwargs):
@ -39,7 +40,7 @@ def guard(f):
def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str, def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"): min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API"):
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
reason += ", token=" + token.name reason += ", token=" + token.name
@ -54,8 +55,8 @@ def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: s
def api_create_zip_release(token: APIToken, package: Package, title: str, file, def api_create_zip_release(token: APIToken, package: Package, title: str, file,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash:str=None): min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash: str = None):
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
reason += ", token=" + token.name reason += ", token=" + token.name
@ -70,7 +71,7 @@ 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, is_cover_image: bool, reason="API"):
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
reason += ", token=" + token.name reason += ", token=" + token.name
@ -84,7 +85,7 @@ def api_create_screenshot(token: APIToken, package: Package, title: str, file, i
def api_order_screenshots(token: APIToken, package: Package, order: [any]): def api_order_screenshots(token: APIToken, package: Package, order: [any]):
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
guard(do_order_screenshots)(token.owner, package, order) guard(do_order_screenshots)(token.owner, package, order)
@ -95,7 +96,7 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]):
def api_set_cover_image(token: APIToken, package: Package, cover_image): def api_set_cover_image(token: APIToken, package: Package, cover_image):
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
guard(do_set_cover_image)(token.owner, package, cover_image) guard(do_set_cover_image)(token.owner, package, cover_image)
@ -106,7 +107,7 @@ def api_set_cover_image(token: APIToken, package: Package, cover_image):
def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"): def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"):
if not token.canOperateOnPackage(package): if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package") error(403, "API token does not have access to the package")
reason += ", token=" + token.name reason += ", token=" + token.name

@ -19,8 +19,8 @@ from flask import render_template, redirect, request, session, url_for, abort
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, SubmitField
from wtforms.validators import * from wtforms.validators import InputRequired, Length
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from app.models import db, User, APIToken, Permission from app.models import db, User, APIToken, Permission

@ -1,3 +1,20 @@
# ContentDB
# Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template from flask import Blueprint, render_template
from flask_login import current_user from flask_login import current_user
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_

@ -65,7 +65,7 @@ def webhook_impl():
title = ref.replace("refs/tags/", "") title = ref.replace("refs/tags/", "")
else: else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported." return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null")) .format(event or "null"))
# #
# Perform release # Perform release

@ -1,8 +1,25 @@
# ContentDB
# Copyright (C) 2018-23 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template, redirect from flask import Blueprint, render_template, redirect
from app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag
bp = Blueprint("homepage", __name__) bp = Blueprint("homepage", __name__)
from app.models import *
from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.orm import joinedload, subqueryload
from sqlalchemy.sql.expression import func from sqlalchemy.sql.expression import func
@ -29,12 +46,12 @@ def home():
joinedload(PackageReview.package).joinedload(Package.author).load_only(User.username, User.display_name), joinedload(PackageReview.package).joinedload(Package.author).load_only(User.username, User.display_name),
joinedload(PackageReview.package).load_only(Package.title, Package.name).subqueryload(Package.main_screenshot)) joinedload(PackageReview.package).load_only(Package.title, Package.name).subqueryload(Package.main_screenshot))
query = Package.query.filter_by(state=PackageState.APPROVED) query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count() count = query.count()
spotlight_pkgs = query.filter(Package.tags.any(name="spotlight")).order_by(func.random()).limit(6).all() spotlight_pkgs = query.filter(Package.tags.any(name="spotlight")).order_by(func.random()).limit(6).all()
new = package_load(query.order_by(db.desc(Package.approved_at))).limit(4).all() new = package_load(query.order_by(db.desc(Package.approved_at))).limit(4).all()
pop_mod = package_load(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all() pop_mod = package_load(query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score))).limit(8).all()
pop_gam = package_load(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all() pop_gam = package_load(query.filter_by(type=PackageType.GAME).order_by(db.desc(Package.score))).limit(8).all()
pop_txp = package_load(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all() pop_txp = package_load(query.filter_by(type=PackageType.TXP).order_by(db.desc(Package.score))).limit(8).all()
@ -58,4 +75,5 @@ def home():
.group_by(Tag.id).order_by(db.asc(Tag.title)).all() .group_by(Tag.id).order_by(db.asc(Tag.title)).all()
return render_template("index.html", count=count, downloads=downloads, tags=tags, spotlight_pkgs=spotlight_pkgs, return render_template("index.html", count=count, downloads=downloads, tags=tags, spotlight_pkgs=spotlight_pkgs,
new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed, reviews=reviews) new=new, updated=updated, pop_mod=pop_mod, pop_txp=pop_txp, pop_gam=pop_gam, high_reviewed=high_reviewed,
reviews=reviews)

@ -21,6 +21,7 @@ from app.models import Package, db, User, UserRank, PackageState
bp = Blueprint("metrics", __name__) bp = Blueprint("metrics", __name__)
def generate_metrics(full=False): def generate_metrics(full=False):
def write_single_stat(name, help, type, value): def write_single_stat(name, help, type, value):
fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n" fmt = "# HELP {name} {help}\n# TYPE {name} {type}\n{name} {value}\n\n"
@ -31,7 +32,6 @@ def generate_metrics(full=False):
pieces = [key + "=" + str(val) for key, val in labels.items()] pieces = [key + "=" + str(val) for key, val in labels.items()]
return ",".join(pieces) return ",".join(pieces)
def write_array_stat(name, help, type, data): def write_array_stat(name, help, type, data):
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \ ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
.format(name=name, help=help, type=type) .format(name=name, help=help, type=type)
@ -67,6 +67,7 @@ def generate_metrics(full=False):
return ret return ret
@bp.route("/metrics") @bp.route("/metrics")
def metrics(): def metrics():
response = make_response(generate_metrics(), 200) response = make_response(generate_metrics(), 200)

@ -14,8 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, redirect, render_template, abort
from flask import *
from sqlalchemy import func from sqlalchemy import func
from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic from app.models import MetaPackage, Package, db, Dependency, PackageState, ForumTopic

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template, redirect, url_for from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import or_, desc from sqlalchemy import or_, desc

@ -19,7 +19,7 @@ from sqlalchemy.orm import joinedload
from . import bp from . import bp
from app.utils import is_package_page from app.utils import is_package_page
from ...models import Package, PackageType, PackageState, db, PackageRelease from app.models import Package, PackageType, PackageState, db, PackageRelease
@bp.route("/packages/<author>/<name>/hub/") @bp.route("/packages/<author>/<name>/hub/")

@ -14,29 +14,37 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import typing
from urllib.parse import quote as urlescape from urllib.parse import quote as urlescape
from celery import uuid from celery import uuid
from flask import render_template, make_response from flask import render_template, make_response, request, redirect, flash, url_for, abort
from flask_login import login_required from flask_babel import gettext, lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from jinja2.utils import markupsafe from jinja2.utils import markupsafe
from sqlalchemy import func from sqlalchemy import func, or_, and_
from sqlalchemy.orm import joinedload, subqueryload from sqlalchemy.orm import joinedload, subqueryload
from wtforms import * from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, BooleanField
from wtforms.validators import * from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, URL, NumberRange, ValidationError
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.logic.packages import do_edit_package from app.logic.packages import do_edit_package
from app.models.packages import PackageProvides
from app.querybuilder import QueryBuilder from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_key from app.rediscache import has_key, set_key
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease from app.tasks.importtasks import importRepoScreenshot, checkZipRelease
from app.tasks.webhooktasks import post_discord_webhook from app.tasks.webhooktasks import post_discord_webhook
from app.utils import * from app.logic.game_support import GameSupportResolver
from . import bp, get_package_tabs from . import bp, get_package_tabs
from ...logic.game_support import GameSupportResolver from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \
Dependency, Thread, UserRank, PackageReview, PackageDevState, ContentWarning, License, AuditSeverity, \
PackageScreenshot, NotificationType, AuditLogEntry, PackageAlias, PackageProvides, PackageGameSupport, \
PackageDailyStats
from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, addAuditLog, getPackageByInfo, \
addNotification, get_system_user, rank_required, get_games_from_csv, get_daterange_options
@bp.route("/packages/") @bp.route("/packages/")
@ -174,14 +182,14 @@ def view(package):
topic_error = "<br />".join(errors) topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id, review_id=None) threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated: if not current_user.is_authenticated:
threads = threads.filter_by(private=False) threads = threads.filter_by(private=False)
elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author: elif not current_user.rank.atLeast(UserRank.APPROVER) and not current_user == package.author:
threads = threads.filter(or_(Thread.private == False, Thread.author == current_user)) threads = threads.filter(or_(Thread.private == False, Thread.author == current_user))
has_review = current_user.is_authenticated and PackageReview.query.filter_by(package=package, author=current_user).count() > 0 has_review = current_user.is_authenticated and \
PackageReview.query.filter_by(package=package, author=current_user).count() > 0
return render_template("packages/view.html", return render_template("packages/view.html",
package=package, releases=releases, packages_uses=packages_uses, package=package, releases=releases, packages_uses=packages_uses,
@ -197,10 +205,11 @@ def shield(package, type):
url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \ url = "https://img.shields.io/static/v1?label=ContentDB&message={}&color={}" \
.format(urlescape(package.title), urlescape("#375a7f")) .format(urlescape(package.title), urlescape("#375a7f"))
elif type == "downloads": elif type == "downloads":
api_url = abs_url_for("api.package", author=package.author.username, name=package.name) api_url = abs_url_for("api.package_view", author=package.author.username, name=package.name)
url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \ url = "https://img.shields.io/badge/dynamic/json?color={}&label=ContentDB&query=downloads&suffix=+downloads&url={}" \
.format(urlescape("#375a7f"), urlescape(api_url)) .format(urlescape("#375a7f"), urlescape(api_url))
else: else:
from flask import abort
abort(404) abort(404)
return redirect(url) return redirect(url)
@ -213,7 +222,7 @@ def download(package):
if release is None: if release is None:
if "application/zip" in request.accept_mimetypes and \ if "application/zip" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes: "text/html" not in request.accept_mimetypes:
return "", 204 return "", 204
else: else:
flash(gettext("No download available."), "danger") flash(gettext("No download available."), "danger")
@ -247,7 +256,7 @@ class PackageForm(FlaskForm):
repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None]) repo = StringField(lazy_gettext("VCS Repository URL"), [Optional(), URL()], filters = [lambda x: x or None])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None]) website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None]) issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)]) forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0, 999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None]) video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None])
donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None]) donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None])
@ -721,7 +730,7 @@ def game_support(package):
all_game_support = package.supported_games.all() all_game_support = package.supported_games.all()
all_game_support.sort(key=lambda x: -x.game.score) all_game_support.sort(key=lambda x: -x.game.score)
supported_games_list: List[str] = [x.game.name for x in all_game_support if x.supports] supported_games_list: typing.List[str] = [x.game.name for x in all_game_support if x.supports]
if package.supports_all_games: if package.supports_all_games:
supported_games_list.insert(0, "*") supported_games_list.insert(0, "*")
supported_games = ", ".join(supported_games_list) supported_games = ", ".join(supported_games_list)
@ -752,7 +761,7 @@ def statistics(package):
@bp.route("/packages/<author>/<name>/stats.csv") @bp.route("/packages/<author>/<name>/stats.csv")
@is_package_page @is_package_page
def stats_csv(package): def stats_csv(package):
stats: List[PackageDailyStats] = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all() stats: typing.List[PackageDailyStats] = package.daily_stats.order_by(db.asc(PackageDailyStats.date)).all()
columns = ["platform_minetest", "platform_other", "reason_new", columns = ["platform_minetest", "platform_other", "reason_new",
"reason_dependency", "reason_update"] "reason_dependency", "reason_update"]

@ -14,19 +14,20 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, request, redirect, flash, url_for, abort
from flask import * from flask_babel import lazy_gettext, gettext
from flask_babel import gettext, lazy_gettext from flask_login import login_required, current_user
from flask_login import login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField
from wtforms.validators import InputRequired, Length, Optional
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release
from app.models import Package, db, User, PackageState, Permission, UserRank, PackageDailyStats, MinetestRelease, \
PackageRelease, PackageUpdateTrigger, PackageUpdateConfig
from app.rediscache import has_key, set_key, make_download_key from app.rediscache import has_key, set_key, make_download_key
from app.tasks.importtasks import check_update_config from app.tasks.importtasks import check_update_config
from app.utils import * from app.utils import is_user_bot, is_package_page, nonEmptyOrNone
from . import bp, get_package_tabs from . import bp, get_package_tabs

@ -13,22 +13,22 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import namedtuple from collections import namedtuple
from flask import render_template, request, redirect, flash, url_for, abort
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext
from . import bp
from flask import *
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, TextAreaField, SubmitField, RadioField
from wtforms.validators import * from wtforms.validators import InputRequired, Length
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \ from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
Permission, AuditSeverity, PackageState Permission, AuditSeverity, PackageState
from app.tasks.webhooktasks import post_discord_webhook
from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, \ from app.utils import is_package_page, addNotification, get_int_or_abort, isYes, is_safe_url, rank_required, \
addAuditLog, has_blocked_domains addAuditLog, has_blocked_domains
from app.tasks.webhooktasks import post_discord_webhook from . import bp
@bp.route("/reviews/") @bp.route("/reviews/")
@ -41,7 +41,7 @@ def list_reviews():
class ReviewForm(FlaskForm): class ReviewForm(FlaskForm):
title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)]) title = StringField(lazy_gettext("Title"), [InputRequired(), Length(3,100)])
comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)]) comment = TextAreaField(lazy_gettext("Comment"), [InputRequired(), Length(10, 2000)])
rating = RadioField(lazy_gettext("Rating"), [InputRequired()], rating = RadioField(lazy_gettext("Rating"), [InputRequired()],
choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))]) choices=[("5", lazy_gettext("Yes")), ("3", lazy_gettext("Neutral")), ("1", lazy_gettext("No"))])
@ -248,5 +248,5 @@ def review_votes(package):
user_biases_info.sort(key=lambda x: -abs(x.balance)) user_biases_info.sort(key=lambda x: -abs(x.balance))
return render_template("packages/review_votes.html", form=form, package=package, reviews=package.reviews, return render_template("packages/review_votes.html", package=package, reviews=package.reviews,
user_biases=user_biases_info) user_biases=user_biases_info)

@ -14,19 +14,19 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import render_template, request, redirect, flash, url_for, abort
from flask import * from flask_babel import lazy_gettext, gettext
from flask_babel import gettext, lazy_gettext from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_login import login_required from wtforms import StringField, SubmitField, BooleanField, FileField
from wtforms import * from wtforms.validators import InputRequired, Length, DataRequired, Optional
from wtforms_sqlalchemy.fields import QuerySelectField from wtforms_sqlalchemy.fields import QuerySelectField
from wtforms.validators import *
from app.utils import *
from . import bp, get_package_tabs
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.logic.screenshots import do_create_screenshot, do_order_screenshots from app.logic.screenshots import do_create_screenshot, do_order_screenshots
from . import bp, get_package_tabs
from app.models import Permission, db, PackageScreenshot
from app.utils import is_package_page
class CreateScreenshotForm(FlaskForm): class CreateScreenshotForm(FlaskForm):
@ -100,23 +100,23 @@ def edit_screenshot(package, id):
if screenshot is None or screenshot.package != package: if screenshot is None or screenshot.package != package:
abort(404) abort(404)
canEdit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS) can_edit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS)
canApprove = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT) can_approve = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT)
if not (canEdit or canApprove): if not (can_edit or can_approve):
return redirect(package.get_url("packages.screenshots")) return redirect(package.get_url("packages.screenshots"))
# Initial form class from post data and default data # Initial form class from post data and default data
form = EditScreenshotForm(obj=screenshot) form = EditScreenshotForm(obj=screenshot)
if form.validate_on_submit(): if form.validate_on_submit():
wasApproved = screenshot.approved was_approved = screenshot.approved
if canEdit: if can_edit:
screenshot.title = form["title"].data or "Untitled" screenshot.title = form["title"].data or "Untitled"
if canApprove: if can_approve:
screenshot.approved = form["approved"].data screenshot.approved = form["approved"].data
else: else:
screenshot.approved = wasApproved screenshot.approved = was_approved
db.session.commit() db.session.commit()
return redirect(package.get_url("packages.screenshots")) return redirect(package.get_url("packages.screenshots"))

@ -14,14 +14,14 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, jsonify, url_for, request, redirect, render_template
from flask import * from flask_login import login_required, current_user
from flask_login import login_required
from app import csrf from app import csrf
from app.models import UserRank
from app.tasks import celery from app.tasks import celery
from app.tasks.importtasks import getMeta from app.tasks.importtasks import getMeta
from app.utils import * from app.utils import shouldReturnJson
bp = Blueprint("tasks", __name__) bp = Blueprint("tasks", __name__)
@ -30,6 +30,7 @@ bp = Blueprint("tasks", __name__)
@bp.route("/tasks/getmeta/new/", methods=["POST"]) @bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required @login_required
def start_getmeta(): def start_getmeta():
from flask import request
author = request.args.get("author") author = request.args.get("author")
author = current_user.forums_username if author is None else author author = current_user.forums_username if author is None else author
aresult = getMeta.delay(request.args.get("url"), author) aresult = getMeta.delay(request.args.get("url"), author)

@ -13,7 +13,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import *
from flask import Blueprint, request, render_template, abort, flash, redirect, url_for
from flask_babel import gettext, lazy_gettext from flask_babel import gettext, lazy_gettext
from app.markdown import get_user_mentions, render_markdown from app.markdown import get_user_mentions, render_markdown
@ -22,11 +23,12 @@ from app.tasks.webhooktasks import post_discord_webhook
bp = Blueprint("threads", __name__) bp = Blueprint("threads", __name__)
from flask_login import current_user, login_required from flask_login import current_user, login_required
from app.models import * from app.models import Package, db, User, Permission, Thread, UserRank, AuditSeverity, \
NotificationType, ThreadReply
from app.utils import addNotification, isYes, addAuditLog, get_system_user, rank_required, has_blocked_domains from app.utils import addNotification, isYes, addAuditLog, get_system_user, rank_required, has_blocked_domains
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import * from wtforms import StringField, TextAreaField, SubmitField, BooleanField
from wtforms.validators import * from wtforms.validators import InputRequired, Length
from app.utils import get_int_or_abort from app.utils import get_int_or_abort
@ -58,8 +60,8 @@ def list_all():
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"]) @bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@login_required @login_required
def subscribe(id): def subscribe(id_):
thread = Thread.query.get(id) thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD): if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404) abort(404)
@ -75,8 +77,8 @@ def subscribe(id):
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"]) @bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@login_required @login_required
def unsubscribe(id): def unsubscribe(id_):
thread = Thread.query.get(id) thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD): if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404) abort(404)
@ -92,8 +94,8 @@ def unsubscribe(id):
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"]) @bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
@login_required @login_required
def set_lock(id): def set_lock(id_):
thread = Thread.query.get(id) thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD): if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD):
abort(404) abort(404)
@ -118,8 +120,8 @@ def set_lock(id):
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
@login_required @login_required
def delete_thread(id): def delete_thread(id_):
thread = Thread.query.get(id) thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD): if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD):
abort(404) abort(404)
@ -141,8 +143,8 @@ def delete_thread(id):
@bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"])
@login_required @login_required
def delete_reply(id): def delete_reply(id_):
thread = Thread.query.get(id) thread = Thread.query.get(id_)
if thread is None: if thread is None:
abort(404) abort(404)
@ -180,8 +182,8 @@ class CommentForm(FlaskForm):
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@login_required @login_required
def edit_reply(id): def edit_reply(id_):
thread = Thread.query.get(id) thread = Thread.query.get(id_)
if thread is None: if thread is None:
abort(404) abort(404)
@ -217,8 +219,8 @@ def edit_reply(id):
@bp.route("/threads/<int:id>/", methods=["GET", "POST"]) @bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id): def view(id_):
thread: Thread = Thread.query.get(id) thread: Thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD): if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404) abort(404)
@ -377,7 +379,6 @@ def new():
return redirect(thread.get_view_url()) return redirect(thread.get_view_url())
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_private_change, package=package)

@ -14,21 +14,22 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import redirect, abort, render_template, flash, request, url_for
from flask import * from flask_babel import gettext, get_locale, lazy_gettext
from flask_babel import gettext, get_locale
from flask_login import current_user, login_required, logout_user, login_user from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from sqlalchemy import or_ from sqlalchemy import or_
from wtforms import * from wtforms import StringField, SubmitField, BooleanField, PasswordField, validators
from wtforms.validators import * from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, Email, EqualTo
from app.models import *
from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email from app.tasks.emails import send_verify_email, send_anon_email, send_unsubscribe_verify, send_user_email
from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \ from app.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
nonEmptyOrNone, post_login, is_username_valid nonEmptyOrNone, post_login, is_username_valid
from . import bp from . import bp
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
@ -45,7 +46,6 @@ def handle_login(form):
else: else:
flash(err, "danger") flash(err, "danger")
username = form.username.data.strip() username = form.username.data.strip()
user = User.query.filter(or_(User.username == username, User.email == username)).first() user = User.query.filter(or_(User.username == username, User.email == username)).first()
if user is None: if user is None:
@ -87,7 +87,6 @@ def login():
if request.method == "GET": if request.method == "GET":
form.remember_me.data = True form.remember_me.data = True
return render_template("users/login.html", form=form) return render_template("users/login.html", form=form)
@ -187,6 +186,7 @@ class ForgotPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()]) email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password")) submit = SubmitField(lazy_gettext("Reset Password"))
@bp.route("/user/forgot-password/", methods=["GET", "POST"]) @bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password(): def forgot_password():
form = ForgotPasswordForm(request.form) form = ForgotPasswordForm(request.form)
@ -222,9 +222,10 @@ class SetPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [Optional(), Email()]) email = StringField(lazy_gettext("Email"), [Optional(), Email()])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)]) password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100), password2 = PasswordField(lazy_gettext("Verify password"), [InputRequired(), Length(8, 100),
validators.EqualTo('password', message=lazy_gettext('Passwords must match'))]) EqualTo('password', message=lazy_gettext('Passwords must match'))])
submit = SubmitField(lazy_gettext("Save")) submit = SubmitField(lazy_gettext("Save"))
class ChangePasswordForm(FlaskForm): class ChangePasswordForm(FlaskForm):
old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)]) old_password = PasswordField(lazy_gettext("Old password"), [InputRequired(), Length(8, 100)])
password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)]) password = PasswordField(lazy_gettext("New password"), [InputRequired(), Length(8, 100)])
@ -245,8 +246,8 @@ def handle_set_password(form):
current_user.password = make_flask_login_password(form.password.data) current_user.password = make_flask_login_password(form.password.data)
if hasattr(form, "email"): if hasattr(form, "email"):
newEmail = nonEmptyOrNone(form.email.data) new_email = nonEmptyOrNone(form.email.data)
if newEmail and newEmail != current_user.email: if new_email and new_email != current_user.email:
if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0: if EmailSubscription.query.filter_by(email=form.email.data, blacklisted=True).count() > 0:
flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger") flash(gettext(u"That email address has been unsubscribed/blacklisted, and cannot be used"), "danger")
return return
@ -262,7 +263,7 @@ def handle_set_password(form):
ver = UserEmailVerification() ver = UserEmailVerification()
ver.user = current_user ver.user = current_user
ver.token = token ver.token = token
ver.email = newEmail ver.email = new_email
db.session.add(ver) db.session.add(ver)
db.session.commit() db.session.commit()

@ -13,6 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask_babel import gettext from flask_babel import gettext
from . import bp from . import bp
@ -58,7 +59,7 @@ def claim_forums():
session["forum_token"] = token session["forum_token"] = token
if request.method == "POST": if request.method == "POST":
ctype = request.form.get("claim_type") ctype = request.form.get("claim_type")
username = request.form.get("username") username = request.form.get("username")
if not is_username_valid(username): if not is_username_valid(username):

@ -15,16 +15,16 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import math import math
from typing import Optional from typing import Optional, Tuple, List
from flask import * from flask import redirect, url_for, abort, render_template, request
from flask_babel import gettext from flask_babel import gettext
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy import func from sqlalchemy import func, text
from app.models import * from app.models import User, db, Package, PackageReview, PackageState, PackageType
from app.utils import get_daterange_options
from . import bp from . import bp
from ...utils import get_daterange_options
@bp.route("/users/", methods=["GET"]) @bp.route("/users/", methods=["GET"])

@ -1,12 +1,29 @@
from flask import * # ContentDB
from flask_babel import gettext, get_locale # Copyright (C) 2023 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import redirect, abort, render_template, request, flash, url_for
from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user from flask_login import current_user, login_required, logout_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from sqlalchemy import or_ from sqlalchemy import or_
from wtforms import * from wtforms import StringField, SubmitField, BooleanField, SelectField
from wtforms.validators import * from wtforms.validators import Length, Optional, Email, URL
from app.models import * from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification, Permission, NotificationType, UserBan
from app.tasks.emails import send_verify_email from app.tasks.emails import send_verify_email
from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required, has_blocked_domains from app.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required, has_blocked_domains
from . import bp from . import bp
@ -309,9 +326,9 @@ def modtools(username):
user.github_username = nonEmptyOrNone(form.github_username.data) user.github_username = nonEmptyOrNone(form.github_username.data)
if user.check_perm(current_user, Permission.CHANGE_RANK): if user.check_perm(current_user, Permission.CHANGE_RANK):
newRank = form["rank"].data new_rank = form["rank"].data
if current_user.rank.atLeast(newRank): if current_user.rank.atLeast(new_rank):
if newRank != user.rank: if new_rank != user.rank:
user.rank = form["rank"].data user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title()) msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title())
addAuditLog(AuditSeverity.MODERATION, current_user, msg, addAuditLog(AuditSeverity.MODERATION, current_user, msg,

@ -16,7 +16,8 @@
from celery import uuid from celery import uuid
from flask import Blueprint, render_template, redirect, request, abort from flask import Blueprint, render_template, redirect, request, abort, url_for
from flask_babel import lazy_gettext
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField, SubmitField from wtforms import StringField, BooleanField, SubmitField
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length
@ -26,7 +27,7 @@ from app.utils import rank_required
bp = Blueprint("zipgrep", __name__) bp = Blueprint("zipgrep", __name__)
from app.models import * from app.models import UserRank, Package
from app.tasks.zipgrep import search_in_releases from app.tasks.zipgrep import search_in_releases
@ -51,14 +52,14 @@ def zipgrep_search():
@bp.route("/zipgrep/<id>/") @bp.route("/zipgrep/<id>/")
def view_results(id): def view_results(id_):
result = celery.AsyncResult(id) result = celery.AsyncResult(id_)
if result.status == "PENDING": if result.status == "PENDING":
abort(404) abort(404)
if result.status != "SUCCESS" or isinstance(result.result, Exception): if result.status != "SUCCESS" or isinstance(result.result, Exception):
result_url = url_for("zipgrep.view_results", id=id) result_url = url_for("zipgrep.view_results", id=id_)
return redirect(url_for("tasks.check", id=id, r=result_url)) return redirect(url_for("tasks.check", id=id_, r=result_url))
matches = result.result["matches"] matches = result.result["matches"]
for match in matches: for match in matches:

@ -1,4 +1,23 @@
from .models import * # ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from .models import User, UserRank, MinetestRelease, Tag, License, Notification, NotificationType, Package, \
PackageState, PackageType, PackageRelease, MetaPackage, Dependency
from .utils import make_flask_login_password from .utils import make_flask_login_password
@ -68,7 +87,6 @@ def populate_test_data(session):
jeija.forums_username = "Jeija" jeija.forums_username = "Jeija"
session.add(jeija) session.add(jeija)
mod = Package() mod = Package()
mod.state = PackageState.APPROVED mod.state = PackageState.APPROVED
mod.name = "alpha" mod.name = "alpha"

@ -30,3 +30,10 @@ and texture packs for Minetest**.
You should read You should read
[the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/) [the official Minetest Modding Book](https://rubenwardy.com/minetest_modding_book/)
for a guide to making mods and games using Minetest. for a guide to making mods and games using Minetest.
## How can I support / donate to ContentDB?
You can donate to rubenwardy to cover ContentDB's costs and support future
development.
<a href="https://rubenwardy.com/donate/" class="btn btn-primary mr-1">Donate</a>

@ -334,7 +334,7 @@ curl -X POST https://content.minetest.net/api/packages/username/name/screenshots
* `author`: filter by review author username * `author`: filter by review author username
* `rating`: 1 for negative, 3 for neutral, 5 for positive * `rating`: 1 for negative, 3 for neutral, 5 for positive
* `is_positive`: true or false. Default: null * `is_positive`: true or false. Default: null
* `q`: filter by title (case insensitive, no fulltext search) * `q`: filter by title (case-insensitive, no fulltext search)
Example: Example:

@ -1,3 +1,19 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime import datetime
from datetime import timedelta from datetime import timedelta
from typing import Optional from typing import Optional
@ -15,7 +31,7 @@ keys = ["platform_minetest", "platform_other", "reason_new",
"reason_dependency", "reason_update"] "reason_dependency", "reason_update"]
def _flatten_data(stats): def flatten_data(stats):
start_date = stats[0].date start_date = stats[0].date
end_date = stats[-1].date end_date = stats[-1].date
result = { result = {
@ -52,7 +68,7 @@ def get_package_stats(package: Package, start_date: Optional[datetime.date], end
if len(stats) == 0: if len(stats) == 0:
return None return None
return _flatten_data(stats) return flatten_data(stats)
def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], end_date: Optional[datetime.date]): def get_package_stats_for_user(user: User, start_date: Optional[datetime.date], end_date: Optional[datetime.date]):
@ -76,7 +92,7 @@ def get_package_stats_for_user(user: User, start_date: Optional[datetime.date],
if len(stats) == 0: if len(stats) == 0:
return None return None
results = _flatten_data(stats) results = flatten_data(stats)
results["package_downloads"] = get_package_overview_for_user(user, stats[0].date, stats[-1].date) results["package_downloads"] = get_package_overview_for_user(user, stats[0].date, stats[-1].date)
return results return results

@ -1,3 +1,19 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import namedtuple from collections import namedtuple
from typing import List from typing import List
@ -15,11 +31,11 @@ def validate_package_for_approval(package: Package) -> List[ValidationError]:
normalised_name = package.getNormalisedName() normalised_name = package.getNormalisedName()
if package.type != PackageType.MOD and Package.query.filter( if package.type != PackageType.MOD and Package.query.filter(
and_(Package.state == PackageState.APPROVED, and_(Package.state == PackageState.APPROVED,
or_(Package.name == normalised_name, or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0: Package.name == normalised_name + "_game"))).count() > 0:
retval.append(("danger", lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3"))) retval.append(("danger", lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3")))
if package.releases.filter(PackageRelease.task_id==None).count() == 0: if package.releases.filter(PackageRelease.task_id == None).count() == 0:
retval.append(("danger", lazy_gettext("A release is required before this package can be approved."))) retval.append(("danger", lazy_gettext("A release is required before this package can be approved.")))
# Don't bother validating any more until we have a release # Don't bother validating any more until we have a release
return retval return retval

@ -14,8 +14,8 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import datetime, re import re
from celery import uuid from celery import uuid
from flask_babel import lazy_gettext from flask_babel import lazy_gettext

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

@ -14,44 +14,47 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import imghdr import imghdr
import os import os
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
from app import app
from app.logic.LogicError import LogicError from app.logic.LogicError import LogicError
from app.models import *
from app.utils import randomString from app.utils import randomString
def get_extension(filename): def get_extension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None return filename.rsplit(".", 1)[1].lower() if "." in filename else None
ALLOWED_IMAGES = {"jpeg", "png"} ALLOWED_IMAGES = {"jpeg", "png"}
def isAllowedImage(data):
def is_allowed_image(data):
return imghdr.what(None, data) in ALLOWED_IMAGES return imghdr.what(None, data) in ALLOWED_IMAGES
def upload_file(file, fileType, fileTypeDesc):
def upload_file(file, file_type, file_type_desc):
if not file or file is None or file.filename == "": if not file or file is None or file.filename == "":
raise LogicError(400, "Expected file") raise LogicError(400, "Expected file")
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist" assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
isImage = False is_image = False
if fileType == "image": if file_type == "image":
allowedExtensions = ["jpg", "jpeg", "png"] allowed_extensions = ["jpg", "jpeg", "png"]
isImage = True is_image = True
elif fileType == "zip": elif file_type == "zip":
allowedExtensions = ["zip"] allowed_extensions = ["zip"]
else: else:
raise Exception("Invalid fileType") raise Exception("Invalid fileType")
ext = get_extension(file.filename) ext = get_extension(file.filename)
if ext is None or not ext in allowedExtensions: if ext is None or ext not in allowed_extensions:
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc)) raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=file_type_desc))
if isImage and not isAllowedImage(file.stream.read()): if is_image and not is_allowed_image(file.stream.read()):
raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image")) raise LogicError(400, lazy_gettext("Uploaded image isn't actually an image"))
file.stream.seek(0) file.stream.seek(0)

@ -1,3 +1,19 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging import logging
from app.tasks.emails import send_user_email from app.tasks.emails import send_user_email
@ -9,6 +25,7 @@ def _has_newline(line):
return True return True
return False return False
def _is_bad_subject(subject): def _is_bad_subject(subject):
"""Copied from: flask_mail.py class Message def has_bad_headers""" """Copied from: flask_mail.py class Message def has_bad_headers"""
if _has_newline(subject): if _has_newline(subject):
@ -32,9 +49,11 @@ class FlaskMailSubjectFormatter(logging.Formatter):
s = self.formatMessage(record) s = self.formatMessage(record)
return s return s
class FlaskMailTextFormatter(logging.Formatter): class FlaskMailTextFormatter(logging.Formatter):
pass pass
class FlaskMailHTMLFormatter(logging.Formatter): class FlaskMailHTMLFormatter(logging.Formatter):
def formatException(self, exc_info): def formatException(self, exc_info):
formatted_exception = logging.Handler.formatException(self, exc_info) formatted_exception = logging.Handler.formatException(self, exc_info)

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

@ -47,7 +47,7 @@ class APIToken(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens") package = db.relationship("Package", foreign_keys=[package_id], back_populates="tokens")
def canOperateOnPackage(self, package): def can_operate_on_package(self, package):
if self.package and self.package != package: if self.package and self.package != package:
return False return False
@ -146,7 +146,7 @@ class ForumTopic(db.Model):
created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
def getRepoURL(self): def get_repo_url(self):
if self.link is None: if self.link is None:
return None return None

@ -27,7 +27,7 @@ from sqlalchemy.dialects.postgresql import insert
from . import db from . import db
from .users import Permission, UserRank, User from .users import Permission, UserRank, User
from .. import app from app import app
class PackageQuery(BaseQuery, SearchQueryMixin): class PackageQuery(BaseQuery, SearchQueryMixin):

@ -68,7 +68,7 @@ class Thread(db.Model):
def get_view_url(self, absolute=False): def get_view_url(self, absolute=False):
if absolute: if absolute:
from ..utils import abs_url_for from app.utils import abs_url_for
return abs_url_for("threads.view", id=self.id) return abs_url_for("threads.view", id=self.id)
else: else:
return url_for("threads.view", id=self.id, _external=False) return url_for("threads.view", id=self.id, _external=False)

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime import datetime
import enum import enum
@ -351,7 +350,7 @@ class EmailSubscription(db.Model):
@property @property
def url(self): def url(self):
from ..utils import abs_url_for from app.utils import abs_url_for
return abs_url_for('users.unsubscribe', token=self.token) return abs_url_for('users.unsubscribe', token=self.token)

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

@ -1,3 +1,19 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from . import r from . import r
# This file acts as a facade between the releases code and redis, # This file acts as a facade between the releases code and redis,

@ -25,6 +25,7 @@ from app import app
class TaskError(Exception): class TaskError(Exception):
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
def __str__(self): def __str__(self):
return repr("TaskError: " + self.value) return repr("TaskError: " + self.value)

@ -14,14 +14,16 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import re
import sys
import urllib.request
from urllib.parse import urljoin
import json, re, sys from app.models import User, db, PackageType, ForumTopic
from app.models import *
from app.tasks import celery from app.tasks import celery
from app.utils import is_username_valid from app.utils import is_username_valid
from app.utils.phpbbparser import getProfile, getTopicsFromForum from app.utils.phpbbparser import getProfile, getTopicsFromForum
import urllib.request
from urllib.parse import urljoin
from .usertasks import set_profile_picture_from_url from .usertasks import set_profile_picture_from_url
@ -84,6 +86,8 @@ def checkAllForumAccounts():
regex_tag = re.compile(r"\[([a-z0-9_]+)\]") regex_tag = re.compile(r"\[([a-z0-9_]+)\]")
BANNED_NAMES = ["mod", "game", "old", "outdated", "wip", "api", "beta", "alpha", "git"] BANNED_NAMES = ["mod", "game", "old", "outdated", "wip", "api", "beta", "alpha", "git"]
def getNameFromTaglist(taglist): def getNameFromTaglist(taglist):
for tag in reversed(regex_tag.findall(taglist)): for tag in reversed(regex_tag.findall(taglist)):
if len(tag) < 30 and not tag in BANNED_NAMES and \ if len(tag) < 30 and not tag in BANNED_NAMES and \
@ -92,7 +96,10 @@ def getNameFromTaglist(taglist):
return None return None
regex_title = re.compile(r"^((?:\[[^\]]+\] *)*)([^\[]+) *((?:\[[^\]]+\] *)*)[^\[]*$") regex_title = re.compile(r"^((?:\[[^\]]+\] *)*)([^\[]+) *((?:\[[^\]]+\] *)*)[^\[]*$")
def parseTitle(title): def parseTitle(title):
m = regex_title.match(title) m = regex_title.match(title)
if m is None: if m is None:

@ -13,27 +13,31 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from json import JSONDecodeError
import gitdb import datetime
import json import json
import os import os
import shutil import shutil
from json import JSONDecodeError
from zipfile import ZipFile from zipfile import ZipFile
import gitdb
from flask import url_for
from git import GitCommandError from git import GitCommandError
from git_archive_all import GitArchiver from git_archive_all import GitArchiver
from kombu import uuid from kombu import uuid
from app.models import * from app.models import AuditSeverity, db, NotificationType, PackageRelease, MetaPackage, Dependency, PackageType, \
MinetestRelease, Package, PackageState, PackageScreenshot, PackageUpdateTrigger, PackageUpdateConfig
from app.tasks import celery, TaskError from app.tasks import celery, TaskError
from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_games_from_csv from app.utils import randomString, post_bot_message, addSystemNotification, addSystemAuditLog, get_games_from_csv
from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir from app.utils.git import clone_repo, get_latest_tag, get_latest_commit, get_temp_dir
from .minetestcheck import build_tree, MinetestCheckError, ContentType from .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError from app import app
from ..logic.game_support import GameSupportResolver from app.logic.LogicError import LogicError
from ..logic.packages import do_edit_package, ALIASES from app.logic.game_support import GameSupportResolver
from ..utils.image import get_image_size from app.logic.packages import do_edit_package, ALIASES
from app.utils.image import get_image_size
@celery.task() @celery.task()
@ -51,7 +55,7 @@ def getMeta(urlstr, author):
result["forums"] = result.get("forumId") result["forums"] = result.get("forumId")
readme_path = tree.getReadMePath() readme_path = tree.get_readme_path()
if readme_path: if readme_path:
with open(readme_path, "r") as f: with open(readme_path, "r") as f:
result["long_description"] = f.read() result["long_description"] = f.read()
@ -96,11 +100,11 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
def getMetaPackages(names): def getMetaPackages(names):
return [ MetaPackage.GetOrCreate(x, cache) for x in names ] return [ MetaPackage.GetOrCreate(x, cache) for x in names ]
provides = tree.getModNames() provides = tree.get_mod_names()
package = release.package package = release.package
package.provides.clear() package.provides.clear()
package.provides.extend(getMetaPackages(tree.getModNames())) package.provides.extend(getMetaPackages(tree.get_mod_names()))
# Delete all mod name dependencies # Delete all mod name dependencies
package.dependencies.filter(Dependency.meta_package != None).delete() package.dependencies.filter(Dependency.meta_package != None).delete()

@ -1,11 +1,30 @@
# ContentDB
# Copyright (C) 2018-23 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from enum import Enum from enum import Enum
class MinetestCheckError(Exception): class MinetestCheckError(Exception):
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
def __str__(self): def __str__(self):
return repr("Error validating package: " + self.value) return repr("Error validating package: " + self.value)
class ContentType(Enum): class ContentType(Enum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
MOD = "mod" MOD = "mod"
@ -13,7 +32,7 @@ class ContentType(Enum):
GAME = "game" GAME = "game"
TXP = "texture pack" TXP = "texture pack"
def isModLike(self): def is_mod_like(self):
return self == ContentType.MOD or self == ContentType.MODPACK return self == ContentType.MOD or self == ContentType.MODPACK
def validate_same(self, other): def validate_same(self, other):
@ -23,7 +42,7 @@ class ContentType(Enum):
assert other assert other
if self == ContentType.MOD: if self == ContentType.MOD:
if not other.isModLike(): if not other.is_mod_like():
raise MinetestCheckError("Expected a mod or modpack, found " + other.value) raise MinetestCheckError("Expected a mod or modpack, found " + other.value)
elif self == ContentType.TXP: elif self == ContentType.TXP:
@ -36,6 +55,7 @@ class ContentType(Enum):
from .tree import PackageTreeNode, get_base_dir from .tree import PackageTreeNode, get_base_dir
def build_tree(path, expected_type=None, author=None, repo=None, name=None): def build_tree(path, expected_type=None, author=None, repo=None, name=None):
path = get_base_dir(path) path = get_base_dir(path)

@ -1,3 +1,20 @@
# ContentDB
# Copyright (C) Lars Mueller
#
# 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/>.
def parse_conf(string): def parse_conf(string):
retval = {} retval = {}
lines = string.splitlines() lines = string.splitlines()

@ -1,9 +1,29 @@
import os, re # ContentDB
# Copyright (C) 2018-21 rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
import re
from . import MinetestCheckError, ContentType from . import MinetestCheckError, ContentType
from .config import parse_conf from .config import parse_conf
basenamePattern = re.compile("^([a-z0-9_]+)$") basenamePattern = re.compile("^([a-z0-9_]+)$")
def get_base_dir(path): def get_base_dir(path):
if not os.path.isdir(path): if not os.path.isdir(path):
raise IOError("Expected dir") raise IOError("Expected dir")
@ -39,8 +59,8 @@ def get_csv_line(line):
class PackageTreeNode: class PackageTreeNode:
def __init__(self, baseDir, relative, author=None, repo=None, name=None): def __init__(self, base_dir, relative, author=None, repo=None, name=None):
self.baseDir = baseDir self.baseDir = base_dir
self.relative = relative self.relative = relative
self.author = author self.author = author
self.name = name self.name = name
@ -49,11 +69,11 @@ class PackageTreeNode:
self.children = [] self.children = []
# Detect type # Detect type
self.type = detect_type(baseDir) self.type = detect_type(base_dir)
self.read_meta() self.read_meta()
if self.type == ContentType.GAME: if self.type == ContentType.GAME:
if not os.path.isdir(baseDir + "/mods"): if not os.path.isdir(base_dir + "/mods"):
raise MinetestCheckError("Game at {} does not have a mods/ folder".format(self.relative)) raise MinetestCheckError("Game at {} does not have a mods/ folder".format(self.relative))
self.add_children_from_mod_dir("mods") self.add_children_from_mod_dir("mods")
elif self.type == ContentType.MOD: elif self.type == ContentType.MOD:
@ -69,13 +89,13 @@ class PackageTreeNode:
if lowercase != dir and lowercase in dirs: if lowercase != dir and lowercase in dirs:
raise MinetestCheckError(f"Incorrect case, {dir} should be {lowercase} at {self.relative}{dir}") raise MinetestCheckError(f"Incorrect case, {dir} should be {lowercase} at {self.relative}{dir}")
def getReadMePath(self): def get_readme_path(self):
for filename in os.listdir(self.baseDir): for filename in os.listdir(self.baseDir):
path = os.path.join(self.baseDir, filename) path = os.path.join(self.baseDir, filename)
if os.path.isfile(path) and filename.lower().startswith("readme."): if os.path.isfile(path) and filename.lower().startswith("readme."):
return path return path
def getMetaFileName(self): def get_meta_file_name(self):
if self.type == ContentType.GAME: if self.type == ContentType.GAME:
return "game.conf" return "game.conf"
elif self.type == ContentType.MOD: elif self.type == ContentType.MOD:
@ -91,13 +111,13 @@ class PackageTreeNode:
result = {} result = {}
# Read .conf file # Read .conf file
meta_file_name = self.getMetaFileName() meta_file_name = self.get_meta_file_name()
if meta_file_name is not None: if meta_file_name is not None:
meta_file_rel = self.relative + meta_file_name meta_file_rel = self.relative + meta_file_name
meta_file_path = self.baseDir + "/" + meta_file_name meta_file_path = self.baseDir + "/" + meta_file_name
try: try:
with open(meta_file_path or "", "r") as myfile: with open(meta_file_path or "", "r") as f:
conf = parse_conf(myfile.read()) conf = parse_conf(f.read())
for key, value in conf.items(): for key, value in conf.items():
result[key] = value result[key] = value
except SyntaxError as e: except SyntaxError as e:
@ -108,12 +128,11 @@ class PackageTreeNode:
if "release" in result: if "release" in result:
raise MinetestCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel)) raise MinetestCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel))
# description.txt # description.txt
if not "description" in result: if "description" not in result:
try: try:
with open(self.baseDir + "/description.txt", "r") as myfile: with open(self.baseDir + "/description.txt", "r") as f:
result["description"] = myfile.read() result["description"] = f.read()
except IOError: except IOError:
pass pass
@ -123,10 +142,10 @@ class PackageTreeNode:
result["optional_depends"] = get_csv_line(result.get("optional_depends")) result["optional_depends"] = get_csv_line(result.get("optional_depends"))
elif os.path.isfile(self.baseDir + "/depends.txt"): elif os.path.isfile(self.baseDir + "/depends.txt"):
pattern = re.compile("^([a-z0-9_]+)\??$") pattern = re.compile(r"^([a-z0-9_]+)\??$")
with open(self.baseDir + "/depends.txt", "r") as myfile: with open(self.baseDir + "/depends.txt", "r") as f:
contents = myfile.read() contents = f.read()
soft = [] soft = []
hard = [] hard = []
for line in contents.split("\n"): for line in contents.split("\n"):
@ -144,8 +163,7 @@ class PackageTreeNode:
result["depends"] = [] result["depends"] = []
result["optional_depends"] = [] result["optional_depends"] = []
def check_dependencies(deps):
def checkDependencies(deps):
for dep in deps: for dep in deps:
if not basenamePattern.match(dep): if not basenamePattern.match(dep):
if " " in dep: if " " in dep:
@ -157,8 +175,8 @@ class PackageTreeNode:
.format(dep, self.relative)) .format(dep, self.relative))
# Check dependencies # Check dependencies
checkDependencies(result["depends"]) check_dependencies(result["depends"])
checkDependencies(result["optional_depends"]) check_dependencies(result["optional_depends"])
# Fix games using "name" as "title" # Fix games using "name" as "title"
if self.type == ContentType.GAME and "name" in result: if self.type == ContentType.GAME and "name" in result:
@ -193,7 +211,7 @@ class PackageTreeNode:
path = os.path.join(dir, entry) path = os.path.join(dir, entry)
if not entry.startswith('.') and os.path.isdir(path): if not entry.startswith('.') and os.path.isdir(path):
child = PackageTreeNode(path, relative + entry + "/", name=entry) child = PackageTreeNode(path, relative + entry + "/", name=entry)
if not child.type.isModLike(): if not child.type.is_mod_like():
raise MinetestCheckError("Expecting mod or modpack, found {} at {} inside {}" \ raise MinetestCheckError("Expecting mod or modpack, found {} at {} inside {}" \
.format(child.type.value, child.relative, self.type.value)) .format(child.type.value, child.relative, self.type.value))
@ -202,7 +220,7 @@ class PackageTreeNode:
self.children.append(child) self.children.append(child)
def getModNames(self): def get_mod_names(self):
return self.fold("name", type_=ContentType.MOD) return self.fold("name", type_=ContentType.MOD)
# attr: Attribute name # attr: Attribute name

@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys
from typing import Optional from typing import Optional
import requests import requests
@ -22,6 +22,7 @@ from app import app
from app.models import User from app.models import User
from app.tasks import celery from app.tasks import celery
@celery.task() @celery.task()
def post_discord_webhook(username: Optional[str], content: str, is_queue: bool, title: Optional[str] = None, description: Optional[str] = None, thumbnail: Optional[str] = None): def post_discord_webhook(username: Optional[str], content: str, is_queue: bool, title: Optional[str] = None, description: Optional[str] = None, thumbnail: Optional[str] = None):
discord_url = app.config.get("DISCORD_WEBHOOK_QUEUE" if is_queue else "DISCORD_WEBHOOK_FEED") discord_url = app.config.get("DISCORD_WEBHOOK_QUEUE" if is_queue else "DISCORD_WEBHOOK_FEED")

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import subprocess import subprocess
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
from typing import Optional from typing import Optional

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

@ -1,3 +1,20 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from app.default_data import populate_test_data from app.default_data import populate_test_data
from app.models import db, Package, PackageState from app.models import db, Package, PackageState
from .utils import parse_json, validate_package_list from .utils import parse_json, validate_package_list
@ -14,7 +31,6 @@ def test_packages_empty(client):
def test_packages_with_contents(client): def test_packages_with_contents(client):
"""Start with a test database.""" """Start with a test database."""
populate_test_data(db.session) populate_test_data(db.session)
db.session.commit() db.session.commit()

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

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

@ -1,3 +1,19 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import pytest, json import pytest, json
from sqlalchemy import text from sqlalchemy import text
@ -19,18 +35,23 @@ def recreate_db():
populate(db.session) populate(db.session)
db.session.commit() db.session.commit()
def parse_json(b): def parse_json(b):
return json.loads(b.decode("utf8")) return json.loads(b.decode("utf8"))
def is_type(t, v): def is_type(t, v):
return v and isinstance(v, t) return v and isinstance(v, t)
def is_optional(t, v): def is_optional(t, v):
return not v or isinstance(v, t) return not v or isinstance(v, t)
def is_str(v): def is_str(v):
return is_type(str, v) return is_type(str, v)
def is_int(v): def is_int(v):
return is_type(int, v) return is_type(int, v)

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

@ -1,6 +1,22 @@
# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime import datetime
from app.logic.graphs import _flatten_data from app.logic.graphs import flatten_data
class DailyStat: class DailyStat:
@ -21,7 +37,7 @@ class DailyStat:
def test_flatten_data(): def test_flatten_data():
res = _flatten_data([ res = flatten_data([
DailyStat("2022-03-28", 3), DailyStat("2022-03-28", 3),
DailyStat("2022-03-29", 10), DailyStat("2022-03-29", 10),
DailyStat("2022-04-01", 5), DailyStat("2022-04-01", 5),

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

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

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

@ -18,14 +18,12 @@ import re
import secrets import secrets
from typing import Dict from typing import Dict
import typing
import deep_compare import deep_compare
from flask import current_app
from .flask import * from .flask import *
from .models import * from .models import *
from .user import * from .user import *
from flask import current_app
YESES = ["yes", "true", "1", "on"] YESES = ["yes", "true", "1", "on"]

@ -14,15 +14,16 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import typing import typing
from urllib.parse import urljoin, urlparse, urlunparse from urllib.parse import urljoin, urlparse, urlunparse
import user_agents import user_agents
from flask import request, abort from flask import request, abort, url_for
from flask_babel import LazyString from flask_babel import LazyString, lazy_gettext
from werkzeug.datastructures import MultiDict from werkzeug.datastructures import MultiDict
from app.models import * from app import app
def is_safe_url(target): def is_safe_url(target):
@ -136,7 +137,7 @@ def get_request_date(key: str) -> typing.Optional[datetime.date]:
abort(400) abort(400)
def get_daterange_options() -> List[Tuple[LazyString, str]]: def get_daterange_options() -> typing.List[typing.Tuple[LazyString, str]]:
now = datetime.datetime.utcnow().date() now = datetime.datetime.utcnow().date()
days7 = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).date() days7 = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).date()
days30 = (datetime.datetime.utcnow() - datetime.timedelta(days=30)).date() days30 = (datetime.datetime.utcnow() - datetime.timedelta(days=30)).date()

@ -15,8 +15,14 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import contextlib, git, gitdb, os, shutil, tempfile import contextlib
import git
import gitdb
import os
import shutil
import tempfile
from urllib.parse import urlsplit from urllib.parse import urlsplit
from git import GitCommandError from git import GitCommandError
from app.tasks import TaskError from app.tasks import TaskError

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

@ -8,7 +8,8 @@ import urllib.parse as urlparse
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from bs4 import *
from bs4 import BeautifulSoup
def urlEncodeNonAscii(b): def urlEncodeNonAscii(b):

@ -22,8 +22,8 @@ from flask_login import login_user, current_user
from passlib.handlers.bcrypt import bcrypt from passlib.handlers.bcrypt import bcrypt
from flask import redirect, url_for, abort, flash from flask import redirect, url_for, abort, flash
from app.models import User, UserRank, UserNotificationPreferences, db
from app.utils import is_safe_url from app.utils import is_safe_url
from app.models import User, UserRank, UserNotificationPreferences, db
def check_password_hash(stored, given): def check_password_hash(stored, given):