293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""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)
|