197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
"""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
|