Files
渡口浪人 1494904b12
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 8m9s
fix: close library upload flow and harden image storage
2026-10-24 00:38:00 +08:00

308 lines
11 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)
from pigway_frame_flow.extensions.ai_images import install_ai_images
install_ai_images(app, service)
from pigway_frame_flow.extensions.mqtt import install_mqtt
install_mqtt(app, service)
from pigway_frame_flow.extensions.device_events import DeviceObserver
service.device_observer = DeviceObserver(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), formats=("JPEG", "PNG", "WEBP")
) 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:
with service.assets.creating(data, name) as asset:
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.get("/api/v1/client-scopes", dependencies=[Depends(require("admin"))])
def client_scopes():
return ALL_SCOPES
@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)