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)
+227
View File
@@ -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()
+171
View File
@@ -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
+51
View File
@@ -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)
+39
View File
@@ -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
)
+18
View File
@@ -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)
+135
View File
@@ -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
+196
View File
@@ -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,),
)
]