diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index 94aeecd3..92cc1d33 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -20,12 +20,12 @@ from sqlalchemy.sql.expression import func from app import csrf from app.markdown import render_markdown -from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Tags, Permission, ForumTopic, MinetestRelease, APIToken +from app.models import Tag, PackageState, PackageType, Package, db, PackageRelease, Tags, Permission, ForumTopic, MinetestRelease, APIToken, PackageScreenshot 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 +from .support import error, api_create_vcs_release, api_create_zip_release, api_create_screenshot, api_order_screenshots @bp.route("/api/packages/") @@ -147,13 +147,6 @@ def package_dependencies(package): return jsonify(out) -@bp.route("/api/packages///releases/") -@is_package_page -def list_releases(package): - releases = package.releases.filter_by(approved=True).all() - return jsonify([ rel.getAsDictionary() for rel in releases ]) - - @bp.route("/api/topics/") def topics(): qb = QueryBuilder(request.args) @@ -200,6 +193,13 @@ def markdown(): return render_markdown(request.data.decode("utf-8")) +@bp.route("/api/packages///releases/") +@is_package_page +def list_releases(package): + releases = package.releases.filter_by(approved=True).all() + return jsonify([ rel.getAsDictionary() for rel in releases ]) + + @bp.route("/api/packages///releases/new/", methods=["POST"]) @csrf.exempt @is_package_page @@ -233,6 +233,13 @@ def create_release(token, package): return api_create_zip_release(token, package, data["title"], file) +@bp.route("/api/packages///screenshots/") +@is_package_page +def list_screenshots(package): + screenshots = package.screenshots.all() + return jsonify([ss.getAsDictionary(current_app.config["BASE_URL"]) for ss in screenshots]) + + @bp.route("/api/packages///screenshots/new/", methods=["POST"]) @csrf.exempt @is_package_page @@ -253,3 +260,62 @@ def create_screenshot(token: APIToken, package: Package): error(400, "Missing 'file' in multipart body") return api_create_screenshot(token, package, data["title"], file) + + +@bp.route("/api/packages///screenshots//") +@is_package_page +def screenshot(package, id): + ss = PackageScreenshot.query.get(id) + if ss is None or ss.package != package: + error(404, "Screenshot not found") + + return jsonify(ss.getAsDictionary(current_app.config["BASE_URL"])) + + +@bp.route("/api/packages///screenshots//", methods=["DELETE"]) +@csrf.exempt +@is_package_page +@is_api_authd +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") + + if not token: + error(401, "Authentication needed") + + if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS): + error(403, "You do not have the permission to delete screenshots") + + if not token.canOperateOnPackage(package): + error(403, "API token does not have access to the package") + + if package.cover_image == ss: + package.cover_image = None + db.session.merge(package) + + db.session.delete(ss) + db.session.commit() + + return jsonify({ "success": True }) + + +@bp.route("/api/packages///screenshots/order/", methods=["POST"]) +@csrf.exempt +@is_package_page +@is_api_authd +def order_screenshots(token: APIToken, package: Package): + if not token: + error(401, "Authentication needed") + + if not package.checkPerm(token.owner, Permission.ADD_SCREENSHOTS): + error(403, "You do not have the permission to delete screenshots") + + if not token.canOperateOnPackage(package): + error(403, "API token does not have access to the package") + + json = request.json + if json is None or not isinstance(json, list): + error(400, "Expected order body to be array") + + return api_order_screenshots(token, package, request.json) diff --git a/app/blueprints/api/support.py b/app/blueprints/api/support.py index 0e0c6c72..f1d3d155 100644 --- a/app/blueprints/api/support.py +++ b/app/blueprints/api/support.py @@ -17,7 +17,7 @@ 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.logic.screenshots import do_create_screenshot +from app.logic.screenshots import do_create_screenshot, do_order_screenshots from app.models import APIToken, Package, MinetestRelease, PackageScreenshot @@ -69,3 +69,14 @@ def api_create_screenshot(token: APIToken, package: Package, title: str, file): "success": True, "screenshot": ss.getAsDictionary() }) + + +def api_order_screenshots(token: APIToken, package: Package, order: [any]): + if not token.canOperateOnPackage(package): + error(403, "API token does not have access to the package") + + run_safe(do_order_screenshots, token.owner, package, order) + + return jsonify({ + "success": True + }) diff --git a/app/blueprints/packages/screenshots.py b/app/blueprints/packages/screenshots.py index 93b9941f..56999d2f 100644 --- a/app/blueprints/packages/screenshots.py +++ b/app/blueprints/packages/screenshots.py @@ -25,7 +25,7 @@ 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 +from app.logic.screenshots import do_create_screenshot, do_order_screenshots class CreateScreenshotForm(FlaskForm): @@ -61,17 +61,11 @@ def screenshots(package): if request.method == "POST": order = request.form.get("order") if order: - lookup = {} - for screenshot in package.screenshots: - lookup[str(screenshot.id)] = screenshot - - counter = 1 - for id in order.split(","): - lookup[id].order = counter - counter += 1 - - db.session.commit() - return redirect(package.getDetailsURL()) + try: + do_order_screenshots(current_user, package, order.split(",")) + return redirect(package.getDetailsURL()) + except LogicError as e: + flash(e.message, "danger") if form.validate_on_submit(): form.populate_obj(package) diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index 4ad3f5f1..bbd0d378 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -16,9 +16,9 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). ### Misc * GET `/api/whoami/` - Json dictionary with the following keys: - * `is_authenticated` - True on successful API authentication - * `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. + * `is_authenticated` - True on successful API authentication + * `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 @@ -26,70 +26,95 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * GET `/api/scores/` - See [Package Queries](#package-queries) * GET `/api/packages///` * GET `/api/packages///dependencies/` - * If query argument `only_hard` is present, only hard deps will be returned. + * If query argument `only_hard` is present, only hard deps will be returned. * GET `/api/tags/` - List of: - * `name` - technical name - * `title` - human-readable title - * `description` - tag description or null + * `name` - technical name + * `title` - human-readable title + * `description` - tag description or null * GET `/api/homepage/` - * `count` - number of packages - * `downloads` - get number of downloads - * `new` - new packages - * `updated` - recently updated packages - * `pop_mod` - popular mods - * `pop_txp` - popular textures - * `pop_game` - popular games - * `high_reviewed` - highest reviewed + * `count` - number of packages + * `downloads` - get number of downloads + * `new` - new packages + * `updated` - recently updated packages + * `pop_mod` - popular mods + * `pop_txp` - popular textures + * `pop_game` - popular games + * `high_reviewed` - highest reviewed ### Releases * GET `/api/packages///releases/` (List) * POST `/api/packages///releases/new/` (Create) - * Requires authentication. - * Body is multipart form if zip upload, JSON otherwise. - * `title`: human-readable name of the release. - * 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/). + * Requires authentication. + * Body is multipart form if zip upload, JSON otherwise. + * `title`: human-readable name of the release. + * 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/). Examples: ```bash # Create release from Git curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \ - -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ - -d "{\"method\": \"git\", \"title\": \"My Release\", \"ref\": \"master\" }" + -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ + -d "{\"method\": \"git\", \"title\": \"My Release\", \"ref\": \"master\" }" # Create release from zip upload curl https://content.minetest.net/api/packages/username/name/releases/new/ \ - -H "Authorization: Bearer YOURTOKEN" \ - -F title="My Release" -F file=@path/to/file.zip + -H "Authorization: Bearer YOURTOKEN" \ + -F title="My Release" -F file=@path/to/file.zip ``` ### Screenshots +* GET `/api/packages///screenshots/` (List) + * Returns array of screenshot dictionaries with keys: + * `id`: screenshot ID + * `approved`: true if approved and visible. + * `title`: human-readable name for the screenshot, shown as a caption and alt text. + * `url`: absolute URL to screenshot. + * `order`: Number used in ordering. +* GET `/api/packages///screenshots//` (Read) + * Returns screenshot dictionary like above. * POST `/api/packages///screenshots/new/` (Create) - * Requires authentication. - * Body is multipart form data. - * `title`: human-readable name for the screenshot, shown as a caption and alt text. - * `file`: multipart file to upload, like ``. + * Requires authentication. + * Body is multipart form data. + * `title`: human-readable name for the screenshot, shown as a caption and alt text. + * `file`: multipart file to upload, like ``. +* DELETE `/api/packages///screenshots//` (Delete) + * Requires authentication. + * Deletes screenshot. +* POST `/api/packages///screenshots/order/` + * Requires authentication. + * Body is a JSON array containing the screenshot IDs in their order. -Example: +Examples: ```bash +# Create screenshots curl https://content.minetest.net/api/packages/username/name/screenshots/new/ \ - -H "Authorization: Bearer YOURTOKEN" \ - -F title="My Release" -F file=@path/to/screnshot.png + -H "Authorization: Bearer YOURTOKEN" \ + -F title="My Release" -F file=@path/to/screnshot.png + +# Delete screenshot +curl -X DELETE https://content.minetest.net/api/packages/username/name/screenshots/3/ \ + -H "Authorization: Bearer YOURTOKEN" + +# Reorder screenshots +curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/order/ \ + -H "Authorization: Bearer YOURTOKEN" -H "Content-Type: application/json" \ + -d "[13, 2, 5, 7]" ``` ### Topics * GET `/api/topics/` - Supports [Package Queries](#package-queries), and the following two options: - * `show_added` - Show topics which exist as packages, default true. - * `show_discarded` - Show topics which have been marked as outdated, default false. + * `show_added` - Show topics which exist as packages, default true. + * `show_discarded` - Show topics which have been marked as outdated, default false. ### Minetest @@ -100,7 +125,7 @@ curl https://content.minetest.net/api/packages/username/name/screenshots/new/ \ Example: - /api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore + /api/packages/?type=mod&type=game&q=mobs+fun&hide=nonfree&hide=gore Supported query parameters: @@ -116,15 +141,15 @@ Supported query parameters: * `protocol_version` - Only show packages supported by this Minetest protocol version. * `engine_version` - Only show packages supported by this Minetest engine version, eg: `5.3.0`. * `fmt` - How the response is formated. - * `keys` - author/name only. - * `short` - stuff needed for the Minetest client. + * `keys` - author/name only. + * `short` - stuff needed for the Minetest client. ## Topic Queries Example: - /api/topics/?q=mobs + /api/topics/?q=mobs Supported query parameters: diff --git a/app/logic/screenshots.py b/app/logic/screenshots.py index bfc69abf..f3d431d7 100644 --- a/app/logic/screenshots.py +++ b/app/logic/screenshots.py @@ -1,5 +1,6 @@ from werkzeug.exceptions import abort +from app.logic.LogicError import LogicError from app.logic.uploads import upload_file from app.models import User, Package, PackageScreenshot, Permission, NotificationType, db from app.utils import addNotification @@ -27,3 +28,21 @@ def do_create_screenshot(user: User, package: Package, title: str, file): db.session.commit() return ss + + +def do_order_screenshots(_user: User, package: Package, order: [any]): + lookup = {} + for screenshot in package.screenshots.all(): + lookup[screenshot.id] = screenshot + + counter = 1 + for id in order: + try: + lookup[int(id)].order = counter + counter += 1 + except KeyError as e: + raise LogicError(400, "Unable to find screenshot with id={}".format(id)) + except ValueError as e: + raise LogicError(400, "Invalid number: {}".format(id)) + + db.session.commit() diff --git a/app/models/packages.py b/app/models/packages.py index 91685083..e67ea8e0 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -909,11 +909,12 @@ class PackageScreenshot(db.Model): def getThumbnailURL(self, level=2): return self.url.replace("/uploads/", "/thumbnails/{:d}/".format(level)) - def getAsDictionary(self): + def getAsDictionary(self, base_url=""): return { "id": self.id, + "order": self.order, "title": self.title, - "url": self.url, + "url": base_url + self.url, "approved": self.approved, }