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)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Bounded layout plans shared by deterministic and optional AI renderers."""
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from typing import Literal
|
||||
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import StrictModel
|
||||
from pigway_frame_flow.settings import secret
|
||||
|
||||
|
||||
class Region(StrictModel):
|
||||
asset_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
x: int = Field(ge=0)
|
||||
y: int = Field(ge=0)
|
||||
width: int = Field(ge=100)
|
||||
height: int = Field(ge=100)
|
||||
|
||||
|
||||
class LayoutInput(StrictModel):
|
||||
asset_ids: list[str] = Field(min_length=1, max_length=12)
|
||||
title: str = Field(default="", max_length=100)
|
||||
message: str = Field(default="", max_length=500)
|
||||
orientation: Literal["portrait", "landscape"] = "portrait"
|
||||
style: Literal["grid", "hero", "poster"] = "grid"
|
||||
use_ai: bool = False
|
||||
prompt: str = Field(default="", max_length=1500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def ids(self):
|
||||
import re
|
||||
|
||||
if len(set(self.asset_ids)) != len(self.asset_ids) or any(
|
||||
not re.fullmatch(r"[a-f0-9]{32}", x) for x in self.asset_ids
|
||||
):
|
||||
raise ValueError("Choose distinct local images")
|
||||
return self
|
||||
|
||||
|
||||
class LayoutPlan(StrictModel):
|
||||
width: int = Field(ge=1200, le=1600)
|
||||
height: int = Field(ge=1200, le=1600)
|
||||
title: str = Field(default="", max_length=100)
|
||||
message: str = Field(default="", max_length=500)
|
||||
regions: list[Region] = Field(min_length=1, max_length=12)
|
||||
engine: Literal["local", "ai"] = "local"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def bounds(self):
|
||||
if (self.width, self.height) not in [(1200, 1600), (1600, 1200)]:
|
||||
raise ValueError("Unsupported canvas")
|
||||
for i, region in enumerate(self.regions):
|
||||
if (
|
||||
region.x + region.width > self.width
|
||||
or region.y + region.height > self.height - 120
|
||||
or region.y < 150
|
||||
):
|
||||
raise ValueError("Photo region outside safe photo area")
|
||||
for other in self.regions[:i]:
|
||||
if (
|
||||
region.x < other.x + other.width
|
||||
and other.x < region.x + region.width
|
||||
and region.y < other.y + other.height
|
||||
and other.y < region.y + region.height
|
||||
):
|
||||
raise ValueError("Photo regions overlap")
|
||||
return self
|
||||
|
||||
|
||||
def local_plan(request: LayoutInput):
|
||||
import math
|
||||
|
||||
width, height = (1200, 1600) if request.orientation == "portrait" else (1600, 1200)
|
||||
gap, top, bottom = 24, 170, height - 140
|
||||
n = len(request.asset_ids)
|
||||
regions = []
|
||||
if request.style in ("hero", "poster") and n > 1:
|
||||
hero_height = int((bottom - top) * (0.45 if n > 7 else 0.62))
|
||||
regions.append(
|
||||
Region(
|
||||
asset_id=request.asset_ids[0],
|
||||
x=gap,
|
||||
y=top,
|
||||
width=width - 2 * gap,
|
||||
height=hero_height - gap,
|
||||
)
|
||||
)
|
||||
top += hero_height
|
||||
ids = request.asset_ids[1:]
|
||||
else:
|
||||
ids = request.asset_ids
|
||||
columns = min(3, max(1, math.ceil(math.sqrt(len(ids) * width / (bottom - top)))))
|
||||
rows = math.ceil(len(ids) / columns)
|
||||
cell_w, cell_h = (
|
||||
(width - gap * (columns + 1)) // columns,
|
||||
(bottom - top - gap * (rows - 1)) // rows,
|
||||
)
|
||||
for i, asset_id in enumerate(ids):
|
||||
regions.append(
|
||||
Region(
|
||||
asset_id=asset_id,
|
||||
x=gap + (i % columns) * (cell_w + gap),
|
||||
y=top + (i // columns) * (cell_h + gap),
|
||||
width=cell_w,
|
||||
height=cell_h,
|
||||
)
|
||||
)
|
||||
return LayoutPlan(
|
||||
width=width,
|
||||
height=height,
|
||||
title=request.title,
|
||||
message=request.message,
|
||||
regions=regions,
|
||||
)
|
||||
|
||||
|
||||
class LayoutStudio:
|
||||
def __init__(self, renderer):
|
||||
self.renderer = renderer
|
||||
|
||||
def plan(self, request):
|
||||
plan = local_plan(request)
|
||||
metadata = []
|
||||
for asset_id in request.asset_ids:
|
||||
with Image.open(self.renderer.assets.path(asset_id)) as image:
|
||||
metadata.append(
|
||||
{"asset_id": asset_id, "width": image.width, "height": image.height}
|
||||
)
|
||||
if not request.use_ai:
|
||||
return plan
|
||||
db = self.renderer.db
|
||||
row = db.one("SELECT payload FROM app_config WHERE key='ai_layout'")
|
||||
config = json.loads(row["payload"]) if row else {}
|
||||
key = secret("FRAME_FLOW_AI_KEY")
|
||||
key_path = self.renderer.settings.config_dir / "ai-layout.secret"
|
||||
if not key and key_path.exists():
|
||||
key = key_path.read_text().strip()
|
||||
if not key or not config.get("endpoint") or not config.get("model"):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"Configure the AI layout endpoint, model and key first",
|
||||
409,
|
||||
)
|
||||
self.renderer.settings.additional_secrets.append(key)
|
||||
body = {
|
||||
"model": config["model"],
|
||||
"response_format": {"type": "json_object"},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Return ONLY a JSON layout following this example. Keep canvas size, all asset ids exactly once, non-overlapping photo rectangles y>=150 and bottom<=height-120. Keep title/message unchanged. Never include code, URLs or HTML. Example: "
|
||||
+ plan.model_dump_json(),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"images": metadata,
|
||||
"style": request.style,
|
||||
"instruction": request.prompt,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
try:
|
||||
data = self.renderer.assets.fetch_url(
|
||||
config["endpoint"],
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body=json.dumps(body).encode(),
|
||||
limit=256 * 1024,
|
||||
)
|
||||
result = json.loads(data)["choices"][0]["message"]["content"]
|
||||
candidate = LayoutPlan.model_validate_json(result)
|
||||
if (
|
||||
sorted(x.asset_id for x in candidate.regions)
|
||||
!= sorted(request.asset_ids)
|
||||
or (candidate.width, candidate.height) != (plan.width, plan.height)
|
||||
or candidate.title != request.title
|
||||
or candidate.message != request.message
|
||||
):
|
||||
raise ValueError()
|
||||
candidate.engine = "ai"
|
||||
return candidate
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.RENDER_FAILED,
|
||||
"AI layout failed or returned an invalid plan; choose local layout to continue",
|
||||
502,
|
||||
) from None
|
||||
|
||||
def render(self, data, profile):
|
||||
plan = LayoutPlan.model_validate(data["layout"])
|
||||
canvas = Image.new("RGB", (plan.width, plan.height), "white")
|
||||
for region in plan.regions:
|
||||
with Image.open(self.renderer.assets.path(region.asset_id)) as source:
|
||||
image = ImageOps.exif_transpose(source).convert("RGB")
|
||||
image = ImageOps.fit(
|
||||
image,
|
||||
(region.width, region.height),
|
||||
method=Image.Resampling.LANCZOS,
|
||||
)
|
||||
canvas.paste(image, (region.x, region.y))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
for text, y, size in [
|
||||
(plan.title, 35, 54),
|
||||
(plan.message, plan.height - 100, 30),
|
||||
]:
|
||||
font = self.renderer.font(size)
|
||||
while text and draw.textlength(text, font=font) > plan.width - 48:
|
||||
text = text[:-1]
|
||||
draw.text((24, y), text, font=font, fill="black")
|
||||
if profile == "grayscale":
|
||||
canvas = ImageOps.grayscale(canvas).convert("RGB")
|
||||
out = BytesIO()
|
||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||
return out.getvalue()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Explicit widget mutations, queued once with conservative uncertain-write handling."""
|
||||
|
||||
from fastapi import Depends, Header, Request
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import Accepted, StrictModel
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
|
||||
class WidgetSelection(StrictModel):
|
||||
cover_id: int | None = Field(default=None, gt=0)
|
||||
args: dict[str, str] = Field(default_factory=dict, max_length=30)
|
||||
|
||||
|
||||
def install_official(app, service):
|
||||
import json
|
||||
|
||||
for device_id, connection in service.registry.official.items():
|
||||
row = service.db.one(
|
||||
"SELECT payload FROM app_config WHERE key=?", ("mock-widget:" + device_id,)
|
||||
)
|
||||
if connection.reader.mock and row:
|
||||
connection.reader.mock_selection = json.loads(row["payload"])["cover_id"]
|
||||
|
||||
def check(device_id, payload):
|
||||
service.registry.get(device_id)
|
||||
connection = service.registry.official.get(device_id)
|
||||
if not connection:
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "This driver has no official widgets", 422
|
||||
)
|
||||
if not service.settings.mock and not service.settings.write_enabled:
|
||||
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
|
||||
view = connection.widgets()
|
||||
if not view.configured:
|
||||
raise FlowError(
|
||||
E.OFFICIAL_AUTH_REQUIRED,
|
||||
"Connect and associate an official account first",
|
||||
401,
|
||||
)
|
||||
if payload.cover_id is not None:
|
||||
widget = next((x for x in view.catalog if x.id == payload.cover_id), None)
|
||||
if not widget:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Widget is not in the authenticated catalog"
|
||||
)
|
||||
if set(payload.args) - set(widget.arg_keys):
|
||||
raise FlowError(E.INVALID_REQUEST, "Unknown widget parameter")
|
||||
if any(len(k) > 120 or len(v) > 2000 for k, v in payload.args.items()):
|
||||
raise FlowError(E.INVALID_REQUEST, "Widget parameter is too long")
|
||||
elif payload.args:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Cancellation does not accept parameters"
|
||||
)
|
||||
return connection
|
||||
|
||||
@app.post(
|
||||
"/api/v1/devices/{device_id}/widgets/selection",
|
||||
response_model=Accepted,
|
||||
status_code=202,
|
||||
tags=["Official"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def select(
|
||||
device_id: str,
|
||||
payload: WidgetSelection,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
check(device_id, payload)
|
||||
id = service.submit(
|
||||
"official-widget",
|
||||
payload.model_dump(),
|
||||
device_id,
|
||||
request.state.client_id,
|
||||
request.state.request_id,
|
||||
idempotency_key,
|
||||
)
|
||||
return Accepted(operation_id=id, job_id=id, status="accepted")
|
||||
|
||||
def execute(op, payload):
|
||||
selection = WidgetSelection.model_validate(payload)
|
||||
connection = check(op["device_id"], selection)
|
||||
service.update(op["id"], "UPLOADING")
|
||||
service.db.execute(
|
||||
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
|
||||
)
|
||||
with connection.lock:
|
||||
if connection.reader.mock:
|
||||
connection.reader.mock_selection = selection.cover_id
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||
("mock-widget:" + op["device_id"], encode(selection.model_dump())),
|
||||
)
|
||||
else:
|
||||
connection.reader.rpc.set_widget(
|
||||
connection.reader._robot_id, selection.cover_id, selection.args
|
||||
)
|
||||
connection.reader._cached = None
|
||||
try:
|
||||
view = connection.widgets()
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN,
|
||||
"Widget write sent but read-only confirmation failed",
|
||||
409,
|
||||
) from None
|
||||
if (
|
||||
not view.current_verified
|
||||
or (view.current.id if view.current else None) != selection.cover_id
|
||||
):
|
||||
raise FlowError(
|
||||
E.WRITE_RESULT_UNKNOWN,
|
||||
"Widget write acknowledged but selection is not confirmed",
|
||||
409,
|
||||
)
|
||||
service.update(op["id"], "COMPLETED", {"cover_id": selection.cover_id})
|
||||
service.db.history(op, "COMPLETED")
|
||||
service.db.event(
|
||||
"display.changed",
|
||||
{
|
||||
"device_id": op["device_id"],
|
||||
"operation_id": op["id"],
|
||||
"cover_id": selection.cover_id,
|
||||
},
|
||||
)
|
||||
|
||||
service.operation_handlers["official-widget"] = execute
|
||||
|
||||
def reconcile(op, acknowledge):
|
||||
id = op["operation_id"]
|
||||
row = service.db.one("SELECT payload FROM operations WHERE id=?", (id,))
|
||||
expected_cover = json.loads(row["payload"])["cover_id"]
|
||||
connection = service.registry.official[op["device_id"]]
|
||||
with connection.lock:
|
||||
connection.reader._cached = None
|
||||
view = connection.widgets()
|
||||
confirmed = (
|
||||
view.current_verified
|
||||
and (view.current.id if view.current else None) == expected_cover
|
||||
)
|
||||
resolved = op["status"] != "UNKNOWN"
|
||||
if op["status"] == "UNKNOWN" and (confirmed or acknowledge):
|
||||
service.update(
|
||||
id,
|
||||
"COMPLETED" if confirmed else "FAILED",
|
||||
{"cover_id": expected_cover} if confirmed else None,
|
||||
)
|
||||
service.db.history(
|
||||
dict(
|
||||
id=id,
|
||||
client_id="admin",
|
||||
request_id=op["request_id"],
|
||||
device_id=op["device_id"],
|
||||
action="reconcile",
|
||||
),
|
||||
"CONFIRMED" if confirmed else "ACKNOWLEDGED",
|
||||
)
|
||||
resolved = True
|
||||
return dict(
|
||||
operation_id=id,
|
||||
resolved=resolved,
|
||||
current_image_id=None,
|
||||
expected_image_id=None,
|
||||
cloud_images=[],
|
||||
)
|
||||
|
||||
service.reconciliation_handlers["official-widget"] = reconcile
|
||||
@@ -0,0 +1,51 @@
|
||||
from fastapi import Depends
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.extensions.api import AISecret
|
||||
from pigway_frame_flow.extensions.secrets import save_secret
|
||||
from pigway_frame_flow.providers.external import (
|
||||
ExternalProviderConfig,
|
||||
)
|
||||
|
||||
|
||||
def install_providers(app, service):
|
||||
providers = service.providers
|
||||
|
||||
@app.get(
|
||||
"/api/v1/providers/external",
|
||||
tags=["Providers"],
|
||||
dependencies=[Depends(require("devices:read"))],
|
||||
)
|
||||
def list_providers():
|
||||
return providers.list()
|
||||
|
||||
@app.put(
|
||||
"/api/v1/providers/external/{id}",
|
||||
tags=["Providers"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def configure(id: str, payload: ExternalProviderConfig):
|
||||
return providers.save(id, payload)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/providers/external/{id}/data",
|
||||
tags=["Providers"],
|
||||
dependencies=[Depends(require("devices:read"))],
|
||||
)
|
||||
def provider_data(id: str):
|
||||
return providers.get(id)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/providers/external/{id}/key",
|
||||
status_code=204,
|
||||
tags=["Providers"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def provider_key(id: str, payload: AISecret):
|
||||
providers.config(id)
|
||||
save_secret(
|
||||
service.settings.config_dir / ("provider-" + id + ".secret"),
|
||||
payload.key.get_secret_value(),
|
||||
)
|
||||
service.settings.additional_secrets.append(payload.key.get_secret_value())
|
||||
providers.cache.pop(id, None)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Small-thumbnail quality heuristics. Never delete or modify source photos."""
|
||||
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
|
||||
def photo_quality(data):
|
||||
with Image.open(BytesIO(data)) as source:
|
||||
image = ImageOps.exif_transpose(source).convert("L")
|
||||
image.thumbnail((256, 256))
|
||||
brightness = ImageStat.Stat(image).mean[0]
|
||||
edges = image.filter(ImageFilter.FIND_EDGES)
|
||||
sharpness = ImageStat.Stat(
|
||||
edges.crop((2, 2, max(3, image.width - 2), max(3, image.height - 2)))
|
||||
).var[0]
|
||||
small = image.resize((9, 8))
|
||||
pixels = list(small.tobytes())
|
||||
bits = "".join(
|
||||
"1" if pixels[y * 9 + x] > pixels[y * 9 + x + 1] else "0"
|
||||
for y in range(8)
|
||||
for x in range(8)
|
||||
)
|
||||
return {
|
||||
"brightness": round(brightness, 2),
|
||||
"sharpness": round(sharpness, 2),
|
||||
"fingerprint": f"{int(bits, 2):016x}",
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def passes(result, source):
|
||||
return (
|
||||
result["sharpness"] >= source.minimum_sharpness
|
||||
and source.minimum_brightness
|
||||
<= result["brightness"]
|
||||
<= source.maximum_brightness
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Atomic mode-0600 secrets; filenames supplied by application code, never callers."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def save_secret(path, value):
|
||||
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
|
||||
temporary = Path(file.name)
|
||||
try:
|
||||
file.write(value)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Opt-in local retention, protecting stars, current content and durable references."""
|
||||
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.models import StrictModel
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class StoragePolicy(StrictModel):
|
||||
enabled: bool = False
|
||||
generated_days: int = Field(default=7, ge=1, le=3650)
|
||||
cache_days: int = Field(default=2, ge=1, le=3650)
|
||||
max_bytes: int = Field(default=1024 * 1024 * 1024, ge=1024 * 1024, le=1024**4)
|
||||
|
||||
|
||||
class LocalRetention:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
self.last = 0.0
|
||||
|
||||
def config(self):
|
||||
row = self.service.db.one(
|
||||
"SELECT payload FROM app_config WHERE key='local_retention'"
|
||||
)
|
||||
return (
|
||||
StoragePolicy.model_validate_json(row["payload"])
|
||||
if row
|
||||
else StoragePolicy()
|
||||
)
|
||||
|
||||
def protected(self):
|
||||
db = self.service.db
|
||||
ids = {
|
||||
row["asset_id"]
|
||||
for row in db.all("SELECT asset_id FROM asset_metadata WHERE starred=1")
|
||||
}
|
||||
payloads = []
|
||||
for query in [
|
||||
"SELECT payload FROM jobs",
|
||||
"SELECT payload FROM template_config",
|
||||
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
||||
]:
|
||||
payloads += [row["payload"] for row in db.all(query)]
|
||||
for payload in payloads:
|
||||
ids.update(re.findall(r"[a-f0-9]{32}", payload))
|
||||
images: set[tuple[str, int]] = set()
|
||||
for row in db.all("SELECT * FROM device_state"):
|
||||
images.update(
|
||||
(row["device_id"], row[key])
|
||||
for key in ["previous_image_id", "base_image_id", "managed_image_id"]
|
||||
if row[key]
|
||||
)
|
||||
images.update(
|
||||
(row["device_id"], row["image_id"])
|
||||
for row in db.all("SELECT device_id,image_id FROM overrides")
|
||||
)
|
||||
for device_id, image_id in images:
|
||||
row = db.one(
|
||||
"SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?",
|
||||
(device_id, image_id),
|
||||
)
|
||||
if row:
|
||||
ids.add(row["asset_id"])
|
||||
return ids
|
||||
|
||||
def inspect(self):
|
||||
rows = []
|
||||
for row in self.service.db.all("SELECT * FROM assets ORDER BY created_at"):
|
||||
path = self.service.assets.path(row["id"])
|
||||
if path.exists():
|
||||
rows.append((row, path, path.stat().st_size))
|
||||
return rows
|
||||
|
||||
def stats(self):
|
||||
rows = self.inspect()
|
||||
protected = self.protected()
|
||||
return {
|
||||
"policy": self.config().model_dump(),
|
||||
"total_bytes": sum(x[2] for x in rows),
|
||||
"asset_count": len(rows),
|
||||
"protected_count": sum(x[0]["id"] in protected for x in rows),
|
||||
}
|
||||
|
||||
def sweep(self, force=False, dry_run=False):
|
||||
if not force and time.monotonic() - self.last < 3600:
|
||||
return {}
|
||||
if not dry_run:
|
||||
self.last = time.monotonic()
|
||||
config = self.config()
|
||||
if not config.enabled:
|
||||
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
|
||||
with self.service.lock:
|
||||
protected = self.protected()
|
||||
rows = self.inspect()
|
||||
total = sum(x[2] for x in rows)
|
||||
removed = []
|
||||
freed = 0
|
||||
for row, path, size in rows:
|
||||
# Manual uploaded photos are never removed by automatic retention.
|
||||
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
|
||||
continue
|
||||
days = (
|
||||
config.cache_days
|
||||
if row["kind"] == "cache"
|
||||
else config.generated_days
|
||||
)
|
||||
expired = datetime.fromisoformat(row["created_at"]) < datetime.now(
|
||||
UTC
|
||||
) - timedelta(days=days)
|
||||
if not expired and total <= config.max_bytes:
|
||||
continue
|
||||
removed.append(row["id"])
|
||||
total -= size
|
||||
freed += size
|
||||
if not dry_run:
|
||||
path.unlink(missing_ok=True)
|
||||
for table, key in [
|
||||
("asset_metadata", "asset_id"),
|
||||
("asset_sources", "asset_id"),
|
||||
("assets", "id"),
|
||||
]:
|
||||
self.service.db.execute(
|
||||
f"DELETE FROM {table} WHERE {key}=?", (row["id"],)
|
||||
)
|
||||
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
|
||||
if not dry_run:
|
||||
self.service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES('local_retention_last',?)",
|
||||
(encode({**result, "timestamp": now_iso()}),),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Signed, bounded, durable event delivery; independent of frame writes."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import StrictModel
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class WebhookInput(StrictModel):
|
||||
callback_url: str = Field(max_length=2048)
|
||||
event_types: list[str] = Field(min_length=1, max_length=20)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class Webhooks:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
self.last = 0.0
|
||||
service.db.conn.executescript("""CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
subscription_id TEXT,event_id TEXT,status TEXT,attempts INTEGER DEFAULT 0,next_at TEXT,error_code TEXT,
|
||||
PRIMARY KEY(subscription_id,event_id));""")
|
||||
|
||||
def tick(self):
|
||||
if time.monotonic() - self.last < 60:
|
||||
return
|
||||
self.last = time.monotonic()
|
||||
db = self.service.db
|
||||
for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"):
|
||||
types = json.loads(sub["event_types"])
|
||||
for event in db.all(
|
||||
"SELECT * FROM events e WHERE created_at>=? AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
|
||||
(sub["created_at"], sub["id"]),
|
||||
):
|
||||
if event["event_type"] not in types:
|
||||
continue
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)",
|
||||
(sub["id"], event["id"], now_iso()),
|
||||
)
|
||||
for row in db.all(
|
||||
"SELECT d.*,s.callback_url FROM webhook_deliveries d JOIN webhook_subscriptions s ON s.id=d.subscription_id WHERE s.enabled=1 AND d.status IN ('QUEUED','RETRY') AND d.next_at<=? ORDER BY d.next_at LIMIT 2",
|
||||
(now_iso(),),
|
||||
):
|
||||
event = db.one("SELECT * FROM events WHERE id=?", (row["event_id"],))
|
||||
if not event:
|
||||
continue
|
||||
keyfile = self.service.settings.config_dir / (
|
||||
"webhook-" + row["subscription_id"] + ".secret"
|
||||
)
|
||||
try:
|
||||
key = keyfile.read_text().strip()
|
||||
payload = encode(
|
||||
{
|
||||
"id": event["id"],
|
||||
"type": event["event_type"],
|
||||
"timestamp": event["created_at"],
|
||||
"data": json.loads(event["payload"]),
|
||||
}
|
||||
).encode()
|
||||
signature = hmac.new(key.encode(), payload, hashlib.sha256).hexdigest()
|
||||
self.service.assets.fetch_url(
|
||||
row["callback_url"],
|
||||
method="POST",
|
||||
body=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Frame-Flow-Event-ID": event["id"],
|
||||
"X-Frame-Flow-Signature": "sha256=" + signature,
|
||||
},
|
||||
limit=64 * 1024,
|
||||
accepted_status=(200, 201, 202, 204),
|
||||
)
|
||||
status, error = "DELIVERED", None
|
||||
except Exception:
|
||||
status, error = (
|
||||
("FAILED" if row["attempts"] >= 2 else "RETRY"),
|
||||
"DELIVERY_FAILED",
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE webhook_deliveries SET status=?,attempts=attempts+1,next_at=?,error_code=? WHERE subscription_id=? AND event_id=?",
|
||||
(
|
||||
status,
|
||||
(
|
||||
datetime.now(UTC) + timedelta(seconds=60 * 2 ** row["attempts"])
|
||||
).isoformat(),
|
||||
error,
|
||||
row["subscription_id"],
|
||||
row["event_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def install_webhooks(app, service):
|
||||
worker = Webhooks(service)
|
||||
service.webhooks = worker
|
||||
db = service.db
|
||||
|
||||
@app.get(
|
||||
"/api/v1/webhooks", tags=["Webhooks"], dependencies=[Depends(require("admin"))]
|
||||
)
|
||||
def list_hooks():
|
||||
return [dict(row) for row in db.all("SELECT * FROM webhook_subscriptions")]
|
||||
|
||||
@app.post(
|
||||
"/api/v1/webhooks",
|
||||
status_code=201,
|
||||
tags=["Webhooks"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def add_hook(payload: WebhookInput):
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
parsed = urlsplit(payload.callback_url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.port not in (None, 443)
|
||||
or parsed.hostname not in service.settings.image_url_hosts
|
||||
):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Webhook requires an explicitly allowed HTTPS host"
|
||||
)
|
||||
allowed = {
|
||||
"operation.completed",
|
||||
"operation.failed",
|
||||
"display.changed",
|
||||
"device.online",
|
||||
"device.offline",
|
||||
"device.low_battery",
|
||||
}
|
||||
if set(payload.event_types) - allowed:
|
||||
raise FlowError(E.INVALID_REQUEST, "Unsupported event type")
|
||||
id = uuid4().hex
|
||||
key = secrets.token_urlsafe(32)
|
||||
import os
|
||||
|
||||
path = service.settings.config_dir / ("webhook-" + id + ".secret")
|
||||
with os.fdopen(
|
||||
os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w"
|
||||
) as f:
|
||||
f.write(key)
|
||||
db.execute(
|
||||
"INSERT INTO webhook_subscriptions VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
id,
|
||||
"admin",
|
||||
payload.callback_url,
|
||||
encode(payload.event_types),
|
||||
int(payload.enabled),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
service.settings.additional_secrets.append(key)
|
||||
return {"id": id, "signing_secret": key}
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/webhooks/{id}",
|
||||
status_code=204,
|
||||
tags=["Webhooks"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_hook(id: str):
|
||||
if not db.one("SELECT id FROM webhook_subscriptions WHERE id=?", (id,)):
|
||||
raise FlowError(E.NOT_FOUND, "Webhook not found", 404)
|
||||
db.execute("DELETE FROM webhook_subscriptions WHERE id=?", (id,))
|
||||
(service.settings.config_dir / ("webhook-" + id + ".secret")).unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/webhooks/{id}/deliveries",
|
||||
tags=["Webhooks"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def deliveries(id: str):
|
||||
return [
|
||||
dict(row)
|
||||
for row in db.all(
|
||||
"SELECT * FROM webhook_deliveries WHERE subscription_id=? ORDER BY next_at DESC LIMIT 100",
|
||||
(id,),
|
||||
)
|
||||
]
|
||||
Reference in New Issue
Block a user