Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.
+diff --git a/CLAUDE.md b/CLAUDE.md index 917a57d..8bfd25e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ on narrow screens or in the add-printer sidebar (`.form-aside`). |-----|---------|---------| | `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) | | `PORT` | `8000` | Server port | -| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for plain-HTTP serving or the browser drops the cookie and a new Owner is created on every request. `false` also makes the cookie **memory-only** (no `Max-Age`) — see below. | +| `COOKIE_SECURE` | `true` | Three-way session mode, parsed by `config.parse_cookie_mode()` into `(COOKIE_SECURE, SINGLE_USER)`: `true` = Secure + 10-year cookie; `false` = plain-HTTP serving (browser drops a Secure cookie → new Owner per request), cookie becomes **memory-only** (no `Max-Age`); `single_user` (or `single-user`/`single`) = no cookie at all, one shared Owner — see below. | `COOKIE_SECURE=false` degrades the session instead of weakening the credential: `services/session.cookie_kwargs()` drops `max_age`, so the browser holds the @@ -125,4 +125,18 @@ cookie-setting call sites (the middleware and `POST /session/restore`) must go through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag, and `base.html` renders the `#ephemeral-session-warning` banner plus an extra paragraph in the onboarding modal off it. Changing this touches -`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`. \ No newline at end of file +`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`. + +`COOKIE_SECURE=single_user` (`cfg.SINGLE_USER`) removes sessions for test boxes +and single-person local prod: the middleware never reads or sets a cookie and +returns `services/session.single_user_owner()` — the **oldest** `Owner` row, +created on demand — so a deployment switched over from cookie mode keeps the +printers it already had and no second row is ever minted. `request.state.owner` +is still what every route filters on, so per-owner query code is unchanged. +`request.state.single_user` gates the sidebar "This session" menu and the +`#single-user-notice` banner in `base.html`; `is_new_owner`/`ephemeral_session` +are forced `False` (no onboarding modal, no memory-only warning). All three +`/session/*` routes 404 via `api/session._require_cookie_sessions()` — a key +can't re-point a cookie that isn't read, and downloading one would leak the +shared owner's bearer credential for a later switch back to cookie mode. Tests: +`test_session.py::test_single_user_*` + `test_parse_cookie_mode_*`. \ No newline at end of file diff --git a/README.md b/README.md index 15c53c5..8430a08 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Set these under `environment:` in `docker-compose.yml`. |------------------|---------|--------------------------------------------------| | `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. | | `PORT` | `8000` | Port the server listens on inside the container. | -| `COOKIE_SECURE` | `true` | `Secure` flag on the session cookie. Set to `false` when the app is reached over plain HTTP — see below. | +| `COOKIE_SECURE` | `true` | Session mode: `true` (Secure cookie), `false` (plain-HTTP cookie, memory-only) or `single_user` (no cookie, one shared store) — see below. | ### `COOKIE_SECURE` and HTTPS @@ -91,3 +91,34 @@ export DATA_DIR=/tmp/imptune_data export COOKIE_SECURE=false uvicorn imptune.main:app --reload --port 8000 ``` + +### `COOKIE_SECURE=single_user` — no sessions at all + +For a test box or a local deployment used by one person, sessions are pure +friction. `COOKIE_SECURE=single_user` (also accepted: `single-user`, `single`) +drops them: + +- No cookie is read or set. Every request — every browser, every device, curl — + resolves to **one shared owner**, so all printers, print defaults and clients + are simply "the server's". +- The onboarding modal, the memory-only warning, the "This session" sidebar menu + and both `/session/*` key routes disappear (the routes return `404`). There is + no backup key to lose, and none to hand out. +- Every page shows a banner stating that whoever reaches the app sees the same + data. + +**There is no isolation left in this mode**, so put it only where reaching the +app is already the permission — localhost, or a network you trust. Switching an +existing deployment over adopts the oldest existing owner, so printers saved +under a cookie stay visible; switching back re-enables cookie scoping and hands +new browsers a fresh empty session (that same data is then reachable only with +its key, which single-user mode never printed — download a backup key *before* +switching if you may switch back). + +```yaml +services: + imptune: + environment: + - DATA_DIR=/data + - COOKIE_SECURE=single_user +``` diff --git a/docker-compose.yml b/docker-compose.yml index 1a33875..8ccad74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,9 @@ services: # drops it and every request mints a new empty Owner. With `false` the # session works but lives only until the browser closes, and the UI warns. # - COOKIE_SECURE=false + # Or drop sessions entirely — no cookie, one shared store for everyone who + # can reach the app. Test boxes / single-person local prod only. + # - COOKIE_SECURE=single_user volumes: imptune_data: \ No newline at end of file diff --git a/imptune/api/session.py b/imptune/api/session.py index 39c7d0a..0336f82 100644 --- a/imptune/api/session.py +++ b/imptune/api/session.py @@ -3,10 +3,11 @@ from __future__ import annotations from pathlib import Path -from fastapi import APIRouter, Form, Request +from fastapi import APIRouter, Form, HTTPException, Request from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse from fastapi.templating import Jinja2Templates +import imptune.config as cfg from imptune.db.models import Owner from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin @@ -17,9 +18,21 @@ templates = Jinja2Templates( ) +def _require_cookie_sessions() -> None: + """404 these routes in single-user mode — keys have no meaning without a cookie. + + Restoring one could not re-point anything, and downloading one would hand + out the shared owner's bearer key, which turns into a live credential the + moment the deployment is switched back to a cookie-scoped mode. + """ + if cfg.SINGLE_USER: + raise HTTPException(status_code=404, detail="Session keys are disabled in single-user mode") + + @router.get("/key/download") def download_key(request: Request) -> PlainTextResponse: """Mark the current owner permanent and hand back its key as a backup file.""" + _require_cookie_sessions() owner: Owner = request.state.owner if not owner.is_permanent: owner.is_permanent = True @@ -33,6 +46,7 @@ def download_key(request: Request) -> PlainTextResponse: @router.get("/restore", response_class=HTMLResponse) def restore_page(request: Request, error: str = "") -> HTMLResponse: + _require_cookie_sessions() return templates.TemplateResponse( request=request, name="session_restore.html", @@ -43,6 +57,7 @@ def restore_page(request: Request, error: str = "") -> HTMLResponse: @router.post("/restore") def restore_session(request: Request, key: str = Form(...)): """Re-associate this browser with a previously downloaded backup key.""" + _require_cookie_sessions() if not is_same_origin(request): return templates.TemplateResponse( request=request, diff --git a/imptune/config.py b/imptune/config.py index dfac5d7..24713a5 100644 --- a/imptune/config.py +++ b/imptune/config.py @@ -7,8 +7,28 @@ load_dotenv() DATA_DIR = os.environ.get("DATA_DIR", "/data") PORT = int(os.environ.get("PORT", "8000")) -# Set to "false" for local plain-HTTP dev — browsers drop Secure cookies over HTTP. -COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false" + +_SINGLE_USER_VALUES = frozenset({"single_user", "single-user", "singleuser", "single"}) + + +def parse_cookie_mode(raw: str) -> tuple[bool, bool]: + """Read `COOKIE_SECURE` as a three-way mode → `(cookie_secure, single_user)`. + + - `true` (default): cookie-scoped Owners, `Secure` + persistent cookie. + - `false`: cookie-scoped Owners over plain HTTP, memory-only cookie — + browsers drop `Secure` cookies on HTTP, so the flag has to come off. + - `single_user`: no cookie at all. Every request shares one Owner, so a + test box or a local single-person deployment needs no session plumbing. + Anyone who can reach the app gets that data — there is no separation + left to enforce, which is the point. + """ + value = raw.strip().lower() + if value in _SINGLE_USER_VALUES: + return False, True + return value != "false", False + + +COOKIE_SECURE, SINGLE_USER = parse_cookie_mode(os.environ.get("COOKIE_SECURE", "true")) DB_PATH = str(Path(DATA_DIR) / "imptune.db") DRIVERS_DIR = str(Path(DATA_DIR) / "drivers") diff --git a/imptune/services/session.py b/imptune/services/session.py index 784c913..641c0c8 100644 --- a/imptune/services/session.py +++ b/imptune/services/session.py @@ -45,6 +45,21 @@ def cookie_kwargs() -> dict: return kwargs +def single_user_owner() -> Owner: + """The one Owner every request shares under `COOKIE_SECURE=single_user`. + + No cookie is read, so the owner has to be findable from the DB alone: the + oldest row wins. That way a deployment that used to run cookie-scoped keeps + the printers it already had instead of waking up empty, and repeat requests + never mint a second row. It is marked permanent so nothing treats it as a + throwaway first-visit session. + """ + owner = Owner.select().order_by(Owner.id).first() + if owner is None: + owner = Owner.create(key=generate_key(), is_permanent=True) + return owner + + def _request_hosts(request: Request) -> set[str]: """Every host spelling that legitimately identifies this deployment.""" hosts = {request.url.netloc} @@ -98,6 +113,16 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware): if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES): return await call_next(request) + if cfg.SINGLE_USER: + # No cookie is read and none is set: identity comes from the DB, so + # onboarding ("keep your printers?") and the ephemeral-cookie + # warning are both meaningless here. + request.state.owner = single_user_owner() + request.state.is_new_owner = False + request.state.ephemeral_session = False + request.state.single_user = True + return await call_next(request) + key = request.cookies.get(COOKIE_NAME) owner = Owner.get_or_none(Owner.key == key) if key else None is_new = owner is None @@ -106,6 +131,7 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware): request.state.owner = owner request.state.is_new_owner = is_new + request.state.single_user = False # Templates warn about it; see cookie_kwargs(). request.state.ephemeral_session = not cfg.COOKIE_SECURE diff --git a/imptune/static/app.css b/imptune/static/app.css index b7f807c..30a47a7 100644 --- a/imptune/static/app.css +++ b/imptune/static/app.css @@ -1203,16 +1203,20 @@ div.error > p { .error p, .notice p { margin: 0; } -/* Memory-only-session banner (COOKIE_SECURE=false): icon, message, and the - escape hatch on one line, stacking on narrow screens. */ -.notice.session-ephemeral { +/* Session-mode banners — memory-only (COOKIE_SECURE=false) and single-user + (COOKIE_SECURE=single_user): icon, message, and the escape hatch on one + line, stacking on narrow screens. */ +.notice.session-ephemeral, +.notice.session-single-user { display: flex; align-items: center; gap: .6rem; } -.notice.session-ephemeral p { flex: 1; min-width: 12rem; } -.notice.session-ephemeral .ico { flex: none; } +.notice.session-ephemeral p, +.notice.session-single-user p { flex: 1; min-width: 12rem; } +.notice.session-ephemeral .ico, +.notice.session-single-user .ico { flex: none; } .notice.session-ephemeral .btn { flex: none; @@ -1226,7 +1230,8 @@ div.error > p { } @media (max-width: 640px) { - .notice.session-ephemeral { flex-wrap: wrap; } + .notice.session-ephemeral, + .notice.session-single-user { flex-wrap: wrap; } } .notice details { margin: .4rem 0 0; } diff --git a/imptune/templates/base.html b/imptune/templates/base.html index f5b0793..b51aa2e 100644 --- a/imptune/templates/base.html +++ b/imptune/templates/base.html @@ -92,6 +92,7 @@ session_restore_key: 'Restaurer depuis une clé', session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.', ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.', + single_user_notice: 'Mode mono-utilisateur : tout est enregistré sur le serveur sans cookie de session, et toute personne qui accède à cette application voit les mêmes imprimantes.', open_menu: 'Ouvrir le menu', // Dashboard dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.', @@ -277,6 +278,7 @@ session_restore_key: 'Restore from a key', session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.', ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.', + single_user_notice: 'Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.', open_menu: 'Open menu', // Dashboard dashboard_intro: 'Three steps from driver ZIP to deployable package.', @@ -459,6 +461,8 @@ + {# Single-user mode has no cookie and no keys — the whole menu would 404. #} + {% if not request.state.single_user %}
+ {% endif %}Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.
+