Skip to content

Commit b9c0183

Browse files
authored
More samples repo into app (#163)
1 parent b5c273a commit b9c0183

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+6023
-116
lines changed

backend/app/api/frames.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
# stdlib ---------------------------------------------------------------------
4-
from datetime import datetime, timedelta
4+
from datetime import datetime
55
from http import HTTPStatus
66
import aiofiles
77
import asyncssh
@@ -19,7 +19,6 @@
1919

2020
# third-party ---------------------------------------------------------------
2121
import httpx
22-
from jose import JWTError, jwt
2322
from fastapi import Depends, File, Form, Request, HTTPException, UploadFile, Header
2423
from fastapi.responses import Response, StreamingResponse
2524
from sqlalchemy.orm import Session
@@ -48,7 +47,7 @@
4847
FrameCreateRequest,
4948
FrameUpdateRequest,
5049
)
51-
from app.api.auth import ALGORITHM, SECRET_KEY, get_current_user
50+
from app.api.auth import get_current_user
5251
from app.config import config
5352
from app.utils.network import is_safe_host
5453
from app.utils.remote_exec import (
@@ -73,6 +72,7 @@
7372
from app.models.assets import copy_custom_fonts_to_local_source_folder
7473
from app.models.settings import get_settings_dict
7574
from app.utils.local_exec import exec_local_command
75+
from app.utils.jwt_tokens import create_scoped_token_response, validate_scoped_token
7676
from . import api_with_auth, api_no_auth
7777

7878

@@ -538,12 +538,7 @@ async def api_frame_get_asset(
538538
except HTTPException:
539539
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
540540
elif token:
541-
try:
542-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
543-
if payload.get("sub") != f"frame={id}":
544-
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
545-
except JWTError:
546-
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
541+
validate_scoped_token(token, expected_subject=f"frame={id}")
547542
else:
548543
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized")
549544

@@ -674,12 +669,7 @@ async def api_frame_get_logs(id: int, db: Session = Depends(get_db)):
674669
"/frames/{id:int}/image_token", response_model=FrameImageLinkResponse
675670
)
676671
async def get_image_token(id: int):
677-
expire_minutes = 5
678-
now = datetime.utcnow()
679-
expire = now + timedelta(minutes=expire_minutes)
680-
to_encode = {"sub": f"frame={id}", "exp": expire}
681-
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
682-
return {"token": token, "expires_in": int((expire - now).total_seconds())}
672+
return create_scoped_token_response(f"frame={id}")
683673

684674

685675
@api_no_auth.get("/frames/{id:int}/image")
@@ -691,12 +681,7 @@ async def api_frame_get_image(
691681
redis: Redis = Depends(get_redis),
692682
):
693683
if config.HASSIO_RUN_MODE != "ingress":
694-
try:
695-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
696-
if payload.get("sub") != f"frame={id}":
697-
raise HTTPException(status_code=401, detail="Unauthorized")
698-
except JWTError:
699-
raise HTTPException(status_code=401, detail="Unauthorized")
684+
validate_scoped_token(token, expected_subject=f"frame={id}")
700685

701686
frame = db.get(Frame, id)
702687
if frame is None:

backend/app/api/repositories.py

Lines changed: 168 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import logging
22
import asyncio
3+
import json
34
from datetime import datetime, timedelta
45
from http import HTTPStatus
6+
from pathlib import Path
57
from fastapi import Depends, HTTPException
8+
from fastapi.responses import FileResponse
69
from sqlalchemy.exc import SQLAlchemyError
710
from sqlalchemy.orm import Session
811
from urllib.parse import urlparse
@@ -15,13 +18,85 @@
1518
RepositoryCreateRequest,
1619
RepositoryUpdateRequest,
1720
RepositoryResponse,
18-
RepositoriesListResponse
21+
RepositoriesListResponse,
22+
RepositoryImageTokenResponse,
1923
)
20-
from . import api_with_auth
24+
from app.config import config
25+
from app.utils.jwt_tokens import create_scoped_token_response, validate_scoped_token
26+
from . import api_with_auth, api_no_auth
2127

