diff --git a/appstore/dev_portal_api.py b/appstore/dev_portal_api.py index a04a4f7..d222dbd 100644 --- a/appstore/dev_portal_api.py +++ b/appstore/dev_portal_api.py @@ -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/developer_portal_api.py b/appstore/developer_portal_api.py index b4b4c7e..3f03aff 100644 --- a/appstore/developer_portal_api.py +++ b/appstore/developer_portal_api.py @@ -1,18 +1,19 @@ import json import traceback import datetime +import uuid from algoliasearch import algoliasearch from flask import Blueprint, jsonify, abort, request 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 -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,106 @@ 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(): + # 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"), 401 + + 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. 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): + 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/models.py b/appstore/models.py index f0a7286..9bf0bdd 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): 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() 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..fa64c21 --- /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')