From fde725531cb8069fd3b45fc454c7086745c830ee Mon Sep 17 00:00:00 2001 From: Willow Systems Date: Mon, 4 Aug 2025 14:13:32 +0100 Subject: [PATCH 1/7] Update DB to support deploy keys --- appstore/dev_portal_api.py | 6 ++++- appstore/models.py | 4 ++- .../e56d904098e5_add_deploy_key_col.py | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/e56d904098e5_add_deploy_key_col.py diff --git a/appstore/dev_portal_api.py b/appstore/dev_portal_api.py index a04a4f7..9770161 100644 --- a/appstore/dev_portal_api.py +++ b/appstore/dev_portal_api.py @@ -1,4 +1,4 @@ -from algoliasearch import algoliasearch +#from algoliasearch import algoliasearch from flask import Blueprint, jsonify, abort, request from flask_cors import CORS from werkzeug.exceptions import BadRequest @@ -67,6 +67,8 @@ def my_apps(): 'authName': me["name"], 'applications': [], 'needsSetup': True, + 'hasDeployKey': False, + 'deployKeyLastUsed': 0, 'w': me["is_wizard"], }) else: @@ -77,6 +79,8 @@ def my_apps(): 'authName': me["name"], 'applications': my_appdata, 'name': developer.name, + 'hasDeployKey': developer.deploy_key is not None, + 'deployKeyLastUsed': developer.deploy_key_last_used if developer.deploy_key_last_used is not None else 0, 'needsSetup': False, 'w': me["is_wizard"], }) diff --git a/appstore/models.py b/appstore/models.py index f0a7286..a3ebf77 100644 --- a/appstore/models.py +++ b/appstore/models.py @@ -12,6 +12,8 @@ class Developer(db.Model): __tablename__ = "developers" id = db.Column(db.String(24), primary_key=True) name = db.Column(db.String) + deploy_key = db.Column(db.String) + deploy_key_last_used = db.Column(db.DateTime) class HomeBanners(db.Model): @@ -126,7 +128,7 @@ class Release(db.Model): app = db.relationship('App', back_populates='releases') binaries = db.relationship('Binary', back_populates='release', - collection_class=attribute_mapped_collection('platform'), + collection_class=attribute_mapped_collection('platform', ignore_unpopulated_attribute=True), lazy='selectin') has_pbw = db.Column(db.Boolean()) capabilities = db.Column(ARRAY(db.String)) diff --git a/migrations/versions/e56d904098e5_add_deploy_key_col.py b/migrations/versions/e56d904098e5_add_deploy_key_col.py new file mode 100644 index 0000000..a7a84d2 --- /dev/null +++ b/migrations/versions/e56d904098e5_add_deploy_key_col.py @@ -0,0 +1,25 @@ +"""Add deploy key col + +Revision ID: e56d904098e5 +Revises: c4e0470dc040 +Create Date: 2025-07-31 23:32:55.499315 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e56d904098e5' +down_revision = 'c4e0470dc040' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('developers', sa.Column('deploy_key', sa.String(), nullable=True)) + op.add_column('developers', sa.Column('deploy_key_last_used', sa.DateTime(), nullable=True)) + +def downgrade(): + op.drop_column('developers', 'deploy_key') + # op.drop_column('developers', 'deploy_key_last_used') From 6622f1ebac59fe2cbcbc7cb419d65c8a56613fc8 Mon Sep 17 00:00:00 2001 From: Willow Systems Date: Mon, 4 Aug 2025 14:18:05 +0100 Subject: [PATCH 2/7] Add deploy keys --- appstore/developer_portal_api.py | 101 ++++++++++++++++++++++++++++++- appstore/utils.py | 10 ++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/appstore/developer_portal_api.py b/appstore/developer_portal_api.py index b4b4c7e..f74ae8f 100644 --- a/appstore/developer_portal_api.py +++ b/appstore/developer_portal_api.py @@ -1,6 +1,7 @@ import json import traceback import datetime +import uuid from algoliasearch import algoliasearch from flask import Blueprint, jsonify, abort, request @@ -12,7 +13,7 @@ from sqlalchemy.exc import DataError from zipfile import BadZipFile -from .utils import authed_request, demand_authed_request, get_uid, id_generator, validate_new_app_fields, is_valid_category, is_valid_appinfo, is_valid_platform, clone_asset_collection_without_images, is_valid_image_file, is_valid_image_size, get_max_image_dimensions, generate_image_url, is_users_developer_id, user_is_wizard, newAppValidationException, algolia_app, first_version_is_newer +from .utils import authed_request, demand_authed_request, get_uid, id_generator, validate_new_app_fields, is_valid_category, is_valid_appinfo, is_valid_platform, clone_asset_collection_without_images, is_valid_image_file, is_valid_image_size, get_max_image_dimensions, generate_image_url, is_users_developer_id, user_is_wizard, newAppValidationException, algolia_app, first_version_is_newer, is_valid_deploy_key_for_app from .models import Category, db, App, Developer, Release, CompanionApp, Binary, AssetCollection, LockerEntry, UserLike from .pbw import PBW, release_from_pbw from .s3 import upload_pbw, upload_asset @@ -673,7 +674,7 @@ def wizard_update_app(app_id): else: return jsonify(error="Invalid POST body. Provide one or more fields to update", e="body.invalid"), 400 - + @devportal_api.route('/wizard/app/', methods=['DELETE']) def wizard_delete_app(app_id): if not user_is_wizard(): @@ -726,6 +727,102 @@ def wizard_get_s3_assets(app_id): return jsonify(images = images, pbws = pbws) +@devportal_api.route("/deploykey", methods=['POST']) +def deploy_key(): + try: + req = request.json + except BadRequest as e: + return jsonify(error="Invalid POST body. Expected JSON", e="body.invalid"), 400 + if req is None: + return jsonify(error="Invalid POST body. Expected JSON", e="body.invalid"), 400 + + if not "operation" in req: + return jsonify(error="Missing required field: operation", e="missing.field.operation"), 400 + + if req["operation"] == "regenerate": + + result = demand_authed_request('GET', f"{config['REBBLE_AUTH_URL']}/api/v1/me/pebble/appstore") + me = result.json() + + try: + developer = Developer.query.filter_by(id=me['id']).one() + except NoResultFound: + return jsonify(error="No developer account associated with user", e="setup.required"), 400 + + new_deploy_key = str(uuid.uuid4()) + developer.deploy_key = new_deploy_key + developer.deploy_key_last_used = None + db.session.commit() + + return jsonify(new_key=new_deploy_key) + + else: + return jsonify(error="Unknown operation requested", e="operation.invalid"), 400 + +@devportal_api.route('/deploy', methods=['POST']) +def submit_new_release_via_deploy(): + if not request.headers.get("x-deploy-key"): + return jsonify(error="No x-deploy-key header found", e="permission.denied"), 403 + + data = dict(request.form) + + if "pbw" not in request.files: + return jsonify(error="Missing file: pbw", e="pbw.missing"), 400 + + if "release_notes" not in data: + return jsonify(error="Missing field: release_notes", e="release_notes.missing"), 400 + + pbw_file = request.files['pbw'].read() + + try: + pbw = PBW(pbw_file, 'aplite') + with pbw.zip.open('appinfo.json') as f: + appinfo = json.load(f) + except BadZipFile as e: + return jsonify(error=f"Your pbw file is invalid or corrupted", e="invalid.pbw"), 400 + except KeyError as e: + return jsonify(error=f"Your pbw file is invalid or corrupted", e="invalid.pbw"), 400 + + appinfo_valid, appinfo_valid_reason = is_valid_appinfo(appinfo) + if not appinfo_valid: + return jsonify(error=f"The appinfo.json in your pbw file has the following error: {appinfo_valid_reason}", e="invalid.appinfocontent"), 400 + + uuid = appinfo['uuid'] + version = appinfo['versionLabel'] + + try: + app = App.query.filter(App.app_uuid == uuid).one() + except NoResultFound: + return jsonify(error="Unknown app", e="app.notfound"), 400 + + # Check we own the app + if not is_valid_deploy_key_for_app(request.headers.get("x-deploy-key"), app): + return jsonify(error="You do not have permission to modify that app", e="permission.denied"), 403 + + # Update last used time + dev = Developer.query.filter_by(id=app.developer_id).one() + dev.deploy_key_last_used = datetime.datetime.now(datetime.timezone.utc) + + release_old = Release.query.filter_by(app=app).order_by(Release.published_date.desc()).first() + + if not first_version_is_newer(version, release_old.version): + return jsonify( + error=f"The version ({version}) is already on the appstore", + e="version.exists", + message="The app version in appinfo.json is not greater than the latest release on the store. Please increment versionLabel in your appinfo.json and try again." + ), 400 + + release_new = release_from_pbw(app, pbw_file, + release_notes=data["release_notes"], + published_date=datetime.datetime.utcnow(), + version=version, + compatibility=appinfo.get('targetPlatforms', ['aplite', 'basalt', 'diorite', 'emery'])) + + upload_pbw(release_new, request.files['pbw']) + db.session.commit() + + return jsonify(success=True) + def init_app(app, url_prefix='/api/dp'): global parent_app diff --git a/appstore/utils.py b/appstore/utils.py index 699fff2..14530dc 100644 --- a/appstore/utils.py +++ b/appstore/utils.py @@ -15,7 +15,8 @@ import beeline from .settings import config -from appstore.models import App, AssetCollection, CompanionApp +from appstore.models import App, AssetCollection, CompanionApp, Developer +from sqlalchemy.orm.exc import NoResultFound parent_app = None @@ -460,6 +461,13 @@ def is_users_developer_id(developer_id): else: return True +def is_valid_deploy_key_for_app(deploy_key, app_obj): + try: + dev = Developer.query.filter_by(deploy_key=deploy_key).one() + return app_obj.developer_id == dev.id + except NoResultFound: + return False + def user_is_wizard(): result = demand_authed_request('GET', f"{config['REBBLE_AUTH_URL']}/api/v1/me") me = result.json() From 527bb3b6c0b96924dd28ddeefa90d5718a6c9717 Mon Sep 17 00:00:00 2001 From: Willow Systems Date: Mon, 4 Aug 2025 14:25:08 +0100 Subject: [PATCH 3/7] Reenable aloglia --- appstore/dev_portal_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstore/dev_portal_api.py b/appstore/dev_portal_api.py index 9770161..d222dbd 100644 --- a/appstore/dev_portal_api.py +++ b/appstore/dev_portal_api.py @@ -1,4 +1,4 @@ -#from algoliasearch import algoliasearch +from algoliasearch import algoliasearch from flask import Blueprint, jsonify, abort, request from flask_cors import CORS from werkzeug.exceptions import BadRequest From 5eae3d62b1218142b41904285dfb81ddc5a1b1c8 Mon Sep 17 00:00:00 2001 From: Willow Systems Date: Mon, 11 Aug 2025 15:29:43 +0100 Subject: [PATCH 4/7] 401 instead of 403 where appropriate --- appstore/developer_portal_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/appstore/developer_portal_api.py b/appstore/developer_portal_api.py index f74ae8f..a9831ea 100644 --- a/appstore/developer_portal_api.py +++ b/appstore/developer_portal_api.py @@ -761,8 +761,10 @@ def deploy_key(): @devportal_api.route('/deploy', methods=['POST']) def submit_new_release_via_deploy(): + # Todo: Merge this with the publish release endpoint + if not request.headers.get("x-deploy-key"): - return jsonify(error="No x-deploy-key header found", e="permission.denied"), 403 + return jsonify(error="No X-Deploy-Key header found", e="permission.denied"), 401 data = dict(request.form) From accdf3a171c10a18f45447df5747b5b646fb45d5 Mon Sep 17 00:00:00 2001 From: Willow Systems Date: Mon, 11 Aug 2025 15:32:51 +0100 Subject: [PATCH 5/7] Do not ignore unpopulated attrs --- appstore/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appstore/models.py b/appstore/models.py index a3ebf77..9bf0bdd 100644 --- a/appstore/models.py +++ b/appstore/models.py @@ -128,7 +128,7 @@ class Release(db.Model): app = db.relationship('App', back_populates='releases') binaries = db.relationship('Binary', back_populates='release', - collection_class=attribute_mapped_collection('platform', ignore_unpopulated_attribute=True), + collection_class=attribute_mapped_collection('platform'), lazy='selectin') has_pbw = db.Column(db.Boolean()) capabilities = db.Column(ARRAY(db.String)) From f8cbf6bc238daa5b1ebe2db417d1e4e64a1095eb Mon Sep 17 00:00:00 2001 From: Willow Systems Date: Mon, 11 Aug 2025 15:40:41 +0100 Subject: [PATCH 6/7] Handle the rare use case of legacy apps with non-unique pbw uuids --- appstore/developer_portal_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/appstore/developer_portal_api.py b/appstore/developer_portal_api.py index a9831ea..3f03aff 100644 --- a/appstore/developer_portal_api.py +++ b/appstore/developer_portal_api.py @@ -8,7 +8,7 @@ from flask_cors import CORS from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from werkzeug.exceptions import BadRequest from sqlalchemy.exc import DataError from zipfile import BadZipFile @@ -795,7 +795,9 @@ def submit_new_release_via_deploy(): try: app = App.query.filter(App.app_uuid == uuid).one() except NoResultFound: - return jsonify(error="Unknown app", e="app.notfound"), 400 + return jsonify(error="Unknown app. To submit a new app to the appstore for the first time, please use dev-portal.rebble.io", e="app.notfound"), 400 + except MultipleResultsFound: + return jsonify(error="You cannot use deploy keys with this app. You must submit a release manually through dev-portal.rebble.io", e="app.noteligible"), 400 # Check we own the app if not is_valid_deploy_key_for_app(request.headers.get("x-deploy-key"), app): From 3e19b74919c9d78237effba7a6a605e02399c2d7 Mon Sep 17 00:00:00 2001 From: Will0 Date: Mon, 11 Aug 2025 16:24:59 +0100 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- migrations/versions/e56d904098e5_add_deploy_key_col.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/e56d904098e5_add_deploy_key_col.py b/migrations/versions/e56d904098e5_add_deploy_key_col.py index a7a84d2..fa64c21 100644 --- a/migrations/versions/e56d904098e5_add_deploy_key_col.py +++ b/migrations/versions/e56d904098e5_add_deploy_key_col.py @@ -22,4 +22,4 @@ def upgrade(): def downgrade(): op.drop_column('developers', 'deploy_key') - # op.drop_column('developers', 'deploy_key_last_used') + op.drop_column('developers', 'deploy_key_last_used')