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/>.
import datetime
import os
import redis
from flask import *
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 import redirect, url_for, render_template, flash, request, Flask, send_from_directory, make_response
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
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
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):
dir = os.path.dirname(os.path.realpath(__file__))

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

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

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

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

@ -23,13 +23,14 @@ from wtforms.validators import InputRequired, Length
from app.utils import rank_required, addAuditLog
from . import bp
from ...models import UserRank, MinetestRelease, db, AuditSeverity
from app.models import UserRank, MinetestRelease, db, AuditSeverity
@bp.route("/versions/")
@rank_required(UserRank.MODERATOR)
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):

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

@ -35,7 +35,7 @@ from . import bp
from .auth import is_api_authd
from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, \
api_order_screenshots, api_edit_package, api_set_cover_image
from ...utils.minetest_hypertext import html_to_minetest
from app.utils.minetest_hypertext import html_to_minetest
def cors_allowed(f):
@ -65,15 +65,15 @@ def cached(max_age: int):
@cors_allowed
@cached(300)
def packages():
qb = QueryBuilder(request.args)
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
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())
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
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>/")
@is_package_page
@cors_allowed
def package(package):
def package_view(package):
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):
id = package.get_id()
if id in out:
id_ = package.get_id()
if id_ in out:
return
ret = []
out[id] = ret
out[id_] = ret
if package.type != PackageType.MOD:
return
@ -173,8 +173,8 @@ def package_dependencies(package):
@bp.route("/api/topics/")
@cors_allowed
def topics():
qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True)
qb = QueryBuilder(request.args)
query = qb.buildTopicQuery(show_added=True)
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>/")
@is_package_page
@cors_allowed
def release(package: Package, id: int):
release = PackageRelease.query.get(id)
def release_view(package: Package, id_: int):
release = PackageRelease.query.get(id_)
if release is None or release.package != package:
error(404, "Release not found")
@ -298,15 +298,15 @@ def release(package: Package, id: int):
@is_package_page
@is_api_authd
@cors_allowed
def delete_release(token: APIToken, package: Package, id: int):
release = PackageRelease.query.get(id)
def delete_release(token: APIToken, package: Package, id_: int):
release = PackageRelease.query.get(id_)
if release is None or release.package != package:
error(404, "Release not found")
if not token:
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")
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>/")
@is_package_page
@cors_allowed
def screenshot(package, id):
ss = PackageScreenshot.query.get(id)
def screenshot(package, id_):
ss = PackageScreenshot.query.get(id_)
if ss is None or ss.package != package:
error(404, "Screenshot not found")
@ -365,8 +365,8 @@ def screenshot(package, id):
@is_package_page
@is_api_authd
@cors_allowed
def delete_screenshot(token: APIToken, package: Package, id: int):
ss = PackageScreenshot.query.get(id)
def delete_screenshot(token: APIToken, package: Package, id_: int):
ss = PackageScreenshot.query.get(id_)
if ss is None or ss.package != package:
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):
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")
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):
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")
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):
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")
json = request.json
@ -498,7 +498,7 @@ def all_package_stats():
@cors_allowed
@cached(300)
def package_scores():
qb = QueryBuilder(request.args)
qb = QueryBuilder(request.args)
query = qb.buildPackageQuery()
pkgs = [package.as_score_dict() for package in query.all()]
@ -520,19 +520,19 @@ def content_warnings():
@bp.route("/api/licenses/")
@cors_allowed
def licenses():
return jsonify([ { "name": license.name, "is_foss": license.is_foss } \
for license in License.query.order_by(db.asc(License.name)).all() ])
all_licenses = 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/")
@cors_allowed
def homepage():
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
spotlight = query.filter(Package.tags.any(name="spotlight")).order_by(
func.random()).limit(6).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
new = query.order_by(db.desc(Package.approved_at)).limit(4).all()
pop_mod = query.filter_by(type=PackageType.MOD).order_by(db.desc(Package.score)).limit(8).all()
pop_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()
@ -548,19 +548,19 @@ def homepage():
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]
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 jsonify({
"count": count,
"downloads": downloads,
"spotlight": mapPackages(spotlight),
"new": mapPackages(new),
"updated": mapPackages(updated),
"pop_mod": mapPackages(pop_mod),
"pop_txp": mapPackages(pop_txp),
"pop_game": mapPackages(pop_gam),
"high_reviewed": mapPackages(high_reviewed)
"spotlight": map_packages(spotlight),
"new": map_packages(new),
"updated": map_packages(updated),
"pop_mod": map_packages(pop_mod),
"pop_txp": map_packages(pop_txp),
"pop_game": map_packages(pop_gam),
"high_reviewed": map_packages(high_reviewed)
})

