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