mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-12 15:03:55 +09:00
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>
280 lines
8.7 KiB
Python
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
|