@ -26,6 +26,7 @@ from app.models import APIToken, Package, MinetestRelease, PackageScreenshot
def error(code: int, msg: str):
abort(make_response(jsonify({ "success": False, "error": msg }), code))
# Catches LogicErrors and aborts with JSON error
def guard(f):
def ret(*args, **kwargs):
@ -39,7 +40,7 @@ def guard(f):
def api_create_vcs_release(token: APIToken, package: Package, title: str, ref: str,
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")
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,
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash:str=None):
if not token.canOperateOnPackage(package):
min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason="API", commit_hash: str = None):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
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"):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
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]):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
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):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
guard(do_set_cover_image)(token.owner, package, cover_image)
@ -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"):
if not token.canOperateOnPackage(package):
if not token.can_operate_on_package(package):
error(403, "API token does not have access to the package")
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_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, SubmitField
from wtforms.validators import InputRequired, Length
from wtforms_sqlalchemy.fields import QuerySelectField
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_login import current_user
from sqlalchemy import or_, and_

@ -65,7 +65,7 @@ def webhook_impl():
title = ref.replace("refs/tags/", "")
else:
return error(400, "Unsupported event: '{}'. Only 'push', 'create:tag', and 'ping' are supported."
.format(event or "null"))
.format(event or "null"))
#
# 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 app.models import Package, PackageReview, Thread, User, PackageState, db, PackageType, PackageRelease, Tags, Tag
bp = Blueprint("homepage", __name__)
from app.models import *
from sqlalchemy.orm import joinedload, subqueryload
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).load_only(Package.title, Package.name).subqueryload(Package.main_screenshot))
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
query = Package.query.filter_by(state=PackageState.APPROVED)
count = query.count()
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_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()
@ -58,4 +75,5 @@ def home():
.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,
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__)
def generate_metrics(full=False):
def write_single_stat(name, help, type, value):
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()]
return ",".join(pieces)
def write_array_stat(name, help, type, data):
ret = "# HELP {name} {help}\n# TYPE {name} {type}\n" \
.format(name=name, help=help, type=type)
@ -67,6 +67,7 @@ def generate_metrics(full=False):
return ret
@bp.route("/metrics")
def metrics():
response = make_response(generate_metrics(), 200)

@ -14,8 +14,7 @@
# 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 *
from flask import Blueprint, redirect, render_template, abort
from sqlalchemy import func
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
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user, login_required
from sqlalchemy import or_, desc

@ -19,7 +19,7 @@ from sqlalchemy.orm import joinedload
from . import bp
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/")

@ -14,29 +14,37 @@
# 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 typing
from urllib.parse import quote as urlescape
from celery import uuid
from flask import render_template, make_response
from flask_login import login_required
from flask import render_template, make_response, request, redirect, flash, url_for, abort
from flask_babel import gettext, lazy_gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from jinja2.utils import markupsafe
from sqlalchemy import func
from sqlalchemy import func, or_, and_
from sqlalchemy.orm import joinedload, subqueryload
from wtforms import *
from wtforms.validators import *
from wtforms import SelectField, StringField, TextAreaField, IntegerField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length, Regexp, DataRequired, Optional, URL, NumberRange, ValidationError
from wtforms_sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from app.logic.LogicError import LogicError
from app.logic.packages import do_edit_package
from app.models.packages import PackageProvides
from app.querybuilder import QueryBuilder
from app.rediscache import has_key, set_key
from app.tasks.importtasks import importRepoScreenshot, checkZipRelease
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 ...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/")
@ -174,14 +182,14 @@ def view(package):
topic_error = "<br />".join(errors)
threads = Thread.query.filter_by(package_id=package.id, review_id=None)
if not current_user.is_authenticated:
threads = threads.filter_by(private=False)
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))
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",
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={}" \
.format(urlescape(package.title), urlescape("#375a7f"))
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={}" \
.format(urlescape("#375a7f"), urlescape(api_url))
else:
from flask import abort
abort(404)
return redirect(url)
@ -213,7 +222,7 @@ def download(package):
if release is None:
if "application/zip" in request.accept_mimetypes and \
not "text/html" in request.accept_mimetypes:
"text/html" not in request.accept_mimetypes:
return "", 204
else:
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])
website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None])
issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)])
forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0, 999999)])
video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters=[lambda x: x or None])
donate_url = StringField(lazy_gettext("Donate URL"), [Optional(), URL()], filters=[lambda x: x or None])
@ -721,7 +730,7 @@ def game_support(package):
all_game_support = package.supported_games.all()
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:
supported_games_list.insert(0, "*")
supported_games = ", ".join(supported_games_list)
@ -752,7 +761,7 @@ def statistics(package):
@bp.route("/packages/<author>/<name>/stats.csv")
@is_package_page
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",
"reason_dependency", "reason_update"]

