"""Optional capabilities share application auth, assets and operation pipeline.""" import hashlib import json import secrets from urllib.parse import urlsplit from fastapi import Depends, File, UploadFile from pydantic import Field, SecretStr from pigway_frame_flow.core.auth import ALL_SCOPES, require from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.extensions.layout import LayoutInput, LayoutPlan, LayoutStudio from pigway_frame_flow.extensions.storage import LocalRetention, StoragePolicy from pigway_frame_flow.models import StrictModel from pigway_frame_flow.storage.database import encode, now_iso class AIConfig(StrictModel): endpoint: str = Field(default="", max_length=2048) model: str = Field(default="", max_length=120) class AISecret(StrictModel): key: SecretStr = Field(min_length=1, max_length=8192) class ClientInput(StrictModel): client_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$") name: str = Field(min_length=1, max_length=120) permissions: list[str] = Field(min_length=1, max_length=20) def install_extensions(app, service): from pigway_frame_flow.extensions.official import install_official install_official(app, service) from pigway_frame_flow.extensions.webhooks import install_webhooks install_webhooks(app, service) from pigway_frame_flow.extensions.providers import install_providers install_providers(app, service) db, settings = service.db, service.settings studio = LayoutStudio(service.renderer) service.local_retention = LocalRetention(service) @app.post( "/api/v1/layout/images", status_code=201, tags=["Layout"], dependencies=[Depends(require("render:write"))], ) async def import_images(files: list[UploadFile] = File(...)): from io import BytesIO from PIL import Image, ImageOps from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS if not 1 <= len(files) <= 12: raise FlowError(E.INVALID_REQUEST, "Choose between one and twelve images") prepared = [] for file in files: data = await file.read(settings.max_bytes + 1) if len(data) > settings.max_bytes: raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") try: with Image.open(BytesIO(data)) as image: if ( image.format not in {"JPEG", "PNG", "WEBP"} or image.width * image.height > MAX_IMAGE_PIXELS ): raise ValueError() rgba = ImageOps.exif_transpose(image).convert("RGBA") rgb = Image.new("RGB", rgba.size, "white") rgb.paste(rgba, mask=rgba.getchannel("A")) out = BytesIO() rgb.save(out, "JPEG", quality=93, optimize=True) prepared.append((out.getvalue(), file.filename or "photo.jpg")) except (OSError, ValueError, Image.DecompressionBombError): raise FlowError(E.INVALID_REQUEST, "Invalid image") from None result = [] for data, name in prepared: asset = service.assets.put(data, name) db.execute( "INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')", (asset["asset_id"],), ) result.append(asset) return result @app.post( "/api/v1/layout/plan", response_model=LayoutPlan, tags=["Layout"], dependencies=[Depends(require("render:write"))], ) def layout_plan(payload: LayoutInput): return studio.plan(payload) @app.get( "/api/v1/settings/ai-layout", tags=["Layout"], dependencies=[Depends(require("admin"))], ) def ai_config(): row = db.one("SELECT payload FROM app_config WHERE key='ai_layout'") from pigway_frame_flow.settings import secret return { **(json.loads(row["payload"]) if row else AIConfig().model_dump()), "key_configured": bool( secret("FRAME_FLOW_AI_KEY") or (settings.config_dir / "ai-layout.secret").exists() ), "sends_photos": False, } @app.put( "/api/v1/settings/ai-layout", response_model=AIConfig, tags=["Layout"], dependencies=[Depends(require("admin"))], ) def save_ai(payload: AIConfig): if payload.endpoint: parsed = urlsplit(payload.endpoint) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.port not in (None, 443) ): raise FlowError( E.INVALID_REQUEST, "AI endpoint must be a plain HTTPS URL" ) if parsed.hostname not in settings.image_url_hosts: settings.image_url_hosts.append(parsed.hostname) db.execute( "INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)", (encode(settings.image_url_hosts),), ) db.execute( "INSERT OR REPLACE INTO app_config VALUES('ai_layout',?)", (encode(payload.model_dump()),), ) return payload @app.put( "/api/v1/settings/ai-layout/key", status_code=204, tags=["Layout"], dependencies=[Depends(require("admin"))], ) def save_ai_key(payload: AISecret): from pigway_frame_flow.extensions.secrets import save_secret save_secret( settings.config_dir / "ai-layout.secret", payload.key.get_secret_value() ) settings.additional_secrets.append(payload.key.get_secret_value()) @app.get( "/api/v1/storage", tags=["Storage"], dependencies=[Depends(require("admin"))] ) def storage(): return service.local_retention.stats() @app.put( "/api/v1/storage/policy", response_model=StoragePolicy, tags=["Storage"], dependencies=[Depends(require("admin"))], ) def storage_policy(payload: StoragePolicy): db.execute( "INSERT OR REPLACE INTO app_config VALUES('local_retention',?)", (encode(payload.model_dump()),), ) return payload @app.post( "/api/v1/storage/cleanup", tags=["Storage"], dependencies=[Depends(require("admin"))], ) def cleanup(dry_run: bool = True): return service.local_retention.sweep(force=True, dry_run=dry_run) @app.get( "/api/v1/clients", tags=["Clients"], dependencies=[Depends(require("admin"))] ) def clients(): return [ { k: json.loads(row[k]) if k == "permissions" else row[k] for k in [ "client_id", "name", "permissions", "created_at", "last_used_at", "enabled", ] } for row in db.all("SELECT * FROM clients") ] @app.post( "/api/v1/clients", status_code=201, tags=["Clients"], dependencies=[Depends(require("admin"))], ) def create_client(payload: ClientInput): if payload.client_id == "admin" or db.one( "SELECT client_id FROM clients WHERE client_id=?", (payload.client_id,) ): raise FlowError(E.CONFLICT, "Client already exists", 409) if set(payload.permissions) - set(ALL_SCOPES): raise FlowError(E.INVALID_REQUEST, "Unknown permission scope") token = secrets.token_urlsafe(32) settings.additional_secrets.append(token) db.execute( "INSERT INTO clients VALUES(?,?,?,?,?,?,?)", ( payload.client_id, hashlib.sha256(token.encode()).hexdigest(), payload.name, encode(payload.permissions), now_iso(), None, 1, ), ) # Deliberate one-time delivery to the administrator; never list the token again. return {"client_id": payload.client_id, "token": token} @app.delete( "/api/v1/clients/{client_id}", status_code=204, tags=["Clients"], dependencies=[Depends(require("admin"))], ) def revoke_client(client_id: str): if client_id == "admin": raise FlowError( E.FORBIDDEN, "The deployment administrator cannot be revoked here", 403 ) db.execute("UPDATE clients SET enabled=0 WHERE client_id=?", (client_id,)) @app.put( "/api/v1/photo-sources/{source_id}/credentials", status_code=204, tags=["Photo sources"], dependencies=[Depends(require("admin"))], ) def drive_credentials(source_id: str, payload: AISecret): source = service.photo_sources.get(source_id) if source.kind not in ("google-drive", "alipan", "baidu"): raise FlowError( E.UNSUPPORTED_CAPABILITY, "Only native cloud drive sources use OAuth credentials here", 422, ) try: raw = json.loads(payload.key.get_secret_value()) if ( not isinstance(raw, dict) or not isinstance(raw.get("access_token"), str) or not raw["access_token"] ): raise ValueError() except (ValueError, TypeError): raise FlowError( E.INVALID_REQUEST, "Credentials must be JSON with access_token" ) from None from pigway_frame_flow.extensions.secrets import save_secret save_secret( settings.config_dir / ("source-" + source_id + ".secret"), payload.key.get_secret_value(), ) for k in ("access_token", "refresh_token", "client_secret"): if raw.get(k): settings.additional_secrets.append(str(raw[k])) service.photo_sources.cache.pop(source_id, None)