# Bucket: PER-APP. Per-app domain routes gate on the shared # app-access seam (can_access_app for reads, can_edit_app for mutations); # host-level DNS/nginx surfaces stay admin-only. import re from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from app import db from app.middleware.rbac import admin_required from app.models import Domain, Application, User from app.services.nginx_service import NginxService from app.services.ssl_service import SSLService, get_acme_contact, remember_acme_contact from app.services.resource_grant_service import ResourceGrantService from app.exceptions import already_exists, not_found, permission_denied from app.api._request import json_object domains_bp = Blueprint('domains', __name__) def _publish_app_vhost(app): """(Re)write and enable an app's vhost from its LIVE domains. One seam for both add and remove, so the two can never disagree about what the served config should look like. Returns the shape these routes already put under the response's ``nginx`` key; never raises. """ from app.services.site_domain_service import SiteDomainService result = SiteDomainService.write_app_vhost(app) or {} nginx = result.get('nginx') warning = result.get('warning') if warning: nginx = dict(nginx or {}, warning=warning) return nginx def validate_and_sanitize_domain(domain_name: str) -> tuple: """Validate and sanitize a domain name for nginx server_name. Args: domain_name: The domain name to validate Returns: Tuple of (sanitized_domain, error_message). error_message is None if valid. """ if not domain_name: return None, 'Domain name is required' # Strip whitespace domain = domain_name.strip() # Remove protocol if present (common user mistake) if domain.startswith('https://'): domain = domain[8:] if domain.startswith('http://'): domain = domain[7:] # Remove trailing slashes and paths domain = domain.split('/')[0] # Remove port if present domain = domain.split(':')[0] # Strip again after modifications domain = domain.strip() if not domain: return None, 'Domain name is empty after sanitization' # Validate domain format # Allow: letters, numbers, hyphens, dots # Must not start or end with hyphen or dot # Must have at least one dot (for TLDs) unless it's localhost domain_pattern = r'^(?!-)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(? 253: return None, 'Domain name is too long (max 253 characters)' # Each label (part between dots) must be <= 63 chars labels = domain.split('.') for label in labels: if len(label) > 63: return None, f'Domain label "{label}" is too long (max 63 characters per label)' return domain.lower(), None @domains_bp.route('', methods=['GET']) @jwt_required() def get_domains(): current_user_id = get_jwt_identity() user = User.query.get(current_user_id) # Workspace-aware scoping (#33). Domains are app-children with no workspace_id # of their own — they inherit their parent Application's. Scope the visible app # set (own + granted, workspace-narrowed) and return those apps' domains. # With no workspace context this matches prior behavior (admin -> all domains, # else -> the user's own apps' domains) and additionally surfaces domains of # apps shared via a grant — consistent with the per-domain routes, which # already authorize through ResourceGrantService.can_access_app. from app.services.workspace_service import WorkspaceService ws_id = WorkspaceService.resolve_workspace_id( user, request.headers.get('X-Workspace-Id') or request.args.get('workspace_id')) app_q = WorkspaceService.scope_query( Application.query_active(), Application, user, workspace_id=ws_id, owner_attr='user_id', grant_resource_type='application') app_ids = [row[0] for row in app_q.with_entities(Application.id).all()] domains = (Domain.query_active().filter(Domain.application_id.in_(app_ids)).all() if app_ids else []) return jsonify({ 'domains': [domain.to_dict() for domain in domains] }), 200 @domains_bp.route('/', methods=['GET']) @jwt_required() def get_domain(domain_id): current_user_id = get_jwt_identity() user = User.query.get(current_user_id) domain = Domain.query_active().filter_by(id=domain_id).first() if not domain: raise not_found('domain') # A soft-deleted app keeps its Domain rows (only the vhost is torn down), so # every route here has to resolve the parent live or a deleted app's domains # stay reachable — and operable, up to a real ACME order in enable_ssl. app = Application.query_active().filter_by(id=domain.application_id).first() if not app: raise not_found('domain') if not ResourceGrantService.can_access_app(user, app): raise permission_denied() return jsonify({'domain': domain.to_dict()}), 200 @domains_bp.route('', methods=['POST']) @jwt_required() def create_domain(): current_user_id = get_jwt_identity() user = User.query.get(current_user_id) data = json_object() if not data: return jsonify({'error': 'No data provided'}), 400 name = data.get('name') application_id = data.get('application_id') if not all([name, application_id]): return jsonify({'error': 'Missing required fields: name, application_id'}), 400 # Validate and sanitize domain name sanitized_name, validation_error = validate_and_sanitize_domain(name) if validation_error: return jsonify({'error': validation_error}), 400 # Use sanitized name name = sanitized_name # Check if domain already exists if Domain.query_active().filter_by(name=name).first(): raise already_exists('domain') # Check if application exists and user has access app = Application.query_active().filter_by(id=application_id).first() if not app: raise not_found('service') if not ResourceGrantService.can_edit_app(user, app): raise permission_denied() # For Docker apps, validate port configuration port_warning = None if app.app_type == 'docker': if not app.port: return jsonify({ 'error': 'Set a port on this service before adding domains', 'hint': 'Open the service settings and set its port, then add the domain' }), 400 # Check if port is accessible (warning only, don't block) from app.services.docker_service import DockerService port_check = DockerService.check_port_accessible(app.port) if not port_check.get('accessible'): port_warning = f"Warning: Port {app.port} is not currently accessible. Make sure the container is running and the port is exposed." # Check if this should be the primary domain is_primary = data.get('is_primary', False) if is_primary: # Unset any existing primary domain for this app Domain.query_active().filter_by(application_id=application_id, is_primary=True).update({'is_primary': False}, synchronize_session=False) domain = Domain( name=name, is_primary=is_primary, ssl_enabled=data.get('ssl_enabled', False), ssl_auto_renew=data.get('ssl_auto_renew', True), application_id=application_id ) db.session.add(domain) db.session.commit() # Publish through SiteDomainService rather than a bare create_site. # # create_site takes ssl_cert/ssl_key/micro_cache as arguments and this call # passed none of them, so the regenerated vhost came out HTTP-only and # cache-less: adding a domain to a site covered by a base wildcard cert # silently knocked the WHOLE site back to plain HTTP, and dropped its # micro-cache directives. app_vhost_kwargs re-resolves both, picks the right # template for the app type (php/static/flask/django too, not just docker), # and enables the site. nginx_result = _publish_app_vhost(app) response = { 'message': 'Domain created successfully', 'domain': domain.to_dict(), 'nginx': nginx_result } if port_warning: response['warning'] = port_warning return jsonify(response), 201 @domains_bp.route('/base-domains', methods=['GET']) @jwt_required() def list_base_domains(): """The base domains a new site can be published under, for the create-flow picker. Returns the registry (default first) or, on a legacy single-base install, the one configured base. ``default`` names the pre-selected base.""" from app.services.site_domain_service import SiteDomainService from app.services.site_base_domain_service import SiteBaseDomainService rows = SiteBaseDomainService.list_rows() if rows: bases = [{'domain': r.domain, 'is_default': r.is_default, 'https_enabled': r.https_enabled, 'dns_mode': r.dns_mode} for r in rows] else: base = SiteDomainService.base_domain() bases = ([{'domain': base, 'is_default': True, 'https_enabled': SiteDomainService.https_enabled(), 'dns_mode': SiteDomainService.dns_mode()}] if base else []) default = next((b['domain'] for b in bases if b['is_default']), None) return jsonify({'base_domains': bases, 'default': default}), 200 @domains_bp.route('/suggest-subdomain', methods=['GET']) @jwt_required() def suggest_subdomain(): """Suggest a managed-sites subdomain (.) for an app, so the UI can prefill the 'give this a subdomain' action. Honours an optional ``base``.""" from app.services.site_domain_service import SiteDomainService app_id = request.args.get('application_id', type=int) app = Application.query_active().filter_by(id=app_id).first() if app_id else None base = SiteDomainService.resolve_base(request.args.get('base')) if not base: return jsonify({'base_domain': None, 'suggestion': None, 'dns_mode': SiteDomainService.dns_mode()}), 200 slug = SiteDomainService.slugify(app.name) if app else 'site' return jsonify({'base_domain': base, 'suggestion': f'{slug}.{base}', 'dns_mode': SiteDomainService.dns_mode(base)}), 200 @domains_bp.route('/give-subdomain', methods=['POST']) @jwt_required() def give_subdomain(): """Publish an app at