diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index 8d424f1d..3bf89f5d 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -25,7 +25,7 @@ from app.querybuilder import QueryBuilder from app.utils import is_package_page 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 +from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots, api_edit_package @bp.route("/api/packages/") @@ -57,6 +57,17 @@ def package(package): return jsonify(package.getAsDictionary(current_app.config["BASE_URL"])) +@bp.route("/api/packages///", methods=["PUT"]) +@csrf.exempt +@is_package_page +@is_api_authd +def edit_package(token, package): + if not token: + error(401, "Authentication needed") + + return api_edit_package(token, package, request.json) + + @bp.route("/api/tags/") def tags(): return jsonify([tag.getAsDictionary() for tag in Tag.query.all() ]) diff --git a/app/blueprints/api/support.py b/app/blueprints/api/support.py index cd30e627..e29d13e4 100644 --- a/app/blueprints/api/support.py +++ b/app/blueprints/api/support.py @@ -15,7 +15,9 @@ # along with this program. If not, see . -from flask import jsonify, abort, make_response, url_for +from flask import jsonify, abort, make_response, url_for, current_app + +from app.logic.packages import do_edit_package from app.logic.releases import LogicError, do_create_vcs_release, do_create_zip_release from app.logic.screenshots import do_create_screenshot, do_order_screenshots from app.models import APIToken, Package, MinetestRelease, PackageScreenshot @@ -89,3 +91,17 @@ def api_order_screenshots(token: APIToken, package: Package, order: [any]): return jsonify({ "success": True }) + + +def api_edit_package(token: APIToken, package: Package, data: dict, reason: str = "API"): + if not token.canOperateOnPackage(package): + error(403, "API token does not have access to the package") + + reason += ", token=" + token.name + + package = guard(do_edit_package)(token.owner, package, False, data, reason) + + return jsonify({ + "success": True, + "package": package.getAsDictionary(current_app.config["BASE_URL"]) + }) diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index cc50f4a0..77a5d042 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -19,7 +19,7 @@ from urllib.parse import quote as urlescape import flask_menu as menu from celery import uuid -from flask import render_template +from flask import render_template, flash from flask_wtf import FlaskForm from flask_login import login_required from sqlalchemy import or_, func @@ -33,6 +33,8 @@ from app.rediscache import has_key, set_key from app.tasks.importtasks import importRepoScreenshot, checkZipRelease from app.utils import * from . import bp +from ...logic.LogicError import LogicError +from ...logic.packages import do_edit_package @menu.register_menu(bp, ".mods", "Mods", order=11, endpoint_arguments_constructor=lambda: { 'type': 'mod' }) @@ -193,7 +195,6 @@ def shield(package, type): return redirect(url) - @bp.route("/packages///download/") @is_package_page def download(package): @@ -240,7 +241,6 @@ class PackageForm(FlaskForm): @login_required def create_edit(author=None, name=None): package = None - form = None if author is None: form = PackageForm(formdata=request.form) author = request.args.get("author") @@ -301,48 +301,35 @@ def create_edit(author=None, name=None): package.maintainers.append(author) wasNew = True - elif package.name != form.name.data and not package.checkPerm(current_user, Permission.CHANGE_NAME): - flash("Unable to change package name", "danger") - return redirect(url_for("packages.create_edit", author=author, name=name)) + try: + do_edit_package(current_user, package, wasNew, { + "name": form.name.data, + "title": form.title.data, + "short_desc": form.short_desc.data, + "desc": form.desc.data, + "type": form.type.data, + "license": form.license.data, + "media_license": form.media_license.data, + "tags": form.tags.raw_data, + "content_warnings": form.content_warnings.raw_data, + "repo": form.repo.data, + "website": form.website.data, + "issueTracker": form.issueTracker.data, + "forums": form.forums.data, + }) - else: - msg = "Edited {}".format(package.title) + if wasNew and package.repo is not None: + importRepoScreenshot.delay(package.id) - addNotification(package.maintainers, current_user, NotificationType.PACKAGE_EDIT, - msg, package.getDetailsURL(), package) + next_url = package.getDetailsURL() + if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name): + next_url = url_for("flatpage", path="help/wtfpl", r=next_url) + elif wasNew: + next_url = package.getSetupReleasesURL() - severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR - addAuditLog(severity, current_user, msg, package.getDetailsURL(), package) - - form.populate_obj(package) # copy to row - - if package.type == PackageType.TXP: - package.license = package.media_license - - if wasNew and package.type == PackageType.MOD: - m = MetaPackage.GetOrCreate(package.name, {}) - package.provides.append(m) - - package.tags.clear() - for tag in form.tags.raw_data: - package.tags.append(Tag.query.get(tag)) - - package.content_warnings.clear() - for warning in form.content_warnings.raw_data: - package.content_warnings.append(ContentWarning.query.get(warning)) - - db.session.commit() # save - - if wasNew and package.repo is not None: - importRepoScreenshot.delay(package.id) - - next_url = package.getDetailsURL() - if wasNew and ("WTFPL" in package.license.name or "WTFPL" in package.media_license.name): - next_url = url_for("flatpage", path="help/wtfpl", r=next_url) - elif wasNew: - next_url = package.getSetupReleasesURL() - - return redirect(next_url) + return redirect(next_url) + except LogicError as e: + flash(e.message, "danger") package_query = Package.query.filter_by(state=PackageState.APPROVED) if package is not None: diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index 88a9e1a8..3398d071 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -16,14 +16,26 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * `username` - Username of the user authenticated as, null otherwise. * 4xx status codes will be thrown on unsupported authentication type, invalid access token, or other errors. - ## Packages -* GET `/api/packages/` - See [Package Queries](#package-queries) -* GET `/api/scores/` - See [Package Queries](#package-queries) -* GET `/api/packages///` +* GET `/api/packages/` (List) + * See [Package Queries](#package-queries) +* GET `/api/packages///` (Read) +* PUT `/api/packages///` (Update) + * JSON dictionary with any of these keys (all are optional): + * `title`: Human-readable title. + * `short_desc` + * `desc` + * `type`: One of `GAME`, `MOD`, `TXP`. + * `license`: A license name. + * `media_license`: A license name. + * `repo`: Git repo URL. + * `website`: Website URL. + * `issue_tracker`: Issue tracker URL. * GET `/api/packages///dependencies/` * If query argument `only_hard` is present, only hard deps will be returned. +* GET `/api/scores/` + * See [Package Queries](#package-queries) * GET `/api/tags/` - List of: * `name` - technical name * `title` - human-readable title @@ -37,7 +49,6 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * `pop_txp` - popular textures * `pop_game` - popular games * `high_reviewed` - highest reviewed - * `tags` ### Package Queries diff --git a/app/logic/packages.py b/app/logic/packages.py new file mode 100644 index 00000000..a1d69e4c --- /dev/null +++ b/app/logic/packages.py @@ -0,0 +1,73 @@ +# 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 . + + + +from app.logic.LogicError import LogicError +from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, NotificationType, AuditSeverity +from app.utils import addNotification, addAuditLog + + +def do_edit_package(user: User, package: Package, was_new: bool, data: dict, reason: str = None): + if "name" in data and package.name != data["name"] and \ + not package.checkPerm(user, Permission.CHANGE_NAME): + raise LogicError(403, "You do not have permission to change the package name") + + if not package.checkPerm(user, Permission.EDIT_PACKAGE): + raise LogicError(403, "You do not have permission to edit this package") + + for alias, to in { "short_description": "short_desc" }.items(): + if alias in data: + data[to] = data[alias] + + for key in ["name", "title", "short_desc", "desc", "type", "license", "media_license", + "repo", "website", "issueTracker", "forums"]: + if key in data: + setattr(package, key, data[key]) + + if package.type == PackageType.TXP: + package.license = package.media_license + + if was_new and package.type == PackageType.MOD: + m = MetaPackage.GetOrCreate(package.name, {}) + package.provides.append(m) + + package.tags.clear() + + if "tag" in data: + for tag in data["tag"]: + package.tags.append(Tag.query.get(tag)) + + if "content_warnings" in data: + package.content_warnings.clear() + for warning in data["content_warnings"]: + package.content_warnings.append(ContentWarning.query.get(warning)) + + if not was_new: + if reason is None: + msg = "Edited {}".format(package.title) + else: + msg = "Edited {} ({})".format(package.title, reason) + + addNotification(package.maintainers, user, NotificationType.PACKAGE_EDIT, + msg, package.getDetailsURL(), package) + + severity = AuditSeverity.NORMAL if user in package.maintainers else AuditSeverity.EDITOR + addAuditLog(severity, user, msg, package.getDetailsURL(), package) + + db.session.commit() + + return package diff --git a/app/logic/releases.py b/app/logic/releases.py index 8bba0ba5..7190d1b4 100644 --- a/app/logic/releases.py +++ b/app/logic/releases.py @@ -28,7 +28,7 @@ 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") + raise LogicError(403, "You do not have permission to make releases") five_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=5) count = package.releases.filter(PackageRelease.releaseDate > five_minutes_ago).count()