2228
FRAMEOS_SAMPLES_URL = "https://repo.frameos.net/samples/repository.json"
2329
FRAMEOS_GALLERY_URL = "https://repo.frameos.net/gallery/repository.json"
2430

31+
SYSTEM_REPOSITORIES_PATH = Path(__file__).resolve().parents[3] / "repo" / "scenes"
32+
33+
34+
def _system_template_subject(repository_slug: str, template_slug: str) -> str:
35+
return f"system-template={repository_slug}/{template_slug}"
36+
37+
38+
def _load_template_definition(repository_slug: str, template_dir: Path):
39+
template_path = template_dir / "template.json"
40+
if not template_path.is_file():
41+
return None
42+
43+
with template_path.open("r", encoding="utf-8") as template_file:
44+
template_data = json.load(template_file)
45+
46+
image_path = template_data.get("image")
47+
if image_path:
48+
template_data["image"] = f"/api/repositories/system/{repository_slug}/templates/{template_dir.name}/image"
49+
50+
scenes_reference = template_data.get("scenes")
51+
if isinstance(scenes_reference, str):
52+
scenes_path = _resolve_template_resource(template_dir, scenes_reference)
53+
if scenes_path and scenes_path.is_file():
54+
with scenes_path.open("r", encoding="utf-8") as scenes_file:
55+
template_data["scenes"] = json.load(scenes_file)
56+
else:
57+
template_data["scenes"] = []
58+
59+
return template_data
60+
61+
62+
def _resolve_template_resource(base_dir: Path, resource_path: str) -> Path | None:
63+
if not resource_path:
64+
return None
65+
66+
relative_path = resource_path[2:] if resource_path.startswith("./") else resource_path
67+
candidate_path = (base_dir / relative_path).resolve()
68+
69+
try:
70+
candidate_path.relative_to(base_dir.resolve())
71+
except ValueError:
72+
return None
73+
74+
return candidate_path
75+
76+
77+
def _load_system_repository(repository_dir: Path):
78+
repository_slug = repository_dir.name
79+
repository_metadata_path = repository_dir / "repository.json"
80+
metadata: dict[str, str | None] = {}
81+
if repository_metadata_path.is_file():
82+
with repository_metadata_path.open("r", encoding="utf-8") as repository_file:
83+
metadata = json.load(repository_file)
84+
85+
templates = []
86+
for template_dir in sorted(path for path in repository_dir.iterdir() if path.is_dir()):
87+
template_definition = _load_template_definition(repository_slug, template_dir)
88+
if template_definition:
89+
templates.append(template_definition)
90+
91+
return {
92+
"id": f"system-{repository_slug}",
93+
"name": metadata.get("name") or repository_slug.title(),
94+
"description": metadata.get("description"),
95+
"url": f"/api/repositories/system/{repository_slug}/repository.json",
96+
"last_updated_at": None,
97+
"templates": templates,
98+
}
99+
25100