@ -14,19 +14,20 @@
# 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 *
from flask_babel import gettext, lazy_gettext
from flask_login import login_required
from flask import render_template, request, redirect, flash, url_for, abort
from flask_babel import lazy_gettext, gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import *
from wtforms import StringField, SubmitField, BooleanField, RadioField, FileField
from wtforms.validators import InputRequired, Length, Optional
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.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.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

@ -13,22 +13,22 @@
#
# 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 flask import render_template, request, redirect, flash, url_for, abort
from flask_babel import gettext, lazy_gettext
from . import bp
from flask import *
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, TextAreaField, SubmitField, RadioField
from wtforms.validators import InputRequired, Length
from app.models import db, PackageReview, Thread, ThreadReply, NotificationType, PackageReviewVote, Package, UserRank, \
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, \
addAuditLog, has_blocked_domains
from app.tasks.webhooktasks import post_discord_webhook
from . import bp
@bp.route("/reviews/")
@ -41,7 +41,7 @@ def list_reviews():
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)])
rating = RadioField(lazy_gettext("Rating"), [InputRequired()],
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))
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)

@ -14,19 +14,19 @@
# 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 *
from flask_babel import gettext, lazy_gettext
from flask import render_template, request, redirect, flash, url_for, abort
from flask_babel import lazy_gettext, gettext
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_login import login_required
from wtforms import *
from wtforms import StringField, SubmitField, BooleanField, FileField
from wtforms.validators import InputRequired, Length, DataRequired, Optional
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.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):
@ -100,23 +100,23 @@ def edit_screenshot(package, id):
if screenshot is None or screenshot.package != package:
abort(404)
canEdit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS)
canApprove = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT)
if not (canEdit or canApprove):
can_edit = package.check_perm(current_user, Permission.ADD_SCREENSHOTS)
can_approve = package.check_perm(current_user, Permission.APPROVE_SCREENSHOT)
if not (can_edit or can_approve):
return redirect(package.get_url("packages.screenshots"))
# Initial form class from post data and default data
form = EditScreenshotForm(obj=screenshot)
if form.validate_on_submit():
wasApproved = screenshot.approved
was_approved = screenshot.approved
if canEdit:
if can_edit:
screenshot.title = form["title"].data or "Untitled"
if canApprove:
if can_approve:
screenshot.approved = form["approved"].data
else:
screenshot.approved = wasApproved
screenshot.approved = was_approved
db.session.commit()
return redirect(package.get_url("packages.screenshots"))

