Private
Public Access
Deployment artifacts for the two-server architecture from ARCHITECTURE.md §9, grounded in verified Debian 13 (trixie) package facts (Python 3.13, PostgreSQL 17, Node.js 20, redis-server 8.0, certbot 4.0, ufw -- confirmed rather than guessed) rather than a generic "modern Linux" guide: deploy/systemd/chatapp.service, deploy/chatapp.env.example, deploy/backup-postgres.sh, deploy/upgrade.sh, and DEPLOYMENT.md as the actual numbered runbook. Revised mid-implementation once the user clarified the app sits behind an existing, separate Nginx Proxy Manager rather than local Nginx+certbot: dropped the local Nginx config entirely, gunicorn now binds a TCP port instead of a Unix socket, and app/main.py gained a static-file mount + SPA fallback route so gunicorn alone serves the built frontend, /api, and /ws on one port -- what lets NPM's simple one-upstream-per-domain mode work with zero custom path routing. Path-traversal-guarded (full_path comes straight from the URL) and cache-header-differentiated (far-future immutable on Vite's content-hashed assets, no-cache on index.html/sw.js/ manifest so a deploy actually propagates instead of leaving clients on a stale service worker) -- verified locally against a real gunicorn process serving a real frontend build, not just eyeballed. Two real gaps found and fixed alongside the docs, not just noted: gunicorn wasn't a dependency anywhere despite being the whole app-server design, and there was no WebSocket reconnect logic on the client -- a reverse proxy's idle-connection timeout (NPM's or otherwise) would have silently killed a quiet chat connection with nothing to recover it. Added exponential-backoff reconnect to useChatSocket.ts, verified by hand (killed and restarted the local dev backend mid-session, confirmed auto-reconnect and that a message sends successfully afterward with no page reload). Every command in DEPLOYMENT.md that could be verified locally, was: the exact systemd ExecStart line run against local dev Postgres/Redis with clean SIGTERM shutdown, the static-file serving behavior against a real build, both shell scripts syntax-checked. What couldn't be verified from this sandbox (actual Debian 13 hardware, Nginx Proxy Manager itself) is flagged explicitly in the plan rather than claimed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
124 lines
5.3 KiB
Python
124 lines
5.3 KiB
Python
import asyncio
|
|
import contextlib
|
|
import pathlib
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import FileResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from redis.asyncio import Redis
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app.config import settings
|
|
from app.routers import admin, auth, bots, health, invites, push, rooms, webhooks
|
|
from app.ws.broadcaster import RoomBroadcaster
|
|
from app.ws.chat import router as ws_router
|
|
from app.ws.connection_manager import ConnectionManager
|
|
from app.ws.presence import Presence
|
|
|
|
# backend/app/main.py -> backend/ -> repo root -- matches both the local
|
|
# monorepo layout and the production layout (/srv/chatapp/backend,
|
|
# /srv/chatapp/frontend/dist), which is the same relative shape.
|
|
FRONTEND_DIST = pathlib.Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
|
|
|
|
|
class ImmutableStaticFiles(StaticFiles):
|
|
"""Vite fingerprints these filenames by content hash, so once served a
|
|
given path never changes -- safe to cache aggressively and skip
|
|
revalidation entirely, unlike index.html/sw.js below."""
|
|
|
|
async def get_response(self, path: str, scope) -> Response:
|
|
response = await super().get_response(path, scope)
|
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
return response
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
# redis/presence/broadcaster are constructed in create_app(), not here --
|
|
# Redis.from_url() is synchronous/lazy (no connection opens until the
|
|
# first command), so app.state.presence/broadcaster are always present
|
|
# even for callers that never trigger the ASGI lifespan (e.g. httpx's
|
|
# ASGITransport, used by the plain REST test fixtures -- only
|
|
# TestClient's websocket_connect-based tests actually run lifespan).
|
|
# The background listener task genuinely needs a running event loop
|
|
# though, so that part stays here.
|
|
listener_task = asyncio.create_task(app.state.broadcaster.listen())
|
|
|
|
yield
|
|
|
|
listener_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await listener_task
|
|
await app.state.redis.aclose()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(title="KeepItTalking", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=settings.session_secret,
|
|
same_site="lax",
|
|
https_only=settings.session_https_only,
|
|
max_age=settings.session_max_age_seconds,
|
|
)
|
|
|
|
app.state.connection_manager = ConnectionManager()
|
|
app.state.redis = Redis.from_url(settings.redis_url, decode_responses=True)
|
|
app.state.presence = Presence(app.state.redis)
|
|
app.state.broadcaster = RoomBroadcaster(app.state.redis, app.state.connection_manager)
|
|
|
|
app.include_router(health.router)
|
|
app.include_router(auth.router)
|
|
app.include_router(rooms.router)
|
|
app.include_router(invites.router)
|
|
app.include_router(push.router)
|
|
app.include_router(admin.router)
|
|
app.include_router(bots.router)
|
|
app.include_router(webhooks.router)
|
|
app.include_router(ws_router)
|
|
|
|
# Serves the built frontend so a single gunicorn port is enough for a
|
|
# reverse proxy (e.g. Nginx Proxy Manager) to forward the whole domain
|
|
# to -- no separate static-file host or per-path proxy routing needed
|
|
# in front of it. Conditional on frontend/dist existing so local dev
|
|
# (Vite's own dev server handles the frontend; frontend/dist is never
|
|
# built there) is unaffected.
|
|
if FRONTEND_DIST.is_dir():
|
|
app.mount(
|
|
"/assets", ImmutableStaticFiles(directory=FRONTEND_DIST / "assets"), name="frontend-assets"
|
|
)
|
|
|
|
# Must always be revalidated -- caching any of these is exactly how
|
|
# a client ends up stuck on a stale app version after a deploy.
|
|
# Vite's hashed /assets/ files (ImmutableStaticFiles above) are the
|
|
# opposite case on purpose: their filename changes when their
|
|
# content does, so there's nothing to revalidate.
|
|
NO_CACHE_FILES = {"index.html", "sw.js", "registerSW.js", "manifest.webmanifest"}
|
|
|
|
@app.get("/{full_path:path}", include_in_schema=False)
|
|
async def serve_frontend(full_path: str) -> FileResponse:
|
|
if full_path.startswith("api/") or full_path.startswith("ws/"):
|
|
raise HTTPException(status_code=404)
|
|
|
|
# .resolve() + is_relative_to() guards against path traversal
|
|
# (e.g. full_path="../../etc/passwd") -- full_path comes
|
|
# straight from the URL, and plain Path./ doesn't stop ".."
|
|
# segments from escaping FRONTEND_DIST on its own.
|
|
candidate = (FRONTEND_DIST / full_path).resolve()
|
|
headers = {"Cache-Control": "no-cache"} if full_path in NO_CACHE_FILES else None
|
|
if full_path and candidate.is_relative_to(FRONTEND_DIST) and candidate.is_file():
|
|
return FileResponse(candidate, headers=headers)
|
|
|
|
# Anything else is a client-side route (e.g. /rooms/<id>) --
|
|
# fall back to the SPA shell, same as Nginx's `try_files $uri
|
|
# /index.html` would have done.
|
|
return FileResponse(FRONTEND_DIST / "index.html", headers={"Cache-Control": "no-cache"})
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|