diff --git a/app/blueprints/api/endpoints.py b/app/blueprints/api/endpoints.py index 92cc1d3..a5523d3 100644 --- a/app/blueprints/api/endpoints.py +++ b/app/blueprints/api/endpoints.py @@ -83,9 +83,6 @@ def homepage(): downloads_result = db.session.query(func.sum(Package.downloads)).one_or_none() downloads = 0 if not downloads_result or not downloads_result[0] else downloads_result[0] - tags = db.session.query(func.count(Tags.c.tag_id), Tag) \ - .select_from(Tag).outerjoin(Tags).group_by(Tag.id).order_by(db.asc(Tag.title)).all() - def mapPackages(packages): return [pkg.getAsDictionaryKey() for pkg in packages] @@ -97,7 +94,7 @@ def homepage(): "pop_mod": mapPackages(pop_mod), "pop_txp": mapPackages(pop_txp), "pop_game": mapPackages(pop_gam), - "high_reviewed": mapPackages(high_reviewed), + "high_reviewed": mapPackages(high_reviewed) } @@ -113,9 +110,6 @@ def resolve_package_deps(out, package, only_hard): if only_hard and dep.optional: continue - name = None - fulfilled_by = None - if dep.package: name = dep.package.name fulfilled_by = [ dep.package.getId() ] @@ -174,7 +168,7 @@ def topic_set_discard(): @bp.route("/api/minetest_versions/") def versions(): - return jsonify([{ "name": rel.name, "protocol_version": rel.protocol }\ + return jsonify([rel.getAsDictionary() \ for rel in MinetestRelease.query.all() if rel.getActual() is not None]) @@ -196,8 +190,7 @@ def markdown(): @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 ]) + return jsonify([ rel.getAsDictionary() for rel in package.releases.all() ]) @bp.route("/api/packages///releases/new/", methods=["POST"]) @@ -215,14 +208,11 @@ def create_release(token, package): if "title" not in data: error(400, "Title is required in the POST data") - if request.json: + if data.get("method") == "git": for option in ["method", "ref"]: if option not in data: error(400, option + " is required in the POST data") - if data["method"].lower() != "git": - error(400, "Release-creation methods other than git are not supported") - return api_create_vcs_release(token, package, data["title"], data["ref"]) elif request.files: @@ -232,6 +222,43 @@ def create_release(token, package): return api_create_zip_release(token, package, data["title"], file) + else: + error(400, "Unknown release-creation method. Specify the method or provide a file.") + + +@bp.route("/api/packages///releases//") +@is_package_page +def release(package: Package, id: int): + release = PackageRelease.query.get(id) + if release is None or release.package != package: + error(404, "Release not found") + + return jsonify(release.getAsDictionary()) + + +@bp.route("/api/packages///releases//", methods=["DELETE"]) +@csrf.exempt +@is_package_page +@is_api_authd +def delete_release(token: APIToken, package: Package, id: int): + release = PackageRelease.query.get(id) + if release is None or release.package != package: + error(404, "Release not found") + + if not token: + error(401, "Authentication needed") + + if not token.canOperateOnPackage(package): + error(403, "API token does not have access to the package") + + if not release.checkPerm(token.owner, Permission.DELETE_RELEASE): + error(403, "Unable to delete the release, make sure there's a newer release available") + + db.session.delete(release) + db.session.commit() + + return jsonify({"success": True}) + @bp.route("/api/packages///screenshots/") @is_package_page diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index bbd0d37..2e51cb4 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -15,7 +15,7 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). ### Misc -* GET `/api/whoami/` - Json dictionary with the following keys: +* 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. @@ -40,13 +40,24 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * `pop_txp` - popular textures * `pop_game` - popular games * `high_reviewed` - highest reviewed + * `tags` ### Releases * GET `/api/packages///releases/` (List) + * Returns array of release dictionaries with keys: + * `id`: release ID + * `title`: human-readable title + * `release_date`: Date released + * `url`: download URL + * `commit`: commit hash or null + * `downloads`: number of downloads + * `min_minetest_version`: dict or null, minimum supported minetest version (inclusive). + * `max_minetest_version`: dict or null, minimum supported minetest version (inclusive). +* GET `/api/packages///releases//` (Read) * POST `/api/packages///releases/new/` (Create) * Requires authentication. - * Body is multipart form if zip upload, JSON otherwise. + * Body can be JSON or multipart form data. Zip uploads must be multipart form data. * `title`: human-readable name of the release. * For Git release creation: * `method`: must be `git`. @@ -54,7 +65,10 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * 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/). - +* DELETE `/api/packages///releases//` (Delete) + * Requires authentication. + * Deletes release. + Examples: ```bash @@ -64,9 +78,13 @@ curl -X POST https://content.minetest.net/api/packages/username/name/releases/ne -d "{\"method\": \"git\", \"title\": \"My Release\", \"ref\": \"master\" }" # Create release from zip upload -curl https://content.minetest.net/api/packages/username/name/releases/new/ \ +curl -X POST https://content.minetest.net/api/packages/username/name/releases/new/ \ -H "Authorization: Bearer YOURTOKEN" \ -F title="My Release" -F file=@path/to/file.zip + +# Delete release +curl -X DELETE https://content.minetest.net/api/packages/username/name/releases/3/ \ + -H "Authorization: Bearer YOURTOKEN" ``` ### Screenshots @@ -96,7 +114,7 @@ Examples: ```bash # Create screenshots -curl https://content.minetest.net/api/packages/username/name/screenshots/new/ \ +curl -X POST https://content.minetest.net/api/packages/username/name/screenshots/new/ \ -H "Authorization: Bearer YOURTOKEN" \ -F title="My Release" -F file=@path/to/screnshot.png diff --git a/app/logic/screenshots.py b/app/logic/screenshots.py index f3d431d..2f4334c 100644 --- a/app/logic/screenshots.py +++ b/app/logic/screenshots.py @@ -1,4 +1,4 @@ -from werkzeug.exceptions import abort +import datetime from app.logic.LogicError import LogicError from app.logic.uploads import upload_file @@ -7,10 +7,15 @@ from app.utils import addNotification def do_create_screenshot(user: User, package: Package, title: str, file): + thirty_minutes_ago = datetime.datetime.now() - datetime.timedelta(minutes=30) + count = package.screenshots.filter(PackageScreenshot.created_at > thirty_minutes_ago).count() + if count >= 20: + raise LogicError(429, "Too many requests, please wait before trying again") + uploaded_url, uploaded_path = upload_file(file, "image", "a PNG or JPG image file") counter = 1 - for screenshot in package.screenshots: + for screenshot in package.screenshots.all(): screenshot.order = counter counter += 1 diff --git a/app/models/packages.py b/app/models/packages.py index e67ea8e..6f168da 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -759,6 +759,13 @@ class MinetestRelease(db.Model): def getActual(self): return None if self.name == "None" else self + def getAsDictionary(self): + return { + "name": self.name, + "protocol_version": self.protocol, + "is_dev": "-dev" in self.name, + } + @classmethod def get(cls, version, protocol_num): if version: @@ -810,8 +817,8 @@ class PackageRelease(db.Model): "release_date": self.releaseDate.isoformat(), "commit": self.commit_hash, "downloads": self.downloads, - "min_protocol": self.min_rel and self.min_rel.protocol, - "max_protocol": self.max_rel and self.max_rel.protocol + "min_minetest_version": self.min_rel and self.min_rel.getAsDictionary(), + "max_minetest_version": self.max_rel and self.max_rel.getAsDictionary(), } def getEditURL(self):