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:
+16
-1
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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. #}
|
||||
|
||||
Reference in New Issue
Block a user