Add optional photo providers, layout studio and scoped integrations

This commit is contained in:
Pigway
2026-10-08 23:18:11 +08:00
parent 3643fae86a
commit 69a0841868
38 changed files with 2843 additions and 41 deletions
+292
View File
@@ -0,0 +1,292 @@
"""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)