26101
@api_with_auth.post("/repositories", response_model=RepositoryResponse, status_code=201)
27102
async def create_repository(data: RepositoryCreateRequest, db: Session = Depends(get_db)):
@@ -44,32 +119,102 @@ async def create_repository(data: RepositoryCreateRequest, db: Session = Depends
44119
logging.error(f'Database error: {e}')
45120
raise HTTPException(status_code=500, detail="Database error")
46121

122+
@api_with_auth.get("/repositories/system", response_model=RepositoriesListResponse)
123+
async def get_system_repositories(db: Session = Depends(get_db)):
124+
if not SYSTEM_REPOSITORIES_PATH.exists():
125+
return []
126+
127+
repositories = []
128+
paths = [path for path in SYSTEM_REPOSITORIES_PATH.iterdir() if path.is_dir()]
129+
for repository_dir in paths:
130+
repositories.append(_load_system_repository(repository_dir))
131+
132+
# Sort order: samples first, then gallery, then everything else alphabetically
133+
def sort_key(repo):
134+
if repo.get('id') == "system-samples":
135+
return (0, "")
136+
elif repo.get('id') == "system-gallery":
137+
return (1, "")
138+
return (2, repo.get('id') or "")
139+
140+
repositories.sort(key=sort_key)
141+
142+
return repositories
143+
144+
145+
@api_with_auth.get(
146+
"/repositories/system/{repository_slug}/templates/{template_slug}/image_token",
147+
response_model=RepositoryImageTokenResponse,
148+
)
149+
async def get_system_repository_image_token(repository_slug: str, template_slug: str):
150+
return create_scoped_token_response(
151+
_system_template_subject(repository_slug, template_slug)
152+
)
153+
154+
155+
@api_no_auth.get("/repositories/system/{repository_slug}/templates/{template_slug}/image")
156+
async def get_system_repository_image(repository_slug: str, template_slug: str, token: str):
157+
if config.HASSIO_RUN_MODE != 'ingress':
158+
validate_scoped_token(
159+
token,
160+
expected_subject=_system_template_subject(repository_slug, template_slug),
161+
)
162+
163+
repository_path = SYSTEM_REPOSITORIES_PATH / repository_slug
164+
if not repository_path.is_dir():
165+
raise HTTPException(status_code=404, detail="Repository not found")
166+
167+
template_path = repository_path / template_slug
168+
if not template_path.is_dir():
169+
raise HTTPException(status_code=404, detail="Template not found")
170+
171+
template_definition_path = template_path / "template.json"
172+
if not template_definition_path.is_file():
173+
raise HTTPException(status_code=404, detail="Template not found")
174+
175+
with template_definition_path.open("r", encoding="utf-8") as template_file:
176+
template_data = json.load(template_file)
177+
178+
image_reference = template_data.get("image")
179+
if not isinstance(image_reference, str) or not image_reference:
180+
raise HTTPException(status_code=404, detail="Template image not found")
181+
182+
image_path = _resolve_template_resource(template_path, image_reference)
183+
if not image_path or not image_path.is_file():
184+
raise HTTPException(status_code=404, detail="Template image not found")
185+
186+
return FileResponse(image_path)
187+
47188
@api_with_auth.get("/repositories", response_model=RepositoriesListResponse)
48189
async def get_repositories(db: Session = Depends(get_db)):
49190
try:
50-
# Remove old repo if it exists
51-
if db.query(Settings).filter_by(key="@system/repository_init_done").first():
52-
old_url = "https://repo.frameos.net/versions/0/templates.json"
53-
repository = db.query(Repository).filter_by(url=old_url).first()
54-
if repository:
55-
db.delete(repository)
56-
db.delete(db.query(Settings).filter_by(key="@system/repository_init_done").first())
57-
db.commit()
191+
if db.query(Settings).filter_by(key="@system/repository_global_cleanup").first():
192+
# We're good here. No need to do all the checks
193+
pass
194+
else:
195+
# Remove old repo if it exists
196+
if db.query(Settings).filter_by(key="@system/repository_init_done").first():
197+
old_url = "https://repo.frameos.net/versions/0/templates.json"
198+
repository = db.query(Repository).filter_by(url=old_url).first()
199+
if repository:
200+
db.delete(repository)
201+
db.delete(db.query(Settings).filter_by(key="@system/repository_init_done").first())
202+
db.commit()
58203

59-
# Create samples repo if not done
60-
if not db.query(Settings).filter_by(key="@system/repository_samples_done").first():
61-
repository = Repository(name="", url=FRAMEOS_SAMPLES_URL)
62-
await repository.update_templates()
63-
db.add(repository)
64-
db.add(Settings(key="@system/repository_samples_done", value="true"))
65-
db.commit()
204+
# delete old gallery/samples repos
205+
if db.query(Settings).filter_by(key="@system/repository_samples_done").first():
206+
repository = db.query(Repository).filter_by(url=FRAMEOS_SAMPLES_URL).first()
207+
if repository:
208+
db.delete(repository)
209+
db.delete(db.query(Settings).filter_by(key="@system/repository_samples_done").first())
210+
211+
if db.query(Settings).filter_by(key="@system/repository_gallery_done").first():
212+
repository = db.query(Repository).filter_by(url=FRAMEOS_GALLERY_URL).first()
213+
if repository:
214+
db.delete(repository)
215+
db.delete(db.query(Settings).filter_by(key="@system/repository_gallery_done").first())
66216

67-
# Create gallery repo if not done
68-
if not db.query(Settings).filter_by(key="@system/repository_gallery_done").first():
69-
repository = Repository(name="", url=FRAMEOS_GALLERY_URL)
70-
await repository.update_templates()
71-
db.add(repository)
72-
db.add(Settings(key="@system/repository_gallery_done", value="true"))
217+
db.add(Settings(key="@system/repository_global_cleanup", value="true"))
73218
db.commit()
74219

75220
repositories = db.query(Repository).all()

backend/app/api/scene_images.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import io
2-
from jose import JWTError, jwt
32

43
from fastapi import Depends, HTTPException, Request
54
from fastapi.responses import StreamingResponse
65
from PIL import Image, ImageDraw, ImageFont
76
from sqlalchemy.orm import Session
8-
from app.api.auth import ALGORITHM, SECRET_KEY
9-
107
from app.config import config
118
from app.database import get_db
129
from app.models.scene_image import SceneImage # created earlier
1310
from app.models.frame import Frame
1411
from . import api_no_auth
12+
from app.utils.jwt_tokens import validate_scoped_token
1513

1614

1715
def _generate_placeholder(
@@ -95,12 +93,7 @@ async def get_scene_image(
9593
"""
9694

9795
if config.HASSIO_RUN_MODE != 'ingress':
98-
try:
99-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
100-
if payload.get("sub") != f"frame={frame_id}":
101-
raise HTTPException(status_code=401, detail="Unauthorized")
102-
except JWTError:
103-
raise HTTPException(status_code=401, detail="Unauthorized")
96+
validate_scoped_token(token, expected_subject=f"frame={frame_id}")
10497

10598

10699
img_row: SceneImage | None = (

backend/app/api/templates.py

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@
33
import zipfile
44
import json
55
import string
6-
from datetime import datetime, timedelta
76

87
import httpx
98
from PIL import Image
109
from fastapi import Depends, HTTPException, UploadFile, File, Form, Request
1110
from fastapi.responses import Response, StreamingResponse, JSONResponse
12-
from jose import jwt, JWTError
1311
from sqlalchemy.orm import Session
1412

1513
from app.database import get_db
@@ -24,9 +22,9 @@
2422
CreateTemplateRequest,
2523
UpdateTemplateRequest,
2624
)
27-
from app.api.auth import SECRET_KEY, ALGORITHM
2825
from app.api import api_with_auth, api_no_auth
2926
from app.redis import get_redis
27+
from app.utils.jwt_tokens import create_scoped_token_response, validate_scoped_token
3028

3129

3230
def respond_with_template(template: Template):
@@ -241,28 +239,13 @@ async def get_template(template_id: str, db: Session = Depends(get_db)):
241239

242240
@api_with_auth.get("/templates/{template_id}/image_token", response_model=TemplateImageTokenResponse)
243241
async def get_image_token(template_id: str):
244-
expire_minutes = 5
245-
now = datetime.utcnow()
246-
expire = now + timedelta(minutes=expire_minutes)
247-
to_encode = {"sub": f"template={template_id}", "exp": expire}
248-
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
249-
expires_in = int((expire - now).total_seconds())
250-
251-
return {
252-
"token": token,
253-
"expires_in": expires_in
254-
}
242+
return create_scoped_token_response(f"template={template_id}")
255243

256244
@api_no_auth.get("/templates/{template_id}/image")
257245
async def get_template_image(template_id: str, token: str, request: Request, db: Session = Depends(get_db)):
258246
if config.HASSIO_RUN_MODE != 'ingress':
259247
# All modes except ingress require a token in the url
260-
try:
261-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
262-
if payload.get("sub") != f"template={template_id}":
263-
raise HTTPException(status_code=401, detail="Unauthorized")
264-
except JWTError:
265-
raise HTTPException(status_code=401, detail="Unauthorized")
248+
validate_scoped_token(token, expected_subject=f"template={template_id}")
266249

267250
template = db.get(Template, template_id)
268251
if not template or not template.image:

backend/app/schemas/common.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel
2+
3+
4+
class ImageTokenResponse(BaseModel):
5+
token: str
6+
expires_in: int
7+

0 commit comments

Comments
 (0)