Add optional photo providers, layout studio and scoped integrations
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""Optional read-only calendar and state providers, no vendor dependency in templates."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import requests
|
||||
from pydantic import Field
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ExternalProviderConfig(StrictModel):
|
||||
kind: str = Field(pattern=r"^(google-calendar|ics|home-assistant|http-json)$")
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
endpoint: str = Field(max_length=2048)
|
||||
enabled: bool = True
|
||||
allow_insecure_lan: bool = False
|
||||
entities: list[str] = Field(default_factory=list, max_length=100)
|
||||
|
||||
|
||||
class ExternalProviders:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
self.cache = {}
|
||||
|
||||
def list(self):
|
||||
return [
|
||||
dict(json.loads(row["payload"]), id=row["key"][9:])
|
||||
for row in self.service.db.all(
|
||||
"SELECT key,payload FROM app_config WHERE key LIKE 'provider:%'"
|
||||
)
|
||||
]
|
||||
|
||||
def config(self, id):
|
||||
row = self.service.db.one(
|
||||
"SELECT payload FROM app_config WHERE key=?", ("provider:" + id,)
|
||||
)
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Provider not found", 404)
|
||||
return ExternalProviderConfig.model_validate_json(row["payload"])
|
||||
|
||||
def save(self, id, config):
|
||||
if not re.fullmatch(r"[a-z0-9-]{1,60}", id):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid provider ID")
|
||||
if config.kind != "google-calendar":
|
||||
parsed = urlsplit(config.endpoint)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.fragment
|
||||
):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Provider needs an HTTP(S) endpoint without credentials",
|
||||
)
|
||||
if not config.allow_insecure_lan and (
|
||||
parsed.scheme != "https"
|
||||
or parsed.hostname not in self.service.settings.image_url_hosts
|
||||
):
|
||||
raise FlowError(
|
||||
E.FORBIDDEN,
|
||||
"Provider host must be allowed; LAN access requires explicit opt-in",
|
||||
403,
|
||||
)
|
||||
self.service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||
("provider:" + id, encode(config.model_dump())),
|
||||
)
|
||||
self.cache.pop(id, None)
|
||||
return dict(config.model_dump(), id=id)
|
||||
|
||||
def get(self, id):
|
||||
config = self.config(id)
|
||||
if not config.enabled:
|
||||
raise FlowError(E.CONFLICT, "Provider disabled", 409)
|
||||
if id in self.cache and time.monotonic() < self.cache[id][0]:
|
||||
return self.cache[id][1]
|
||||
secretfile = self.service.settings.config_dir / ("provider-" + id + ".secret")
|
||||
token = secretfile.read_text().strip() if secretfile.exists() else ""
|
||||
if token:
|
||||
self.service.settings.additional_secrets.append(token)
|
||||
headers = {"Authorization": "Bearer " + token} if token else {}
|
||||
if config.kind == "google-calendar":
|
||||
if not token:
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"Google Calendar requires an authorized OAuth token",
|
||||
409,
|
||||
)
|
||||
start = datetime.now(UTC)
|
||||
from urllib.parse import urlencode
|
||||
|
||||
url = (
|
||||
"https://www.googleapis.com/calendar/v3/calendars/"
|
||||
+ quote(config.endpoint, safe="")
|
||||
+ "/events?"
|
||||
+ urlencode(
|
||||
{
|
||||
"timeMin": start.isoformat(),
|
||||
"timeMax": (start + timedelta(days=7)).isoformat(),
|
||||
"singleEvents": "true",
|
||||
"orderBy": "startTime",
|
||||
"maxResults": 100,
|
||||
}
|
||||
)
|
||||
)
|
||||
if "www.googleapis.com" not in self.service.settings.image_url_hosts:
|
||||
self.service.settings.image_url_hosts.append("www.googleapis.com")
|
||||
else:
|
||||
url = config.endpoint.rstrip("/") + (
|
||||
"/api/states" if config.kind == "home-assistant" else ""
|
||||
)
|
||||
try:
|
||||
if config.allow_insecure_lan:
|
||||
# Endpoint is an explicitly administrator-approved, fixed LAN destination.
|
||||
with requests.get(
|
||||
url, headers=headers, timeout=10, allow_redirects=False, stream=True
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
parts = []
|
||||
total = 0
|
||||
for part in response.iter_content(65536):
|
||||
total += len(part)
|
||||
if total > 2 * 1024 * 1024:
|
||||
raise ValueError()
|
||||
parts.append(part)
|
||||
raw = b"".join(parts)
|
||||
else:
|
||||
raw = self.service.assets.fetch_url(
|
||||
url, headers=headers, limit=2 * 1024 * 1024
|
||||
)
|
||||
result: dict
|
||||
if config.kind == "ics":
|
||||
events = []
|
||||
text = (
|
||||
raw.decode("utf-8-sig").replace("\r\n ", "").replace("\r\n", "\n")
|
||||
)
|
||||
for block in text.split("BEGIN:VEVENT")[1:]:
|
||||
block = block.split("END:VEVENT")[0]
|
||||
values = {
|
||||
line.split(":", 1)[0].split(";")[0]: line.split(":", 1)[1]
|
||||
for line in block.splitlines()
|
||||
if ":" in line
|
||||
}
|
||||
if "RRULE" in values:
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Recurring ICS events require a calendar provider with expanded instances",
|
||||
422,
|
||||
)
|
||||
if values.get("SUMMARY"):
|
||||
events.append(values["SUMMARY"].replace("\\n", " "))
|
||||
result = {"events": events[:100]}
|
||||
elif config.kind == "google-calendar":
|
||||
result = {
|
||||
"events": [
|
||||
{
|
||||
"title": x.get("summary", "Untitled"),
|
||||
"start": x.get("start", {}).get(
|
||||
"dateTime", x.get("start", {}).get("date")
|
||||
),
|
||||
}
|
||||
for x in json.loads(raw).get("items", [])
|
||||
]
|
||||
}
|
||||
elif config.kind == "home-assistant":
|
||||
result = {
|
||||
"events": [
|
||||
x["entity_id"] + ": " + str(x["state"])
|
||||
for x in json.loads(raw)
|
||||
if not config.entities or x["entity_id"] in config.entities
|
||||
]
|
||||
}
|
||||
else:
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError()
|
||||
result = self.service.settings.sanitize(result)
|
||||
self.cache[id] = (time.monotonic() + 300, result)
|
||||
return result
|
||||
except FlowError:
|
||||
raise
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"External provider unavailable; check connection and credentials",
|
||||
502,
|
||||
) from None
|
||||
Reference in New Issue
Block a user