atlus/backend/main.py
roberts 9fee17af17 Disable browser caching for all static assets
Add no-cache middleware to set Cache-Control: no-store on CSS, JS,
asset, and HTML responses so code changes appear immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:54:59 -05:00

206 lines
6.7 KiB
Python

"""Atlus — main FastAPI application entry point."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from pydantic import BaseModel
from backend.auth import authenticate_user, create_token, logout
from backend.config import FRONTEND_DIR, HOST, PORT, load_config
from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display
from backend.sessions import manager as session_manager
from backend.display import display_manager
from backend.netwatch import ethernet_watchdog
from backend.routers.plugins import asi_bridge
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("atlus")
# ---------------------------------------------------------------------------
# Lifespan — start/stop background tasks
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Atlus starting on %s:%d", HOST, PORT)
# Autostart configured GUI apps (always-on desktop session)
cfg = load_config()
gui_apps = cfg.get("gui_apps", [])
default_user = cfg.get("default_user")
if gui_apps:
try:
await display_manager.autostart_apps(gui_apps, default_user=default_user)
except Exception as e:
log.warning("Autostart failed: %s", e)
# Start stats broadcaster
broadcaster = asyncio.create_task(stats.stats_broadcaster())
# Start periodic session cleanup (every hour, remove sessions idle > 24h)
async def _session_cleanup_loop():
while True:
await asyncio.sleep(3600)
await session_manager.cleanup_stale(max_idle_hours=24)
cleanup_task = asyncio.create_task(_session_cleanup_loop())
# Start ethernet link watchdog (auto-enable interfaces when cable plugged in)
netwatch_task = asyncio.create_task(ethernet_watchdog())
yield
broadcaster.cancel()
cleanup_task.cancel()
netwatch_task.cancel()
try:
await broadcaster
except asyncio.CancelledError:
pass
try:
await cleanup_task
except asyncio.CancelledError:
pass
try:
await netwatch_task
except asyncio.CancelledError:
pass
# Kill all PTYs on shutdown
session_manager.shutdown_all()
# Stop all virtual displays and GUI apps
await display_manager.shutdown_all()
log.info("Atlus shutdown complete")
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(
title="Atlus",
description="Web-based desktop environment for headless SBCs",
version="0.1.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# No-cache middleware — prevent browsers from caching static assets
# ---------------------------------------------------------------------------
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
"""Add Cache-Control: no-store to all CSS, JS, and asset responses."""
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
path = request.url.path
if path.startswith(("/css/", "/js/", "/assets/")) or path in ("/", "/desktop") or path.endswith(".html"):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
app.add_middleware(NoCacheStaticMiddleware)
# ---------------------------------------------------------------------------
# Auth endpoints (not behind auth — they *create* auth)
# ---------------------------------------------------------------------------
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/api/auth/login")
async def login(req: LoginRequest):
if not authenticate_user(req.username, req.password):
raise HTTPException(401, "Invalid credentials")
token, jti = create_token(req.username)
return {"token": token, "username": req.username}
@app.post("/api/auth/logout")
async def logout_endpoint(request: Request):
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
logout(auth[7:])
return {"ok": True}
# ---------------------------------------------------------------------------
# API routers
# ---------------------------------------------------------------------------
app.include_router(stats.router)
app.include_router(terminal.router)
app.include_router(files.router)
app.include_router(services.router)
app.include_router(processes.router)
app.include_router(settings.router)
app.include_router(network.router)
app.include_router(packages.router)
app.include_router(updates.router)
app.include_router(session.router)
app.include_router(display.router)
app.include_router(asi_bridge.router)
# ---------------------------------------------------------------------------
# Frontend — served as static files, SPA-style routing
# ---------------------------------------------------------------------------
# Mount static assets (CSS, JS, images)
app.mount("/css", StaticFiles(directory=str(FRONTEND_DIR / "css")), name="css")
app.mount("/js", StaticFiles(directory=str(FRONTEND_DIR / "js")), name="js")
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="assets")
@app.get("/")
async def serve_login():
return FileResponse(str(FRONTEND_DIR / "index.html"))
@app.get("/desktop")
async def serve_desktop():
return FileResponse(str(FRONTEND_DIR / "desktop.html"))
# ---------------------------------------------------------------------------
# Error handler
# ---------------------------------------------------------------------------
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
log.exception("Unhandled error: %s", exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"backend.main:app",
host=HOST,
port=PORT,
reload=False,
log_level="info",
)