Add support for zip uploads in the API

Fixes #261
This commit is contained in:
rubenwardy 2021-02-02 00:07:41 +00:00
parent a78fe8ceb9
commit 033f40c263
13 changed files with 256 additions and 137 deletions

@ -19,7 +19,7 @@ from flask import *
from flask_login import current_user, login_required from flask_login import current_user, login_required
from . import bp from . import bp
from .auth import is_api_authd 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 import csrf
from app.models import * from app.models import *
from app.utils import is_package_page 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): if not package.checkPerm(token.owner, Permission.APPROVE_RELEASE):
error(403, "You do not have the permission to approve releases") error(403, "You do not have the permission to approve releases")
json = request.json data = request.json or request.form
if json is None: if "title" not in data:
error(400, "JSON post data is required") error(400, "Title is required in the POST data")
for option in ["method", "title", "ref"]: if request.json:
if json.get(option) is None: for option in ["method", "ref"]:
if option not in data:
error(400, option + " is required in the POST data") error(400, option + " is required in the POST data")
if json["method"].lower() != "git": if data["method"].lower() != "git":
error(400, "Release-creation methods other than git are not supported") 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)

@ -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 <https://www.gnu.org/licenses/>.
from celery import uuid
from flask import jsonify, abort, make_response, url_for from flask import jsonify, abort, make_response, url_for
from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release
from app.models import PackageRelease, db, Permission from app.models import APIToken, Package, MinetestRelease
from app.tasks.importtasks import makeVCSRelease
from app.utils import AuditSeverity, addAuditLog
def error(status, message): def error(code: int, msg: str):
abort(make_response(jsonify({ "success": False, "error": message }), status)) 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): 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): rel = run_safe(do_create_vcs_release, token.owner, package, title, ref, None, None, reason)
return error(403, "Permission denied. Missing MAKE_RELEASE permission")
return jsonify({
five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) "success": True,
count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count() "task": url_for("tasks.check", id=rel.task_id),
if count >= 2: "release": rel.getAsDictionary()
return error(429, "Too many requests, please wait before trying again") })
rel = PackageRelease()
rel.package = package def api_create_zip_release(token: APIToken, package: Package, title: str, file, reason="API"):
rel.title = title if not token.canOperateOnPackage(package):
rel.url = "" error(403, "API token does not have access to the package")
rel.task_id = uuid()
rel.min_rel = None rel = run_safe(do_create_zip_release, token.owner, package, title, file, None, None, reason)
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)
return jsonify({ return jsonify({
"success": True, "success": True,

@ -24,7 +24,7 @@ from sqlalchemy import func, or_, and_
from app import github, csrf from app import github, csrf
from app.models import db, User, APIToken, Package, Permission, AuditSeverity from app.models import db, User, APIToken, Package, Permission, AuditSeverity
from app.utils import abs_url_for, addAuditLog, login_user_set_active 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 import hmac, requests
@bp.route("/github/start/") @bp.route("/github/start/")
@ -146,4 +146,4 @@ def webhook():
# Perform release # Perform release
# #
return handleCreateRelease(actual_token, package, title, ref, reason="Webhook") return api_create_vcs_release(actual_token, package, title, ref, reason="Webhook")

@ -20,7 +20,7 @@ bp = Blueprint("gitlab", __name__)
from app import csrf from app import csrf
from app.models import Package, APIToken, Permission 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(): def webhook_impl():
@ -63,7 +63,7 @@ def webhook_impl():
# Perform release # 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"]) @bp.route("/gitlab/webhook/", methods=["POST"])

@ -15,7 +15,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from celery import uuid
from flask import * from flask import *
from flask_login import login_required from flask_login import login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@ -23,8 +22,9 @@ from wtforms import *
from wtforms.ext.sqlalchemy.fields import QuerySelectField from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import * 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.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 app.utils import *
from . import bp from . import bp
@ -80,48 +80,16 @@ def create_release(package):
form.title.data = request.args.get("title") form.title.data = request.args.get("title")
if form.validate_on_submit(): if form.validate_on_submit():
try:
if form["uploadOpt"].data == "vcs": if form["uploadOpt"].data == "vcs":
rel = PackageRelease() rel = do_create_vcs_release(current_user, package, form.title.data,
rel.package = package form.vcsLabel.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
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()
return redirect(url_for("tasks.check", id=rel.task_id, r=rel.getEditURL()))
else: else:
uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "zip", "a zip file") rel = do_create_zip_release(current_user, package, form.title.data,
if uploadedUrl is not None: form.fileUpload.data, form.min_rel.data.getActual(), form.max_rel.data.getActual())
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())) 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) return render_template("packages/release_new.html", package=package, form=form)

@ -24,6 +24,8 @@ from wtforms.validators import *
from app.utils import * from app.utils import *
from . import bp from . import bp
from app.logic.LogicError import LogicError
from app.logic.screenshots import do_create_screenshot
class CreateScreenshotForm(FlaskForm): class CreateScreenshotForm(FlaskForm):
@ -88,27 +90,11 @@ def create_screenshot(package):
# Initial form class from post data and default data # Initial form class from post data and default data
form = CreateScreenshotForm() form = CreateScreenshotForm()
if form.validate_on_submit(): if form.validate_on_submit():
uploadedUrl, uploadedPath = doFileUpload(form.fileUpload.data, "image", try:
"a PNG or JPG image file") do_create_screenshot(current_user, package, form.title.data, form.fileUpload.data)
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()
return redirect(package.getEditScreenshotsURL()) return redirect(package.getEditScreenshotsURL())
except LogicError as e:
flash(e.message, "danger")
return render_template("packages/screenshot_new.html", package=package, form=form) return render_template("packages/screenshot_new.html", package=package, form=form)

@ -46,10 +46,13 @@ Tokens can be attained by visiting [Profile > "API Tokens"](/user/tokens/).
* GET `/api/packages/<username>/<name>/releases/` * GET `/api/packages/<username>/<name>/releases/`
* POST `/api/packages/<username>/<name>/releases/new/` * POST `/api/packages/<username>/<name>/releases/new/`
* Requires authentication. * Requires authentication.
* Body is multipart form if zip upload, JSON otherwise.
* `title`: human-readable name of the release. * `title`: human-readable name of the release.
* `method`: Release-creation method, only `git` is supported. * For Git release creation:
* If `git` release-creation method: * `method`: must be `git`.
* `ref` - git reference, eg: `master`. * `ref`: (Optional) git reference, eg: `master`.
* For zip upload release creation:
* `file`: multipart file to upload, like `<input type=file>`.
* You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/). * You can set min and max Minetest Versions [using the content's .conf file](/help/package_config/).

24
app/logic/LogicError.py Normal file

@ -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 <https://www.gnu.org/licenses/>.
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))