@ -14,14 +14,14 @@
# 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 *
from flask_login import login_required
from flask import Blueprint, jsonify, url_for, request, redirect, render_template
from flask_login import login_required, current_user
from app import csrf
from app.models import UserRank
from app.tasks import celery
from app.tasks.importtasks import getMeta
from app.utils import *
from app.utils import shouldReturnJson
bp = Blueprint("tasks", __name__)
@ -30,6 +30,7 @@ bp = Blueprint("tasks", __name__)
@bp.route("/tasks/getmeta/new/", methods=["POST"])
@login_required
def start_getmeta():
from flask import request
author = request.args.get("author")
author = current_user.forums_username if author is None else 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
# 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 app.markdown import get_user_mentions, render_markdown
@ -22,11 +23,12 @@ from app.tasks.webhooktasks import post_discord_webhook
bp = Blueprint("threads", __name__)
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 flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, TextAreaField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length
from app.utils import get_int_or_abort
@ -58,8 +60,8 @@ def list_all():
@bp.route("/threads/<int:id>/subscribe/", methods=["POST"])
@login_required
def subscribe(id):
thread = Thread.query.get(id)
def subscribe(id_):
thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404)
@ -75,8 +77,8 @@ def subscribe(id):
@bp.route("/threads/<int:id>/unsubscribe/", methods=["POST"])
@login_required
def unsubscribe(id):
thread = Thread.query.get(id)
def unsubscribe(id_):
thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404)
@ -92,8 +94,8 @@ def unsubscribe(id):
@bp.route("/threads/<int:id>/set-lock/", methods=["POST"])
@login_required
def set_lock(id):
thread = Thread.query.get(id)
def set_lock(id_):
thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.LOCK_THREAD):
abort(404)
@ -118,8 +120,8 @@ def set_lock(id):
@bp.route("/threads/<int:id>/delete/", methods=["GET", "POST"])
@login_required
def delete_thread(id):
thread = Thread.query.get(id)
def delete_thread(id_):
thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.DELETE_THREAD):
abort(404)
@ -141,8 +143,8 @@ def delete_thread(id):
@bp.route("/threads/<int:id>/delete-reply/", methods=["GET", "POST"])
@login_required
def delete_reply(id):
thread = Thread.query.get(id)
def delete_reply(id_):
thread = Thread.query.get(id_)
if thread is None:
abort(404)
@ -180,8 +182,8 @@ class CommentForm(FlaskForm):
@bp.route("/threads/<int:id>/edit/", methods=["GET", "POST"])
@login_required
def edit_reply(id):
thread = Thread.query.get(id)
def edit_reply(id_):
thread = Thread.query.get(id_)
if thread is None:
abort(404)
@ -217,8 +219,8 @@ def edit_reply(id):
@bp.route("/threads/<int:id>/", methods=["GET", "POST"])
def view(id):
thread: Thread = Thread.query.get(id)
def view(id_):
thread: Thread = Thread.query.get(id_)
if thread is None or not thread.check_perm(current_user, Permission.SEE_THREAD):
abort(404)
@ -377,7 +379,6 @@ def new():
return redirect(thread.get_view_url())
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
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
from flask import *
from flask_babel import gettext, get_locale
from flask import redirect, abort, render_template, flash, request, url_for
from flask_babel import gettext, get_locale, lazy_gettext
from flask_login import current_user, login_required, logout_user, login_user
from flask_wtf import FlaskForm
from sqlalchemy import or_
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, SubmitField, BooleanField, PasswordField, validators
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.utils import randomString, make_flask_login_password, is_safe_url, check_password_hash, addAuditLog, \
nonEmptyOrNone, post_login, is_username_valid
from . import bp
from app.models import User, AuditSeverity, db, UserRank, PackageAlias, EmailSubscription, UserNotificationPreferences, \
UserEmailVerification
class LoginForm(FlaskForm):
@ -45,7 +46,6 @@ def handle_login(form):
else:
flash(err, "danger")
username = form.username.data.strip()
user = User.query.filter(or_(User.username == username, User.email == username)).first()
if user is None:
@ -87,7 +87,6 @@ def login():
if request.method == "GET":
form.remember_me.data = True
return render_template("users/login.html", form=form)
@ -187,6 +186,7 @@ class ForgotPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [InputRequired(), Email()])
submit = SubmitField(lazy_gettext("Reset Password"))
@bp.route("/user/forgot-password/", methods=["GET", "POST"])
def forgot_password():
form = ForgotPasswordForm(request.form)
@ -222,9 +222,10 @@ class SetPasswordForm(FlaskForm):
email = StringField(lazy_gettext("Email"), [Optional(), Email()])
password = PasswordField(lazy_gettext("New 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"))
class ChangePasswordForm(FlaskForm):
old_password = PasswordField(lazy_gettext("Old 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)
if hasattr(form, "email"):
newEmail = nonEmptyOrNone(form.email.data)
if newEmail and newEmail != current_user.email:
new_email = nonEmptyOrNone(form.email.data)
if new_email and new_email != current_user.email:
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")
return
@ -262,7 +263,7 @@ def handle_set_password(form):
ver = UserEmailVerification()
ver.user = current_user
ver.token = token
ver.email = newEmail
ver.email = new_email
db.session.add(ver)
db.session.commit()

@ -13,6 +13,7 @@
#
# 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_babel import gettext
from . import bp
@ -58,7 +59,7 @@ def claim_forums():
session["forum_token"] = token
if request.method == "POST":
ctype = request.form.get("claim_type")
ctype = request.form.get("claim_type")
username = request.form.get("username")
if not is_username_valid(username):

@ -15,16 +15,16 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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_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 ...utils import get_daterange_options
@bp.route("/users/", methods=["GET"])

@ -1,12 +1,29 @@
from flask import *
from flask_babel import gettext, get_locale
# 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 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_wtf import FlaskForm
from sqlalchemy import or_
from wtforms import *
from wtforms.validators import *
from wtforms import StringField, SubmitField, BooleanField, SelectField
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.utils import nonEmptyOrNone, addAuditLog, randomString, rank_required, has_blocked_domains
from . import bp
@ -309,9 +326,9 @@ def modtools(username):
user.github_username = nonEmptyOrNone(form.github_username.data)
if user.check_perm(current_user, Permission.CHANGE_RANK):
newRank = form["rank"].data
if current_user.rank.atLeast(newRank):
if newRank != user.rank:
new_rank = form["rank"].data
if current_user.rank.atLeast(new_rank):
if new_rank != user.rank:
user.rank = form["rank"].data
msg = "Set rank of {} to {}".format(user.display_name, user.rank.get_title())
addAuditLog(AuditSeverity.MODERATION, current_user, msg,

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

@ -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
@ -68,7 +87,6 @@ def populate_test_data(session):
jeija.forums_username = "Jeija"
session.add(jeija)
mod = Package()
mod.state = PackageState.APPROVED
mod.name = "alpha"

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

@ -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
from datetime import timedelta
from typing import Optional
@ -15,7 +31,7 @@ keys = ["platform_minetest", "platform_other", "reason_new",
"reason_dependency", "reason_update"]
def _flatten_data(stats):
def flatten_data(stats):
start_date = stats[0].date
end_date = stats[-1].date
result = {
@ -52,7 +68,7 @@ def get_package_stats(package: Package, start_date: Optional[datetime.date], end
if len(stats) == 0:
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]):
@ -76,7 +92,7 @@ def get_package_stats_for_user(user: User, start_date: Optional[datetime.date],
if len(stats) == 0:
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)
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 typing import List
@ -15,11 +31,11 @@ def validate_package_for_approval(package: Package) -> List[ValidationError]:
normalised_name = package.getNormalisedName()
if package.type != PackageType.MOD and Package.query.filter(
and_(Package.state == PackageState.APPROVED,
or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0:
or_(Package.name == normalised_name,
Package.name == normalised_name + "_game"))).count() > 0:
retval.append(("danger", lazy_gettext("A package already exists with this name. Please see Policy and Guidance 3")))
if package.releases.filter(PackageRelease.task_id==None).count() == 0:
if package.releases.filter(PackageRelease.task_id == None).count() == 0:
retval.append(("danger", lazy_gettext("A release is required before this package can be approved.")))
# Don't bother validating any more until we have a release
return retval

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

@ -14,44 +14,47 @@
# 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 imghdr
import os
from flask_babel import lazy_gettext
from app import app
from app.logic.LogicError import LogicError
from app.models import *
from app.utils import randomString
def get_extension(filename):
return filename.rsplit(".", 1)[1].lower() if "." in filename else None
ALLOWED_IMAGES = {"jpeg", "png"}
def isAllowedImage(data):
def is_allowed_image(data):
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 == "":
raise LogicError(400, "Expected file")
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
isImage = False
if fileType == "image":
allowedExtensions = ["jpg", "jpeg", "png"]
isImage = True
elif fileType == "zip":
allowedExtensions = ["zip"]
is_image = False
if file_type == "image":
allowed_extensions = ["jpg", "jpeg", "png"]
is_image = True
elif file_type == "zip":
allowed_extensions = ["zip"]
else:
raise Exception("Invalid fileType")
ext = get_extension(file.filename)
if ext is None or not ext in allowedExtensions:
raise LogicError(400, lazy_gettext("Please upload %(file_desc)s", file_desc=fileTypeDesc))
if ext is None or ext not in allowed_extensions:
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"))
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
from app.tasks.emails import send_user_email
@ -9,6 +25,7 @@ def _has_newline(line):
return True
return False
def _is_bad_subject(subject):
"""Copied from: flask_mail.py class Message def has_bad_headers"""
if _has_newline(subject):
@ -32,9 +49,11 @@ class FlaskMailSubjectFormatter(logging.Formatter):
s = self.formatMessage(record)
return s
class FlaskMailTextFormatter(logging.Formatter):
pass
class FlaskMailHTMLFormatter(logging.Formatter):
def 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
import bleach

@ -47,7 +47,7 @@ class APIToken(db.Model):
package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True)
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:
return False
@ -146,7 +146,7 @@ class ForumTopic(db.Model):
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:
return None

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

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

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import enum
@ -351,7 +350,7 @@ class EmailSubscription(db.Model):
@property
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)

@ -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_babel import lazy_gettext
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
# This file acts as a facade between the releases code and redis,

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

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

@ -13,27 +13,31 @@
#
# 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 json import JSONDecodeError
import gitdb
import datetime
import json
import os
import shutil
from json import JSONDecodeError
from zipfile import ZipFile
import gitdb
from flask import url_for
from git import GitCommandError
from git_archive_all import GitArchiver
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.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 .minetestcheck import build_tree, MinetestCheckError, ContentType
from ..logic.LogicError import LogicError
from ..logic.game_support import GameSupportResolver
from ..logic.packages import do_edit_package, ALIASES
from ..utils.image import get_image_size
from app import app
from app.logic.LogicError import LogicError
from app.logic.game_support import GameSupportResolver
from app.logic.packages import do_edit_package, ALIASES
from app.utils.image import get_image_size
@celery.task()
@ -51,7 +55,7 @@ def getMeta(urlstr, author):
result["forums"] = result.get("forumId")
readme_path = tree.getReadMePath()
readme_path = tree.get_readme_path()
if readme_path:
with open(readme_path, "r") as f:
result["long_description"] = f.read()
@ -96,11 +100,11 @@ def postReleaseCheckUpdate(self, release: PackageRelease, path):
def getMetaPackages(names):
return [ MetaPackage.GetOrCreate(x, cache) for x in names ]
provides = tree.getModNames()
provides = tree.get_mod_names()
package = release.package
package.provides.clear()
package.provides.extend(getMetaPackages(tree.getModNames()))
package.provides.extend(getMetaPackages(tree.get_mod_names()))
# Delete all mod name dependencies
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
class MinetestCheckError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr("Error validating package: " + self.value)
class ContentType(Enum):
UNKNOWN = "unknown"
MOD = "mod"
@ -13,7 +32,7 @@ class ContentType(Enum):
GAME = "game"
TXP = "texture pack"
def isModLike(self):
def is_mod_like(self):
return self == ContentType.MOD or self == ContentType.MODPACK
def validate_same(self, other):
@ -23,7 +42,7 @@ class ContentType(Enum):
assert other
if self == ContentType.MOD:
if not other.isModLike():
if not other.is_mod_like():
raise MinetestCheckError("Expected a mod or modpack, found " + other.value)
elif self == ContentType.TXP:
@ -36,6 +55,7 @@ class ContentType(Enum):
from .tree import PackageTreeNode, get_base_dir
def build_tree(path, expected_type=None, author=None, repo=None, name=None):
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):
retval = {}
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 .config import parse_conf
basenamePattern = re.compile("^([a-z0-9_]+)$")
def get_base_dir(path):
if not os.path.isdir(path):
raise IOError("Expected dir")
@ -39,8 +59,8 @@ def get_csv_line(line):
class PackageTreeNode:
def __init__(self, baseDir, relative, author=None, repo=None, name=None):
self.baseDir = baseDir
def __init__(self, base_dir, relative, author=None, repo=None, name=None):
self.baseDir = base_dir
self.relative = relative
self.author = author
self.name = name
@ -49,11 +69,11 @@ class PackageTreeNode:
self.children = []
# Detect type
self.type = detect_type(baseDir)
self.type = detect_type(base_dir)
self.read_meta()
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))
self.add_children_from_mod_dir("mods")
elif self.type == ContentType.MOD:
@ -69,13 +89,13 @@ class PackageTreeNode:
if lowercase != dir and lowercase in dirs:
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):
path = os.path.join(self.baseDir, filename)
if os.path.isfile(path) and filename.lower().startswith("readme."):
return path
def getMetaFileName(self):
def get_meta_file_name(self):
if self.type == ContentType.GAME:
return "game.conf"
elif self.type == ContentType.MOD:
@ -91,13 +111,13 @@ class PackageTreeNode:
result = {}
# Read .conf file
meta_file_name = self.getMetaFileName()
meta_file_name = self.get_meta_file_name()
if meta_file_name is not None:
meta_file_rel = self.relative + meta_file_name
meta_file_path = self.baseDir + "/" + meta_file_name
try:
with open(meta_file_path or "", "r") as myfile:
conf = parse_conf(myfile.read())
with open(meta_file_path or "", "r") as f:
conf = parse_conf(f.read())
for key, value in conf.items():
result[key] = value
except SyntaxError as e:
@ -108,12 +128,11 @@ class PackageTreeNode:
if "release" in result:
raise MinetestCheckError("{} should not contain 'release' key, as this is for use by ContentDB only.".format(meta_file_rel))
# description.txt
if not "description" in result:
if "description" not in result:
try:
with open(self.baseDir + "/description.txt", "r") as myfile:
result["description"] = myfile.read()
with open(self.baseDir + "/description.txt", "r") as f:
result["description"] = f.read()
except IOError:
pass
@ -123,10 +142,10 @@ class PackageTreeNode:
result["optional_depends"] = get_csv_line(result.get("optional_depends"))
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:
contents = myfile.read()
with open(self.baseDir + "/depends.txt", "r") as f:
contents = f.read()
soft = []
hard = []
for line in contents.split("\n"):
@ -144,8 +163,7 @@ class PackageTreeNode:
result["depends"] = []
result["optional_depends"] = []
def checkDependencies(deps):
def check_dependencies(deps):
for dep in deps:
if not basenamePattern.match(dep):
if " " in dep:
@ -157,8 +175,8 @@ class PackageTreeNode:
.format(dep, self.relative))
# Check dependencies
checkDependencies(result["depends"])
checkDependencies(result["optional_depends"])
check_dependencies(result["depends"])
check_dependencies(result["optional_depends"])
# Fix games using "name" as "title"
if self.type == ContentType.GAME and "name" in result:
@ -193,7 +211,7 @@ class PackageTreeNode:
path = os.path.join(dir, entry)
if not entry.startswith('.') and os.path.isdir(path):
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 {}" \
.format(child.type.value, child.relative, self.type.value))
@ -202,7 +220,7 @@ class PackageTreeNode:
self.children.append(child)
def getModNames(self):
def get_mod_names(self):
return self.fold("name", type_=ContentType.MOD)
# attr: Attribute name

