diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index 52fd964..8b96893 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -19,7 +19,7 @@ from flask import * from flask_login import current_user, login_required from . import bp from .auth import is_api_authd -from .support import error, handleCreateRelease +from .support import error, api_create_vcs_release, api_create_zip_release from app import csrf from app.models import * from app.utils import is_package_page @@ -210,15 +210,23 @@ def create_release(token, package): if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE): error(403, "You do not have the permission to approve releases") - json = request.json - if json is None: - error(400, "JSON post data is required") + data = request.json or request.form + if "title" not in data: + error(400, "Title is required in the POST data") - for option in ["method", "title", "ref"]: - if json.get(option) is None: - error(400, option + " is required in the POST data") + if request.json: + for option in ["method", "ref"]: + if option not in data: + error(400, option + " is required in the POST data") - if json["method"].lower() != "git": - error(400, "Release-creation methods other than git are not supported") + if data["method"].lower() != "git": + error(400, "Release-creation methods other than git are not supported") - return handleCreateRelease(token, package, json["title"], json["ref"]) + return api_create_vcs_release(token, package, data["title"], data["ref"]) + + elif request.files: + file = request.files.get("file") + if file is None: + error(400, "Missing 'file' in multipart body") + + return api_create_zip_release(token, package, data["title"], file) diff --git a/app/blueprints/api/support.py b/app/blueprints/api/support.py index a198df5..f5b115b 100644 --- a/app/blueprints/api/support.py +++ b/app/blueprints/api/support.py @@ -1,44 +1,55 @@ -import datetime +# 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 . + -from celery import uuid from flask import jsonify, abort, make_response, url_for - -from app.models import PackageRelease, db, Permission -from app.tasks.importtasks import makeVCSRelease -from app.utils import AuditSeverity, addAuditLog +from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release +from app.models import APIToken, Package, MinetestRelease -def error(status, message): - abort(make_response(jsonify({ "success": False, "error": message }), status)) +def error(code: int, msg: str): + abort(make_response(jsonify({ "success": False, "error": msg }), code)) + +# Catches LogicErrors and aborts with JSON error +def run_safe(f, *args, **kwargs): + try: + return f(*args, **kwargs) + except LogicError as e: + error(e.code, e.message) -def handleCreateRelease(token, package, title, ref, reason="API"): +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): - return error(403, "API token does not have access to the package") + error(403, "API token does not have access to the package") - if not package.checkPerm(token.owner, Permission.MAKE_RELEASE): - return error(403, "Permission denied. Missing MAKE_RELEASE permission") - - five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) - count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count() - if count >= 2: - return error(429, "Too many requests, please wait before trying again") - - rel = PackageRelease() - rel.package = package - rel.title = title - rel.url = "" - rel.task_id = uuid() - rel.min_rel = None - rel.max_rel = None - db.session.add(rel) - - msg = "Created release {} ({})".format(rel.title, reason) - addAuditLog(AuditSeverity.NORMAL, token.owner, msg, package.getDetailsURL(), package) - - db.session.commit() - - makeVCSRelease.apply_async((rel.id, ref), task_id=rel.task_id) + rel = run_safe(do_create_vcs_release, token.owner, package, title, ref, None, None, reason) + + return jsonify({ + "success": True, + "task": url_for("tasks.check", id=rel.task_id), + "release": rel.getAsDictionary() + }) + + +def api_create_zip_release(token: APIToken, package: Package, title: str, file, reason="API"): + if not token.canOperateOnPackage(package): + error(403, "API token does not have access to the package") + + rel = run_safe(do_create_zip_release, token.owner, package, title, file, None, None, reason) return jsonify({ "success": True, diff --git a/app/blueprints/github/__init__.py b/app/blueprints/github/__init__.py index 2718597..081ef9e 100644 --- a/app/blueprints/github/__init__.py +++ b/app/blueprints/github/__init__.py @@ -24,7 +24,7 @@ from sqlalchemy import func, or_, and_ from app import github, csrf from app.models import db, User, APIToken, Package, Permission, AuditSeverity from app.utils import abs_url_for, addAuditLog, login_user_set_active -from app.blueprints.api.support import error, handleCreateRelease +from app.blueprints.api.support import error, api_create_vcs_release import hmac, requests @bp.route("/github/start/") @@ -146,4 +146,4 @@ def webhook(): # Perform release # - return handleCreateRelease(actual_token, package, title, ref, reason="Webhook") + return api_create_vcs_release(actual_token, package, title, ref, reason="Webhook") diff --git a/app/blueprints/gitlab/__init__.py b/app/blueprints/gitlab/__init__.py index 31b8068..bbe3219 100644 --- a/app/blueprints/gitlab/__init__.py +++ b/app/blueprints/gitlab/__init__.py @@ -20,7 +20,7 @@ bp = Blueprint("gitlab", __name__) from app import csrf from app.models import Package, APIToken, Permission -from app.blueprints.api.support import error, handleCreateRelease +from app.blueprints.api.support import error, api_create_vcs_release def webhook_impl(): @@ -63,7 +63,7 @@ def webhook_impl(): # Perform release # - return handleCreateRelease(token, package, title, ref, reason="Webhook") + return api_create_vcs_release(token, package, title, ref, reason="Webhook") @bp.route("/gitlab/webhook/", methods=["POST"]) diff --git a/app/blueprints/packages/releases.py b/app/blueprints/packages/releases.py index f58d68b..71d913d 100644 --- a/app/blueprints/packages/releases.py +++ b/app/blueprints/packages/releases.py @@ -15,7 +15,6 @@ # along with this program. If not, see . -from celery import uuid from flask import * from flask_login import login_required from flask_wtf import FlaskForm @@ -23,8 +22,9 @@ from wtforms import * from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms.validators import * +from app.logic.releases import do_create_vcs_release, LogicError, do_create_zip_release from app.rediscache import has_key, set_key, make_download_key -from app.tasks.importtasks import makeVCSRelease, checkZipRelease, check_update_config +from app.tasks.importtasks import check_update_config from app.utils import * from . import bp @@ -80,48 +80,16 @@ def create_release(package): form.title.data = request.args.get("title") if form.validate_on_submit(): - if form["uploadOpt"].data == "vcs": - rel = PackageRelease() - rel.package = package - rel.title = form["title"].data - rel.url = "" - rel.task_id = uuid() - rel.min_rel = form["min_rel"].data.getActual() - rel.max_rel = form["max_rel"].data.getActual() - db.session.add(rel) - db.session.commit() - - makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(form.vcsLabel.data)), task_id=rel.task_id) - - msg = "Created release {}".format(rel.title) - addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, rel.getEditURL(), package) - addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getDetailsURL(), package) - db.session.commit() - + try: + if form["uploadOpt"].data == "vcs": + rel = do_create_vcs_release(current_user, package, form.title.data, + form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual()) + else: + rel = do_create_zip_release(current_user, package, form.title.data, + form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual()) return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL())) - else: - uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file") - if uploadedUrl is not None: - rel = PackageRelease() - rel.package = package - rel.title = form["title"].data - rel.url = uploadedUrl - rel.task_id = uuid() - rel.min_rel = form["min_rel"].data.getActual() - rel.max_rel = form["max_rel"].data.getActual() - db.session.add(rel) - db.session.commit() - - checkZipRelease.apply_async((rel.id, uploadedPath), task_id=rel.task_id) - - msg = "Created release {}".format(rel.title) - addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, - msg, rel.getEditURL(), package) - addAuditLog(AuditSeverity.NORMAL, current_user, msg, package.getDetailsURL(), - package) - db.session.commit() - - return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL())) + except LogicError as e: + flash(e.message, "danger") return render_template("packages/release_new.html", package=package, form=form) diff --git a/app/blueprints/packages/screenshots.py b/app/blueprints/packages/screenshots.py index 694fb05..93b9941 100644 --- a/app/blueprints/packages/screenshots.py +++ b/app/blueprints/packages/screenshots.py @@ -24,6 +24,8 @@ from wtforms.validators import * from app.utils import * from . import bp +from app.logic.LogicError import LogicError +from app.logic.screenshots import do_create_screenshot class CreateScreenshotForm(FlaskForm): @@ -88,27 +90,11 @@ def create_screenshot(package): # Initial form class from post data and default data form = CreateScreenshotForm() if form.validate_on_submit(): - uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "image", - "a PNG or JPG image file") - if uploadedUrl is not None: - counter = 1 - for screenshot in package.screenshots: - screenshot.order = counter - counter += 1 - - ss = PackageScreenshot() - ss.package = package - ss.title = form["title"].data or "Untitled" - ss.url = uploadedUrl - ss.approved = package.checkPerm(current_user, Permission.APPROVE_SCREENSHOT) - ss.order = counter - db.session.add(ss) - - msg = "Screenshot added {}" \ - .format(ss.title) - addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, msg, package.getDetailsURL(), package) - db.session.commit() + try: + do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data) return redirect(package.getEditScreenshotsURL()) + except LogicError as e: + flash(e.message, "danger") return render_template("packages/screenshot_new.html", package=package, form=form) diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index 92bae88..191b25e 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -46,10 +46,13 @@ Tokens can be attained by visiting [Profile > "API Tokens"](/user/tokens/). * GET `/api/packages///releases/` * POST `/api/packages///releases/new/` * Requires authentication. + * Body is multipart form if zip upload, JSON otherwise. * `title`: human-readable name of the release. - * `method`: Release-creation method, only `git` is supported. - * If `git` release-creation method: - * `ref` - git reference, eg: `master`. + * For Git release creation: + * `method`: must be `git`. + * `ref`: (Optional) git reference, eg: `master`. + * For zip upload release creation: + * `file`: multipart file to upload, like ``. * You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/). diff --git a/app/logic/LogicError.py b/app/logic/LogicError.py new file mode 100644 index 0000000..000904a --- /dev/null +++ b/app/logic/LogicError.py @@ -0,0 +1,24 @@ +# ContentDB +# Copyright (C) 2021 rubenwardy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +class LogicError(Exception): + def __init__(self, code, message): + self.code = code + self.message = message + + def __str__(self): + return repr("LogicError {}: {}".format(self.code, self.message)) diff --git a/app/logic/__init__.py b/app/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/logic/releases.py b/app/logic/releases.py new file mode 100644 index 0000000..8bba0ba --- /dev/null +++ b/app/logic/releases.py @@ -0,0 +1,90 @@ +# 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 . + + +import datetime + +from celery import uuid + +from app.logic.LogicError import LogicError +from app.logic.uploads import upload_file +from app.models import PackageRelease, db, Permission, User, Package, MinetestRelease +from app.tasks.importtasks import makeVCSRelease, checkZipRelease +from app.utils import AuditSeverity, addAuditLog, nonEmptyOrNone + + +def check_can_create_release(user: User, package: Package): + if not package.checkPerm(user, Permission.MAKE_RELEASE): + raise LogicError(403, "Permission denied. Missing MAKE_RELEASE permission") + + five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) + count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count() + if count >= 2: + raise LogicError(429, "Too many requests, please wait before trying again") + + +def do_create_vcs_release(user: User, package: Package, title: str, ref: str, + min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None): + check_can_create_release(user, package) + + rel = PackageRelease() + rel.package = package + rel.title = title + rel.url = "" + rel.task_id = uuid() + rel.min_rel = min_v + rel.max_rel = max_v + db.session.add(rel) + + if reason is None: + msg = "Created release {}".format(rel.title) + else: + msg = "Created release {} ({})".format(rel.title, reason) + addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package) + + db.session.commit() + + makeVCSRelease.apply_async((rel.id, nonEmptyOrNone(ref)), task_id=rel.task_id) + + return rel + + +def do_create_zip_release(user: User, package: Package, title: str, file, + min_v: MinetestRelease = None, max_v: MinetestRelease = None, reason: str = None): + check_can_create_release(user, package) + + uploaded_url, uploaded_path = upload_file(file, "zip", "a zip file") + + rel = PackageRelease() + rel.package = package + rel.title = title + rel.url = uploaded_url + rel.task_id = uuid() + rel.min_rel = min_v + rel.max_rel = max_v + db.session.add(rel) + + if reason is None: + msg = "Created release {}".format(rel.title) + else: + msg = "Created release {} ({})".format(rel.title, reason) + addAuditLog(AuditSeverity.NORMAL, user, msg, package.getDetailsURL(), package) + + db.session.commit() + + checkZipRelease.apply_async((rel.id, uploaded_path), task_id=rel.task_id) + + return rel diff --git a/app/logic/screenshots.py b/app/logic/screenshots.py new file mode 100644 index 0000000..bfc69ab --- /dev/null +++ b/app/logic/screenshots.py @@ -0,0 +1,29 @@ +from werkzeug.exceptions import abort + +from app.logic.uploads import upload_file +from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db +from app.utils import addNotification + + +def do_create_screenshot(user: User, package: Package, title: str, file): + uploaded_url, uploaded_path = upload_file(file, "image", "a PNG or JPG image file") + + counter = 1 + for screenshot in package.screenshots: + screenshot.order = counter + counter += 1 + + ss = PackageScreenshot() + ss.package = package + ss.title = title or "Untitled" + ss.url = uploaded_url + ss.approved = package.checkPerm(user, Permission.APPROVE_SCREENSHOT) + ss.order = counter + db.session.add(ss) + + msg = "Screenshot added {}" \ + .format(ss.title) + addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, msg, package.getDetailsURL(), package) + db.session.commit() + + return ss diff --git a/app/utils/uploads.py b/app/logic/uploads.py similarity index 69% rename from app/utils/uploads.py rename to app/logic/uploads.py index 78dd656..25607e0 100644 --- a/app/utils/uploads.py +++ b/app/logic/uploads.py @@ -17,37 +17,25 @@ import imghdr import os -import random -import string - -from flask import request, flash +from app.logic.LogicError import LogicError from app.models import * +from app.utils import randomString -def getExtension(filename): +def get_extension(filename): return filename.rsplit(".", 1)[1].lower() if "." in filename else None ALLOWED_IMAGES = {"jpeg", "png"} def isAllowedImage(data): return imghdr.what(None, data) in ALLOWED_IMAGES -def shouldReturnJson(): - return "application/json" in request.accept_mimetypes and \ - not "text/html" in request.accept_mimetypes - -def randomString(n): - return ''.join(random.choice(string.ascii_lowercase + \ - string.ascii_uppercase + string.digits) for _ in range(n)) - -def doFileUpload(file, fileType, fileTypeDesc): +def upload_file(file, fileType, fileTypeDesc): if not file or file is None or file.filename == "": - flash("No selected file", "danger") - return None, None + raise LogicError(400, "Expected file") assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist" - allowedExtensions = [] isImage = False if fileType == "image": allowedExtensions = ["jpg", "jpeg", "png"] @@ -57,18 +45,17 @@ def doFileUpload(file, fileType, fileTypeDesc): else: raise Exception("Invalid fileType") - ext = getExtension(file.filename) + ext = get_extension(file.filename) if ext is None or not ext in allowedExtensions: - flash("Please upload " + fileTypeDesc, "danger") - return None, None + raise LogicError(400, "Please upload " + fileTypeDesc) if isImage and not isAllowedImage(file.stream.read()): - flash("Uploaded image isn't actually an image", "danger") - return None, None + raise LogicError(400, "Uploaded image isn't actually an image") file.stream.seek(0) filename = randomString(10) + "." + ext filepath = os.path.join(app.config["UPLOAD_DIR"], filename) file.save(filepath) + return "/uploads/" + filename, filepath diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 68a2d31..78261cb 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -13,24 +13,37 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . - +import random +import string from .flask import * -from .uploads import * from .models import * from .user import * YESES = ["yes", "true", "1", "on"] + def isYes(val): return val and val.lower() in YESES + def isNo(val): return val and not isYes(val) + def nonEmptyOrNone(str): if str is None or str == "": return None return str + + +def shouldReturnJson(): + return "application/json" in request.accept_mimetypes and \ + not "text/html" in request.accept_mimetypes + + +def randomString(n): + return ''.join(random.choice(string.ascii_lowercase + \ + string.ascii_uppercase + string.digits) for _ in range(n))