0
app/logic/__init__.py Normal file

90
app/logic/releases.py Normal file

@ -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 <https://www.gnu.org/licenses/>.
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

29
app/logic/screenshots.py Normal file

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

@ -17,37 +17,25 @@
import imghdr import imghdr
import os import os
import random
import string
from flask import request, flash
from app.logic.LogicError import LogicError
from app.models import * 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 return filename.rsplit(".", 1)[1].lower() if "." in filename else None
ALLOWED_IMAGES = {"jpeg", "png"} ALLOWED_IMAGES = {"jpeg", "png"}
def isAllowedImage(data): def isAllowedImage(data):
return imghdr.what(None, data) in ALLOWED_IMAGES return imghdr.what(None, data) in ALLOWED_IMAGES
def shouldReturnJson(): def upload_file(file, fileType, fileTypeDesc):
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):
if not file or file is None or file.filename == "": if not file or file is None or file.filename == "":
flash("No selected file", "danger") raise LogicError(400, "Expected file")
return None, None
assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist" assert os.path.isdir(app.config["UPLOAD_DIR"]), "UPLOAD_DIR must exist"
allowedExtensions = []
isImage = False isImage = False
if fileType == "image": if fileType == "image":
allowedExtensions = ["jpg", "jpeg", "png"] allowedExtensions = ["jpg", "jpeg", "png"]
@ -57,18 +45,17 @@ def doFileUpload(file, fileType, fileTypeDesc):
else: else:
raise Exception("Invalid fileType") raise Exception("Invalid fileType")
ext = getExtension(file.filename) ext = get_extension(file.filename)
if ext is None or not ext in allowedExtensions: if ext is None or not ext in allowedExtensions:
flash("Please upload " + fileTypeDesc, "danger") raise LogicError(400, "Please upload " + fileTypeDesc)
return None, None
if isImage and not isAllowedImage(file.stream.read()): if isImage and not isAllowedImage(file.stream.read()):
flash("Uploaded image isn't actually an image", "danger") raise LogicError(400, "Uploaded image isn't actually an image")
return None, None
file.stream.seek(0) file.stream.seek(0)
filename = randomString(10) + "." + ext filename = randomString(10) + "." + ext
filepath = os.path.join(app.config["UPLOAD_DIR"], filename) filepath = os.path.join(app.config["UPLOAD_DIR"], filename)
file.save(filepath) file.save(filepath)
return "/uploads/" + filename, filepath return "/uploads/" + filename, filepath

@ -13,24 +13,37 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import random
import string
from .flask import * from .flask import *
from .uploads import *
from .models import * from .models import *
from .user import * from .user import *
YESES = ["yes", "true", "1", "on"] YESES = ["yes", "true", "1", "on"]
def isYes(val): def isYes(val):
return val and val.lower() in YESES return val and val.lower() in YESES
def isNo(val): def isNo(val):
return val and not isYes(val) return val and not isYes(val)
def nonEmptyOrNone(str): def nonEmptyOrNone(str):
if str is None or str == "": if str is None or str == "":
return None return None
return str 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))