Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions appstore/dev_portal_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ def my_apps():
'authName': me["name"],
'applications': [],
'needsSetup': True,
'hasDeployKey': False,
'deployKeyLastUsed': 0,
'w': me["is_wizard"],
})
else:
Expand All @@ -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"],
})
Expand Down
107 changes: 104 additions & 3 deletions appstore/developer_portal_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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/<app_id>', methods=['DELETE'])
def wizard_delete_app(app_id):
if not user_is_wizard():
Expand Down Expand Up @@ -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)
Comment on lines +806 to +808
Copy link

Copilot AI Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deploy key last used time is being updated but the session is not committed until after the release is created. If the release creation fails, the last used time will still be updated. Consider moving this update after successful release creation or using a separate transaction.

Suggested change
# 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)
# Prepare developer object for later update
dev = Developer.query.filter_by(id=app.developer_id).one()

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will take a look


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
Expand Down
2 changes: 2 additions & 0 deletions appstore/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
10 changes: 9 additions & 1 deletion appstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions migrations/versions/e56d904098e5_add_deploy_key_col.py
Original file line number Diff line number Diff line change
@@ -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')