Files
ServerKit/backend/app/api/private_urls.py
T
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.

**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:

  DELETE   containers down (VOLUMES KEPT), vhost removed, firewall closed,
           cron suspended, row tombstoned. All reversible.
  RESTORE  vhost rewritten, firewall reopened, cron resumed. Containers left
           STOPPED -- "put it back" must not silently start processes or open
           ports, the same reasoning that stops domain restore re-issuing certs.
  PURGE    volumes removed, uploaded source deleted, cron detached.

register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.

A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.

**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.

**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().

Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.

Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.

Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:26:04 -04:00

280 lines
8.7 KiB
Python

# Bucket: PER-APP (plan 29 #9). Private-URL routes gate on the shared app-access
# seam (can_access_app for reads, can_edit_app for mutations).
"""API endpoints for managing private URLs for applications."""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app import db
from app.models import Application, User
from app.services.private_url_service import PrivateURLService
from app.services.nginx_service import NginxService
from app.services.resource_grant_service import ResourceGrantService
private_urls_bp = Blueprint('private_urls', __name__)
@private_urls_bp.route('/<int:app_id>/private-url', methods=['POST'])
@jwt_required()
def enable_private_url(app_id):
"""Enable private URL for an application and generate/set slug.
Request body (optional):
{
"slug": "custom-slug" // Optional custom slug
}
Returns:
JSON with private_slug and private_url
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
# Check if already enabled
if app.private_url_enabled:
return jsonify({
'error': 'Private URL is already enabled',
'private_slug': app.private_slug,
'private_url': f'/p/{app.private_slug}'
}), 400
data = request.get_json() or {}
custom_slug = data.get('slug')
if custom_slug:
# Validate custom slug
is_valid, error = PrivateURLService.validate_slug(custom_slug)
if not is_valid:
return jsonify({'error': error}), 400
if not PrivateURLService.is_slug_available(custom_slug, exclude_app_id=app_id):
return jsonify({'error': 'Slug is already in use'}), 409
slug = custom_slug
else:
# Generate unique slug
slug = PrivateURLService.generate_unique_slug()
if not slug:
return jsonify({'error': 'Could not generate unique slug'}), 500
app.private_slug = slug
app.private_url_enabled = True
db.session.commit()
# Update Nginx config if app has port
nginx_result = None
if app.port:
nginx_result = NginxService.update_private_url_config(app)
return jsonify({
'message': 'Private URL enabled',
'private_slug': slug,
'private_url': f'/p/{slug}',
'nginx_updated': nginx_result.get('success') if nginx_result else None
}), 200
@private_urls_bp.route('/<int:app_id>/private-url', methods=['GET'])
@jwt_required()
def get_private_url(app_id):
"""Get private URL info for an application.
Returns:
JSON with private_slug, private_url_enabled, and private_url
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_access_app(user, app):
return jsonify({'error': 'Access denied'}), 403
return jsonify({
'private_url_enabled': app.private_url_enabled,
'private_slug': app.private_slug,
'private_url': f'/p/{app.private_slug}' if app.private_slug else None
}), 200
@private_urls_bp.route('/<int:app_id>/private-url', methods=['PUT'])
@jwt_required()
def update_private_url(app_id):
"""Update the private URL slug for an application.
Request body:
{
"slug": "new-custom-slug"
}
Returns:
JSON with updated private_slug and private_url
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
if not app.private_url_enabled:
return jsonify({'error': 'Private URL is not enabled for this app'}), 400
data = request.get_json()
if not data or 'slug' not in data:
return jsonify({'error': 'Slug is required'}), 400
new_slug = data['slug']
# Validate
is_valid, error = PrivateURLService.validate_slug(new_slug)
if not is_valid:
return jsonify({'error': error}), 400
if not PrivateURLService.is_slug_available(new_slug, exclude_app_id=app_id):
return jsonify({'error': 'Slug is already in use'}), 409
old_slug = app.private_slug
app.private_slug = new_slug
db.session.commit()
# Update Nginx config
nginx_result = None
if app.port:
nginx_result = NginxService.update_private_url_config(app, old_slug=old_slug)
return jsonify({
'message': 'Private URL updated',
'private_slug': new_slug,
'private_url': f'/p/{new_slug}',
'nginx_updated': nginx_result.get('success') if nginx_result else None
}), 200
@private_urls_bp.route('/<int:app_id>/private-url', methods=['DELETE'])
@jwt_required()
def disable_private_url(app_id):
"""Disable private URL for an application.
Returns:
JSON with success message
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
if not app.private_url_enabled:
return jsonify({'error': 'Private URL is not enabled for this app'}), 400
old_slug = app.private_slug
app.private_slug = None
app.private_url_enabled = False
db.session.commit()
# Remove from Nginx config
nginx_result = None
if old_slug:
nginx_result = NginxService.remove_private_url_config(old_slug)
return jsonify({
'message': 'Private URL disabled',
'nginx_updated': nginx_result.get('success') if nginx_result else None
}), 200
@private_urls_bp.route('/<int:app_id>/private-url/regenerate', methods=['POST'])
@jwt_required()
def regenerate_private_url(app_id):
"""Generate a new random slug for an application.
Returns:
JSON with new private_slug and private_url
"""
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not ResourceGrantService.can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
if not app.private_url_enabled:
return jsonify({'error': 'Private URL is not enabled for this app'}), 400
old_slug = app.private_slug
new_slug = PrivateURLService.generate_unique_slug()
if not new_slug:
return jsonify({'error': 'Could not generate unique slug'}), 500
app.private_slug = new_slug
db.session.commit()
# Update Nginx config
nginx_result = None
if app.port:
nginx_result = NginxService.update_private_url_config(app, old_slug=old_slug)
return jsonify({
'message': 'Private URL regenerated',
'private_slug': new_slug,
'private_url': f'/p/{new_slug}',
'nginx_updated': nginx_result.get('success') if nginx_result else None
}), 200
# =============================================================================
# Public Endpoint - No Authentication Required
# =============================================================================
@private_urls_bp.route('/p/<slug>', methods=['GET'])
@jwt_required()
def resolve_private_url(slug):
"""Resolve a private URL slug to app info.
This endpoint is for API consumers that need to look up which app
a private URL points to. The actual proxying is handled by Nginx.
Args:
slug: The private URL slug
Returns:
JSON with app_id, app_name, port, and status
"""
# Slug-keyed: a tombstone still holds its slug (that reservation is what
# keeps a restore from colliding), so an unfiltered .first() could resolve
# /p/<slug> to a deleted app and hand out its port.
app = Application.query_active().filter_by(
private_slug=slug,
private_url_enabled=True
).first()
if not app:
return jsonify({'error': 'Not found'}), 404
return jsonify({
'app_id': app.id,
'app_name': app.name,
'port': app.port,
'status': app.status
}), 200