Add optional photo providers, layout studio and scoped integrations
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user