Skip to content

Commit 3c57862

Browse files
committed
mostly working
1 parent bd46abe commit 3c57862

21 files changed

+108
-99
lines changed

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ npm-debug.log
2525
.env
2626
.aws
2727
frameos/nimble.paths
28+
db

backend/app/api/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from fastapi import APIRouter
22

3-
# When using HASSIO_MODE (Home Assistant ingress with automatic login), we make this distinction:
3+
# When using HASSIO_RUN_MODE (Home Assistant ingress with automatic login), we make this distinction:
44
# - api_public: exported to the local network via port 8989, no authentication required
55
# - api_no_auth: accessible via Home Assistant ingress (on port 8990) without authentication
66
# - api_with_auth: accessible via Home Assistant ingress (on port 8990) without authentication
77

8-
# When not using HASSIO_MODE (running directly from Docker), we make this distinction:
8+
# When not using HASSIO_RUN_MODE (running directly from Docker), we make this distinction:
99
# - api_public: routes that do not require authentication
1010
# - api_no_auth: routes that do not require authentication
1111
# - api_with_auth: routes that can only be accessed by authenticated users

backend/app/api/auth.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sqlalchemy.orm import Session
99
from arq import ArqRedis as Redis
1010

11-
from app.config import get_config
11+
from app.config import config
1212
from app.models.user import User
1313
from app.database import get_db
1414
from app.redis import get_redis
@@ -17,7 +17,6 @@
1717

1818
from . import api_no_auth
1919

