feat: session mode parsing, memory-only cookies, single-user mode

- COOKIE_SECURE env now accepts: 'true' (secure), 'false' (memory-only), 'single_user' (no cookie)
- config.parse_cookie_mode() returns (COOKIE_SECURE, SINGLE_USER) tuple for routing
- single_user_owner() returns oldest Owner for test/local deployments
- session cookie respects mode: Max-Age only in secure mode, dropped for memory-only
- base.html renders ephemeral-session and single-user banners per mode
- Tests: comprehensive coverage for all three modes with monkeypatch configs
- CLAUDE.md + README docs updated

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:21:18 +02:00
co-authored by Claude Haiku 4.5
parent a8bcf7cdeb
commit f70ba93e7a
9 changed files with 231 additions and 12 deletions
+16 -1
View File
@@ -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,
+22 -2
View File
@@ -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")
+26
View File
@@ -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
+11 -6
View File
@@ -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; }
+12
View File
@@ -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 @@
</ul>
</div>
{# Single-user mode has no cookie and no keys — the whole menu would 404. #}
{% if not request.state.single_user %}
<div class="sidebar-foot">
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
@@ -469,6 +473,7 @@
</div>
</details>
</div>
{% endif %}
</nav>
<div class="main-wrapper">
@@ -497,6 +502,13 @@
</header>
<main class="main-content">
{% if request.state.single_user %}
{# COOKIE_SECURE=single_user: one shared store, no cookie, no isolation. #}
<div class="notice session-single-user" id="single-user-notice">
{{ ico.i('key', 16) }}
<p x-data x-text="$store.i18n.t('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.</p>
</div>
{% endif %}
{% if request.state.ephemeral_session %}
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
dies with the browser window. Say it before work is lost. #}