Add read-only NAS and WebDAV photo sources with random scheduling
This commit is contained in:
+136
-74
@@ -317,6 +317,68 @@
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/photo-sources": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/photo-sources/{source_id}": {
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"delete": [
|
||||
"204",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/photo-sources/{source_id}/images": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/images/{image_id}": {
|
||||
"delete": [
|
||||
"202",
|
||||
@@ -625,6 +687,80 @@
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/official/connection": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"post": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"delete": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/widgets": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/current-content": {
|
||||
"get": [
|
||||
"200",
|
||||
@@ -766,79 +902,5 @@
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/widgets": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
},
|
||||
"/api/v1/devices/{device_id}/official/connection": {
|
||||
"get": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"post": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"put": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
],
|
||||
"delete": [
|
||||
"200",
|
||||
"400",
|
||||
"401",
|
||||
"403",
|
||||
"404",
|
||||
"409",
|
||||
"413",
|
||||
"422",
|
||||
"429",
|
||||
"500",
|
||||
"502",
|
||||
"503"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TemplateContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/FutureContent"
|
||||
}
|
||||
@@ -367,6 +370,7 @@
|
||||
"image": "#/components/schemas/ImageContent",
|
||||
"image_url": "#/components/schemas/URLContent",
|
||||
"markdown": "#/components/schemas/FutureContent",
|
||||
"photo_source": "#/components/schemas/PhotoSourceContent",
|
||||
"template": "#/components/schemas/TemplateContent"
|
||||
}
|
||||
}
|
||||
@@ -752,5 +756,215 @@
|
||||
"error"
|
||||
],
|
||||
"title": "ErrorResponse"
|
||||
},
|
||||
"PhotoSourceContent": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "photo_source",
|
||||
"title": "Type"
|
||||
},
|
||||
"source_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]{1,60}$",
|
||||
"title": "Source Id"
|
||||
},
|
||||
"item_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 2048
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Item Id"
|
||||
},
|
||||
"fit": {
|
||||
"$ref": "#/components/schemas/Fit",
|
||||
"default": "contain"
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/components/schemas/Profile",
|
||||
"default": "color"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"source_id"
|
||||
],
|
||||
"title": "PhotoSourceContent"
|
||||
},
|
||||
"PhotoSourceInput": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"title": "Name"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"webdav"
|
||||
],
|
||||
"title": "Kind"
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"minLength": 1,
|
||||
"title": "Location"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"title": "Recursive",
|
||||
"default": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"default": true
|
||||
},
|
||||
"credential_env": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Credential Env"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"kind",
|
||||
"location"
|
||||
],
|
||||
"title": "PhotoSourceInput"
|
||||
},
|
||||
"PhotoSourceView": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"title": "Name"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"webdav"
|
||||
],
|
||||
"title": "Kind"
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"minLength": 1,
|
||||
"title": "Location"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean",
|
||||
"title": "Recursive",
|
||||
"default": true
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled",
|
||||
"default": true
|
||||
},
|
||||
"credential_env": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Credential Env"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"kind",
|
||||
"location",
|
||||
"id"
|
||||
],
|
||||
"title": "PhotoSourceView"
|
||||
},
|
||||
"PhotoItem": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"title": "PhotoItem"
|
||||
},
|
||||
"PhotoCatalog": {
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PhotoItem"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Items"
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"title": "Total"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"title": "Offset"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"title": "Limit"
|
||||
},
|
||||
"catalog_limit": {
|
||||
"type": "integer",
|
||||
"title": "Catalog Limit"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"required": [
|
||||
"items",
|
||||
"total",
|
||||
"offset",
|
||||
"limit",
|
||||
"catalog_limit"
|
||||
],
|
||||
"title": "PhotoCatalog"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||
|
||||
|
||||
def photo(path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (160, 200), "blue").save(path)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def photos(settings, service):
|
||||
settings.photo_root = settings.data_dir.parent / "photos"
|
||||
photo(settings.photo_root / "family" / "a.jpg")
|
||||
photo(settings.photo_root / "family" / "nested" / "b.png")
|
||||
photo(settings.photo_root / "family" / "@eaDir" / "thumb.jpg")
|
||||
(settings.photo_root / "family" / "note.txt").write_text("not a photo")
|
||||
service.photo_sources.save(
|
||||
"family", PhotoSourceInput(name="Family", kind="local", location="family")
|
||||
)
|
||||
return service.photo_sources
|
||||
|
||||
|
||||
def test_local_catalog_filters_and_paginates(client, photos):
|
||||
out = client.get("/api/v1/photo-sources/family/images?limit=1").json()
|
||||
assert out["total"] == 2 and len(out["items"]) == 1
|
||||
assert (
|
||||
client.get("/api/v1/photo-sources/family/images?offset=1").json()["items"][0][
|
||||
"name"
|
||||
]
|
||||
== "b.png"
|
||||
)
|
||||
assert client.get("/api/v1/photo-sources").json()[0]["id"] == "family"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("location", ["../", "/etc"])
|
||||
def test_path_escape_forbidden(client, photos, location):
|
||||
r = client.put(
|
||||
"/api/v1/photo-sources/escape",
|
||||
json={"name": "escape", "kind": "local", "location": location},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_symlinks_and_post_catalog_swap_are_blocked(photos, settings):
|
||||
outside = settings.data_dir.parent / "outside.jpg"
|
||||
photo(outside)
|
||||
(settings.photo_root / "family" / "link.jpg").symlink_to(outside)
|
||||
items = photos.catalog("family", refresh=True)
|
||||
assert len(items) == 2
|
||||
path = settings.photo_root / "family" / "a.jpg"
|
||||
path.unlink()
|
||||
path.symlink_to(outside)
|
||||
with pytest.raises(FlowError):
|
||||
photos.read("family", "family/a.jpg")
|
||||
|
||||
|
||||
def test_random_preview_is_local_and_display_uses_same_asset(client, service, photos):
|
||||
before = service.registry.get("living-room").get_current_image()
|
||||
r = client.post(
|
||||
"/api/v1/devices/living-room/upload",
|
||||
json={"type": "photo_source", "source_id": "family"},
|
||||
)
|
||||
assert r.status_code == 202
|
||||
assert service.process_one()
|
||||
op = client.get("/api/v1/operations/" + r.json()["operation_id"]).json()
|
||||
assert op["status"] == "COMPLETED"
|
||||
assert service.registry.get("living-room").get_current_image() == before
|
||||
image = client.get(op["result"]["url"])
|
||||
assert image.status_code == 200
|
||||
assert Image.open(BytesIO(image.content)).size == (1200, 1600)
|
||||
r = client.post(
|
||||
"/api/v1/display",
|
||||
json={"content": {"type": "image", "asset_id": op["result"]["asset_id"]}},
|
||||
)
|
||||
assert r.status_code == 202 and service.process_one()
|
||||
|
||||
|
||||
def test_scheduler_source_persistence_and_delete_protection(app, client, photos):
|
||||
payload = {
|
||||
"name": "Random family photo",
|
||||
"enabled": False,
|
||||
"schedule": {"type": "daily", "time": "08:00"},
|
||||
"source": {"type": "photo_source", "source_id": "family"},
|
||||
}
|
||||
job = client.post("/api/v1/jobs", json=payload)
|
||||
assert job.status_code == 201
|
||||
assert app.state.scheduler.jobs()[0]["source"]["source_id"] == "family"
|
||||
assert client.delete("/api/v1/photo-sources/family").status_code == 409
|
||||
assert client.delete("/api/v1/jobs/" + job.json()["id"]).status_code == 204
|
||||
assert client.delete("/api/v1/photo-sources/family").status_code == 204
|
||||
|
||||
|
||||
def test_empty_disabled_unknown_item(photos, settings):
|
||||
with pytest.raises(FlowError):
|
||||
photos.read("family", "../outside.jpg")
|
||||
photos.save(
|
||||
"family",
|
||||
PhotoSourceInput(name="Family", kind="local", location="family", enabled=False),
|
||||
)
|
||||
with pytest.raises(FlowError):
|
||||
photos.read("family")
|
||||
folder = settings.photo_root / "empty"
|
||||
folder.mkdir()
|
||||
photos.save("empty", PhotoSourceInput(name="Empty", kind="local", location="empty"))
|
||||
with pytest.raises(FlowError):
|
||||
photos.read("empty")
|
||||
|
||||
|
||||
def test_webdav_metadata_cached_credentials_not_persisted(
|
||||
service, settings, monkeypatch
|
||||
):
|
||||
settings.image_url_hosts = ["dav.example"]
|
||||
credential = "FRAME_FLOW_SOURCE_TEST"
|
||||
monkeypatch.setenv(
|
||||
credential, json.dumps({"username": "test-user", "password": "test-password"})
|
||||
)
|
||||
provider = service.photo_sources
|
||||
provider.save(
|
||||
"dav",
|
||||
PhotoSourceInput(
|
||||
name="Cloud",
|
||||
kind="webdav",
|
||||
location="https://dav.example/photos",
|
||||
credential_env=credential,
|
||||
),
|
||||
)
|
||||
calls = []
|
||||
xml = b"""<d:multistatus xmlns:d="DAV:"><d:response><d:href>/photos/a.jpg</d:href></d:response><d:response><d:href>/photos/a.jpg</d:href></d:response><d:response><d:href>https://evil.example/a.jpg</d:href></d:response><d:response><d:href>/photos/%2e%2e/private.jpg</d:href></d:response><d:response><d:href>/photos/sub</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>"""
|
||||
|
||||
def fetch(url, **kwargs):
|
||||
calls.append((url, kwargs))
|
||||
return xml if kwargs.get("method") == "PROPFIND" else b"photo"
|
||||
|
||||
monkeypatch.setattr(service.assets, "fetch_url", fetch)
|
||||
items = provider.catalog("dav")
|
||||
assert len(items) == 1
|
||||
assert provider.catalog("dav") == items and len(calls) == 1
|
||||
assert calls[0][1]["headers"]["Depth"] == "1"
|
||||
assert provider.read("dav")[0] == b"photo"
|
||||
assert provider.get("dav").recursive is False
|
||||
stored = service.db.one(
|
||||
"SELECT payload FROM app_config WHERE key='photo_source:dav'"
|
||||
)["payload"]
|
||||
assert "test-password" not in stored and "test-user" not in stored
|
||||
assert "test-password" not in json.dumps(
|
||||
settings.sanitize({"value": "test-password"})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("xml", [b"<!DOCTYPE x><x/>", b"<broken"])
|
||||
def test_webdav_bad_xml(service, settings, monkeypatch, xml):
|
||||
settings.image_url_hosts = ["dav.example"]
|
||||
provider = service.photo_sources
|
||||
provider.save(
|
||||
"dav",
|
||||
PhotoSourceInput(
|
||||
name="Cloud", kind="webdav", location="https://dav.example/photos/"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(service.assets, "fetch_url", lambda *a, **kw: xml)
|
||||
with pytest.raises(FlowError):
|
||||
provider.catalog("dav")
|
||||
|
||||
|
||||
def test_photo_source_permissions_and_openapi(client, photos):
|
||||
schema = client.get("/openapi.json").json()
|
||||
assert "PhotoSourceContent" in schema["components"]["schemas"]
|
||||
assert schema["paths"]["/api/v1/photo-sources"]["get"]["responses"]["200"][
|
||||
"content"
|
||||
]["application/json"]["schema"]["items"]["$ref"].endswith("PhotoSourceView")
|
||||
client.headers["Authorization"] = "Bearer incorrect"
|
||||
assert client.get("/api/v1/photo-sources").status_code == 401
|
||||
|
||||
|
||||
def test_scheduled_random_photo_runs_once_with_restore(app, service, photos):
|
||||
from datetime import datetime
|
||||
|
||||
from pigway_frame_flow.models import JobInput
|
||||
|
||||
scheduler = app.state.scheduler
|
||||
original = service.registry.get("living-room").get_current_image()
|
||||
job = scheduler.save_job(
|
||||
JobInput(
|
||||
name="Random",
|
||||
schedule={"type": "daily", "time": "08:00"},
|
||||
source={"type": "photo_source", "source_id": "family"},
|
||||
display_duration=60,
|
||||
restore_previous=True,
|
||||
)
|
||||
)
|
||||
at = datetime.fromisoformat(job["next_run"])
|
||||
scheduler.tick(at)
|
||||
scheduler.tick(at)
|
||||
rows = service.db.all("SELECT * FROM operations WHERE client_id='scheduler'")
|
||||
assert len(rows) == 1
|
||||
assert service.process_one()
|
||||
op = service.operation(rows[0]["id"])
|
||||
assert op["status"] in ("SYNCING", "COMPLETED")
|
||||
assert service.state("living-room")["previous_image_id"] == original
|
||||
Reference in New Issue
Block a user