20-
config = get_config()
2120
SECRET_KEY = config.SECRET_KEY
2221
ALGORITHM = "HS256"
2322
ACCESS_TOKEN_EXPIRE_MINUTES = 7 * 24 * 60 # 7 days
@@ -35,14 +34,6 @@ def create_access_token(data: dict, expires_delta: Optional[datetime.timedelta]
3534
return encoded_jwt
3635

3736
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
38-
# if config.HASSIO_MODE == "ingress":
39-
# user = db.query(User).first()
40-
# if user is None:
41-
# user = User(email="[email protected]", password="")
42-
# db.add(user)
43-
# db.commit()
44-
# return user
45-
4637
credentials_exception = HTTPException(
4738
status_code=status.HTTP_401_UNAUTHORIZED,
4839
detail="Could not validate credentials",
@@ -63,8 +54,8 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
6354

6455
@api_no_auth.post("/login", response_model=Token)
6556
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
66-
if config.HASSIO_MODE is not None:
67-
raise HTTPException(status_code=401, detail="Login not allowed with HASSIO_MODE")
57+
if config.HASSIO_RUN_MODE is not None:
58+
raise HTTPException(status_code=401, detail="Login not allowed with HASSIO_RUN_MODE")
6859
email = form_data.username
6960
password = form_data.password
7061
ip = request.client.host
@@ -88,8 +79,8 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
8879

8980
@api_no_auth.post("/signup")
9081
async def signup(data: UserSignup, db: Session = Depends(get_db)):
91-
if config.HASSIO_MODE is not None:
92-
raise HTTPException(status_code=401, detail="Signup not allowed with HASSIO_MODE")
82+
if config.HASSIO_RUN_MODE is not None:
83+
raise HTTPException(status_code=401, detail="Signup not allowed with HASSIO_RUN_MODE")
9384

9485
# Check if there is already a user registered (one-user system)
9586
if db.query(User).first() is not None:

backend/app/api/frames.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
FrameAssetsResponse, FrameCreateRequest, FrameUpdateRequest
2727
)
2828
from app.api.auth import ALGORITHM, SECRET_KEY
29+
from app.config import config
2930
from app.utils.network import is_safe_host
3031
from app.redis import get_redis
3132
from app.config import Config, get_config
@@ -73,12 +74,13 @@ async def get_image_link(id: int, config: Config = Depends(get_config)):
7374

7475
@api_no_auth.get("/frames/{id:int}/image")
7576
async def api_frame_get_image(id: int, token: str, request: Request, db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
76-
try:
77-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
78-
if payload.get("sub") != f"frame={id}":
77+
if config.HASSIO_RUN_MODE != 'ingress':
78+
try:
79+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
80+
if payload.get("sub") != f"frame={id}":
81+
raise HTTPException(status_code=401, detail="Unauthorized")
82+
except JWTError:
7983
raise HTTPException(status_code=401, detail="Unauthorized")
80-
except JWTError:
81-
raise HTTPException(status_code=401, detail="Unauthorized")
8284

8385
frame = db.get(Frame, id)
8486
if frame is None:

backend/app/api/templates.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sqlalchemy.orm import Session
1414

1515
from app.database import get_db
16+
from app.config import config
1617
from arq import ArqRedis as Redis
1718
from app.models.template import Template
1819
from app.models.frame import Frame
@@ -255,15 +256,13 @@ async def get_image_link(template_id: str, config: Config = Depends(get_config))
255256

256257
@api_no_auth.get("/templates/{template_id}/image")
257258
async def get_template_image(template_id: str, token: str, request: Request, db: Session = Depends(get_db)):
258-
"""
259-
Access to image is via token. This is how we originally protected direct image access.
260-
"""
261-
try:
262-
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
263-
if payload.get("sub") != f"template={template_id}":
259+
if config.HASSIO_RUN_MODE != 'ingress':
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:
264265
raise HTTPException(status_code=401, detail="Unauthorized")
265-
except JWTError:
266-
raise HTTPException(status_code=401, detail="Unauthorized")
267266

268267
template = db.get(Template, template_id)
269268
if not template or not template.image:

backend/app/config.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import secrets
33
import uuid
44
from dotenv import load_dotenv
5+
import requests
56

67
def get_bool_env(key: str) -> bool:
78
return os.environ.get(key, '0').lower() in ['true', '1', 'yes']
@@ -26,12 +27,29 @@ class Config:
2627
DATABASE_URL = os.environ.get('DATABASE_URL') or 'sqlite:///../db/frameos.db'
2728
REDIS_URL = os.environ.get('REDIS_URL') or 'redis://localhost:6379/0'
2829
INSTANCE_ID = INSTANCE_ID
29-
HASSIO_MODE = os.environ.get('HASSIO_MODE', None)
30-
HASSIO_INGRESS_PATH = os.environ.get('HASSIO_INGRESS_PATH', None)
30+
HASSIO_RUN_MODE = os.environ.get('HASSIO_RUN_MODE', None)
31+
HASSIO_TOKEN = os.environ.get('HASSIO_TOKEN', None)
32+
SUPERVISOR_TOKEN = os.environ.get('SUPERVISOR_TOKEN', None)
33+
HOSTNAME = os.environ.get('HOSTNAME', None)
34+
base_path = ''
3135

32-
@property
33-
def base_path(self) -> str:
34-
return self.HASSIO_INGRESS_PATH or ""
36+
def __init__(self):
37+
# Get Home Assistant Supervisor Ingress URL
38+
if self.HASSIO_RUN_MODE == "ingress" and self.SUPERVISOR_TOKEN:
39+
try:
40+
headers = {
41+
"Authorization": f"Bearer {self.SUPERVISOR_TOKEN}",
42+
"Content-Type": "application/json",
43+
}
44+
response = requests.get("http://supervisor/addons/self/info", headers=headers)
45+
info = response.json()
46+
ingress_url = info.get("data", {}).get("ingress_url")
47+
if ingress_url and ingress_url.endswith("/"):
48+
ingress_url = ingress_url[:-1]
49+
self.base_path = ingress_url
50+
print(f"🟢 Fetched HA ingress URL: {self.base_path}")
51+
except Exception as e:
52+
print(f"🔴 Failed to get HA ingress URL: {e}")
3553

3654
class DevelopmentConfig(Config):
3755
DEBUG = True
@@ -53,7 +71,10 @@ class ProductionConfig(Config):
5371
def __init__(self):
5472
super().__init__()
5573
if self.SECRET_KEY is None:
56-
raise ValueError('SECRET_KEY must be set in production')
74+
if self.HASSIO_TOKEN is not None:
75+
self.SECRET_KEY = secrets.token_urlsafe(32)
76+
else:
77+
raise ValueError('SECRET_KEY must be set in production')
5778

5879
configs = {
5980
"development": DevelopmentConfig,
@@ -67,3 +88,6 @@ def get_config() -> Config:
6788
is_dev = get_bool_env('DEBUG')
6889
config_class = TestConfig if is_test else DevelopmentConfig if is_dev else ProductionConfig
6990
return config_class()
91+
92+
# Singleton instance
93+
config = get_config()

backend/app/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
from arq import ArqRedis as Redis # noqa: E402
1010
from httpx import AsyncClient # noqa: E402
1111
from httpx._transports.asgi import ASGITransport # noqa: E402
12-
from app.config import get_config # noqa: E402
12+
from app.config import config # noqa: E402
1313
from app.fastapi import app # noqa: E402
1414
from app.database import SessionLocal, engine, Base # noqa: E402
1515
from app.models.user import User # noqa: E402
1616

1717
@pytest.fixture(autouse=True)
1818
def setup_and_teardown_db():
19-
if not get_config().TEST:
19+
if not config.TEST:
2020
raise ValueError("Tests should only be run with TEST=1 (or via bin/tests) as doing otherwise may wipe your database")
2121

2222
# Create all tables before each test
@@ -35,7 +35,7 @@ async def db():
3535

3636
@pytest_asyncio.fixture
3737
async def redis():
38-
client = Redis.from_url(get_config().REDIS_URL)
38+
client = Redis.from_url(config.REDIS_URL)
3939
try:
4040
yield client
4141
finally:

backend/app/database.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from sqlalchemy import create_engine
22
from sqlalchemy.orm import declarative_base, sessionmaker
3-
from app.config import get_config
3+
from app.config import config
44

5-
config = get_config()
65
engine = create_engine(config.DATABASE_URL)
76
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
87
Base = declarative_base()

backend/app/fastapi.py

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from app.middleware import GzipRequestMiddleware
1414

1515
from app.websockets import register_ws_routes, redis_listener
16-
from app.config import get_config
16+
from app.config import config
1717
from app.utils.sentry import initialize_sentry
1818

1919
@contextlib.asynccontextmanager
@@ -24,34 +24,32 @@ async def lifespan(app: FastAPI):
2424
# optionally do cleanup here
2525
task.cancel()
2626

27-
config = get_config()
28-
2927
app = FastAPI(lifespan=lifespan)
3028
app.add_middleware(GZipMiddleware)
3129
app.add_middleware(GzipRequestMiddleware)
3230

3331
register_ws_routes(app)
3432

35-
if config.HASSIO_MODE:
36-
if config.HASSIO_MODE == "public":
33+
if config.HASSIO_RUN_MODE:
34+
if config.HASSIO_RUN_MODE == "public":
35+
app.include_router(api_public, prefix="/api")
36+
elif config.HASSIO_RUN_MODE == "ingress":
3737
app.include_router(api_public, prefix="/api")
38-
elif config.HASSIO_MODE == "ingress":
39-
app.include_router(api_public, prefix=config.base_path + "/api")
40-
app.include_router(api_no_auth, prefix=config.base_path + "/api")
41-
app.include_router(api_with_auth, prefix=config.base_path + "/api")
38+
app.include_router(api_no_auth, prefix="/api")
39+
app.include_router(api_with_auth, prefix="/api")
4240
else:
43-
raise ValueError("Invalid HASSIO_MODE")
41+
raise ValueError("Invalid HASSIO_RUN_MODE")
4442
else:
4543
app.include_router(api_public, prefix="/api")
4644
app.include_router(api_no_auth, prefix="/api")
4745
app.include_router(api_with_auth, prefix="/api", dependencies=[Depends(get_current_user)])
4846

49-
# Serve HTML and static files in all cases except for public HASSIO_MODE
50-
serve_html = config.HASSIO_MODE != "public"
47+
# Serve HTML and static files in all cases except for public HASSIO_RUN_MODE
48+
serve_html = config.HASSIO_RUN_MODE != "public"
5149
if serve_html:
52-
app.mount(config.base_path + "/assets", StaticFiles(directory="../frontend/dist/assets"), name="assets")
53-
app.mount(config.base_path + "/img", StaticFiles(directory="../frontend/dist/img"), name="img")
54-
app.mount(config.base_path + "/static", StaticFiles(directory="../frontend/dist/static"), name="static")
50+
app.mount("/assets", StaticFiles(directory="../frontend/dist/assets"), name="assets")
51+
app.mount("/img", StaticFiles(directory="../frontend/dist/img"), name="img")
52+
app.mount("/static", StaticFiles(directory="../frontend/dist/static"), name="static")
5553

5654
# Public config for the frontend
5755
frameos_app_config = {}
@@ -64,25 +62,20 @@ async def lifespan(app: FastAPI):
6462
else:
6563
raise
6664

67-
if config.HASSIO_MODE:
68-
frameos_app_config["HASSIO_MODE"] = config.HASSIO_MODE
65+
if config.HASSIO_RUN_MODE:
66+
frameos_app_config["HASSIO_RUN_MODE"] = config.HASSIO_RUN_MODE
6967
if config.base_path:
7068
frameos_app_config["base_path"] = config.base_path
71-
index_html = index_html.replace('<base href="/">', f'<base href="{config.base_path}/">')
69+
7270
index_html = index_html.replace('<head>', f'<head><script>window.FRAMEOS_APP_CONFIG={json.dumps(frameos_app_config)}</script>')
7371

74-
@app.get(config.base_path + "/")
72+
@app.get("/")
7573
async def read_index():
7674
return HTMLResponse(index_html)
7775

78-
if config.base_path:
79-
@app.get(config.base_path)
80-
async def read_index():
81-
return HTMLResponse(index_html)
82-
8376
@app.exception_handler(StarletteHTTPException)
8477
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
85-
if os.environ.get("TEST") == "1" or exc.status_code != 404 or (config.base_path and not request.url.path.startswith(config.base_path)):
78+
if os.environ.get("TEST") == "1" or exc.status_code != 404:
8679
return JSONResponse(
8780
status_code=exc.status_code,
8881
content={"detail": exc.detail or f"Error {exc.status_code}"}
@@ -100,8 +93,8 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
10093

10194
if __name__ == '__main__':
10295
# run migrations
103-
if get_config().DEBUG:
104-
database_url = get_config().DATABASE_URL
96+
if config.DEBUG:
97+
database_url = config.DATABASE_URL
10598
if database_url.startswith("sqlite:///../db/"):
10699
os.makedirs('../db', exist_ok=True)
107100
# start server

backend/app/redis.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from arq import ArqRedis
22
from arq.connections import ConnectionPool
3-
from app.config import get_config
3+
from app.config import config
44

55
def create_redis_connection():
6-
pool = ConnectionPool.from_url(get_config().REDIS_URL)
6+
pool = ConnectionPool.from_url(config.REDIS_URL)
77
return ArqRedis(pool)
88

99
async def get_redis():

0 commit comments

Comments
 (0)