@ -13,7 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys
from typing import Optional
import requests
@ -22,6 +22,7 @@ from app import app
from app.models import User
from app.tasks import celery
@celery.task()
def post_discord_webhook(username: Optional[str], content: str, is_queue: bool, 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")

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import subprocess
from subprocess import Popen, PIPE
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 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.models import db, Package, PackageState
from .utils import parse_json, validate_package_list
@ -14,7 +31,6 @@ def test_packages_empty(client):
def test_packages_with_contents(client):
"""Start with a test database."""
populate_test_data(db.session)
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.models import db
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 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
from sqlalchemy import text
@ -19,18 +35,23 @@ def recreate_db():
populate(db.session)
db.session.commit()
def parse_json(b):
return json.loads(b.decode("utf8"))
def is_type(t, v):
return v and isinstance(v, t)
def is_optional(t, v):
return not v or isinstance(v, t)
def is_str(v):
return is_type(str, v)
def is_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
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
from app.logic.graphs import _flatten_data
from app.logic.graphs import flatten_data
class DailyStat:
@ -21,7 +37,7 @@ class DailyStat:
def test_flatten_data():
res = _flatten_data([
res = flatten_data([
DailyStat("2022-03-28", 3),
DailyStat("2022-03-29", 10),
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

@ -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

@ -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

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

@ -14,15 +14,16 @@
# 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 typing
from urllib.parse import urljoin, urlparse, urlunparse
import user_agents
from flask import request, abort
from flask_babel import LazyString
from flask import request, abort, url_for
from flask_babel import LazyString, lazy_gettext
from werkzeug.datastructures import MultiDict
from app.models import *
from app import app
def is_safe_url(target):
@ -136,7 +137,7 @@ def get_request_date(key: str) -> typing.Optional[datetime.date]:
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()
days7 = (datetime.datetime.utcnow() - datetime.timedelta(days=7)).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/>.
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 git import GitCommandError
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
import re
import sys

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

@ -22,8 +22,8 @@ from flask_login import login_user, current_user
from passlib.handlers.bcrypt import bcrypt
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.models import User, UserRank, UserNotificationPreferences, db
def check_password_hash(stored, given):