feat(session): per-owner printer/config storage via cookie-scoped bearer key

Printers and groups (Client) are now scoped to an Owner identified by an opaque
bearer key (secrets.token_urlsafe(32)) stored in an httponly cookie, defaulting
to temporary. First-visit modal offers backup-key download (marks permanent) or
temporary-only choice. /session/restore re-attaches a fresh browser to a saved
key. Every printer-facing route enforces ownership (404 on mismatch, not just
filtering) since printer IDs are sequential ints. Drivers stay global/shared.

On upgrade, pre-existing printer/client rows backfill to a synthetic legacy Owner;
its key is written to {DATA_DIR}/legacy_owner_key.txt for manual restore.

SECURITY: Added Origin/Referer same-origin check on POST /session/restore to
block login-CSRF/session-fixation attacks (cross-site form POST can't re-point
victim's cookie at attacker's Owner without hitting that check first).

Tests: 140 pass (2 deselected: pre-existing locale-flaky, unrelated to this change).
Verified live: modal on first visit, isolation between browsers, backup-key
download and restore flow work end-to-end.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:29:43 +02:00
co-authored by Claude Haiku 4.5
parent 9e46fee312
commit ed41f7f520
27 changed files with 583 additions and 102 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
def _render_client_list(request: Request) -> HTMLResponse:
"""Render the client list partial for HTMX swap."""
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="partials/client_list.html",
@@ -47,7 +47,7 @@ def create_client(request: Request, name: str = Form(...)) -> HTMLResponse:
return _error_response("Client name is required.")
try:
Client.create(name=name)
Client.create(name=name, owner=request.state.owner)
except IntegrityError:
return _error_response(f"Client '{name}' already exists.", status_code=409)
+6 -4
View File
@@ -5,7 +5,7 @@ import hashlib
import io
from pathlib import Path
from fastapi import APIRouter, UploadFile
from fastapi import APIRouter, Request, UploadFile
from fastapi.responses import HTMLResponse
from PIL import Image
@@ -18,7 +18,7 @@ MAX_ICON_BYTES = 750 * 1024 # 750 KB
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResponse:
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
Validation rules:
@@ -29,8 +29,10 @@ def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
Replaces any previously uploaded icon for this printer.
Returns an HTMX-friendly HTML fragment.
"""
# Check printer exists
printer = Printer.get_or_none(Printer.id == printer_id)
# Check printer exists and belongs to this owner
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
return HTMLResponse(
content="<p>Printer not found.</p>",
+9 -9
View File
@@ -6,11 +6,11 @@ import shutil
import tempfile
import zipfile
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse, Response
import imptune.config as cfg
from imptune.db.models import Icon, Printer
from imptune.db.models import Icon, Owner, Printer
from imptune.generators.intunewin_builder import build_intunewin
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
from imptune.storage.driver_store import DriverStore
@@ -18,9 +18,9 @@ from imptune.storage.driver_store import DriverStore
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Fetch printer and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none(Printer.id == printer_id)
def _get_printer_and_driver(printer_id: int, owner: Owner):
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
if printer is None:
return None, PlainTextResponse("Printer not found", status_code=404)
@@ -48,9 +48,9 @@ def _get_driver_zip_path(driver) -> str:
@router.get("/{printer_id}/packages/ninja")
def get_ninja_package(printer_id: int):
def get_ninja_package(request: Request, printer_id: int):
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
result, error = _get_printer_and_driver(printer_id)
result, error = _get_printer_and_driver(printer_id, request.state.owner)
if error is not None:
return error
@@ -96,9 +96,9 @@ def get_ninja_package(printer_id: int):
@router.get("/{printer_id}/packages/intunewin")
def get_intunewin_package(printer_id: int):
def get_intunewin_package(request: Request, printer_id: int):
"""Download a Microsoft Intune .intunewin deployment package."""
result, error = _get_printer_and_driver(printer_id)
result, error = _get_printer_and_driver(printer_id, request.state.owner)
if error is not None:
return error
+17 -10
View File
@@ -17,12 +17,16 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templa
def dashboard(request: Request):
from imptune.db.models import Printer
owner = request.state.owner
recent_printers = list(
Printer.select().order_by(Printer.created_at.desc()).limit(5)
Printer.select()
.where(Printer.owner == owner)
.order_by(Printer.created_at.desc())
.limit(5)
)
recent_packages = list(
Printer.select()
.where(Printer.driver.is_null(False))
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.order_by(Printer.created_at.desc())
.limit(5)
)
@@ -56,9 +60,11 @@ def drivers_page(request: Request):
def printers_page(request: Request):
from imptune.db.models import Client, Driver, Printer
owner = request.state.owner
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list] = defaultdict(list)
@@ -66,7 +72,7 @@ def printers_page(request: Request):
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = []
@@ -89,7 +95,7 @@ def printers_page(request: Request):
def printers_new_page(request: Request):
from imptune.db.models import Client, Driver
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
@@ -111,7 +117,7 @@ def printer_detail(request: Request, printer_id: int):
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where(Printer.id == printer_id)
.where((Printer.id == printer_id) & (Printer.owner == request.state.owner))
.first()
)
if printer is None:
@@ -148,7 +154,7 @@ def printer_detail(request: Request, printer_id: int):
def clients_page(request: Request):
from imptune.db.models import Client
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="clients.html",
@@ -160,7 +166,8 @@ def clients_page(request: Request):
def client_detail(request: Request, client_id: int):
from imptune.db.models import Client, Driver, Printer
client = Client.get_or_none(Client.id == client_id)
owner = request.state.owner
client = Client.get_or_none((Client.id == client_id) & (Client.owner == owner))
if client is None:
return HTMLResponse(
content="<h1>404 Not Found</h1><p>Client not found.</p>",
@@ -170,12 +177,12 @@ def client_detail(request: Request, client_id: int):
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.client == client_id)
.where((Printer.client == client_id) & (Printer.owner == owner))
.order_by(Printer.name)
)
grouped = {client.name: list(query)}
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
@@ -203,7 +210,7 @@ def packages_page(request: Request):
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where(Printer.driver.is_null(False))
.where((Printer.owner == request.state.owner) & Printer.driver.is_null(False))
.order_by(Printer.name)
)
return templates.TemplateResponse(
+23 -5
View File
@@ -33,9 +33,11 @@ def _render_printer_list(request: Request) -> HTMLResponse:
"""Query printers with LEFT JOIN on client and render grouped partial."""
import json
owner = request.state.owner
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list[Printer]] = defaultdict(list)
@@ -43,7 +45,7 @@ def _render_printer_list(request: Request) -> HTMLResponse:
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
@@ -94,8 +96,13 @@ def create_printer(
color_mode_bool = color_mode == "on"
collate_bool = collate == "on"
# Resolve optional FK IDs
owner = request.state.owner
# Resolve optional FK IDs — client must belong to this owner
client_fk = int(client_id) if client_id.strip() else None
if client_fk is not None:
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return _error_response(f"Client {client_fk} not found.", status_code=404)
driver_fk = int(driver_id) if driver_id.strip() else None
Printer.create(
@@ -106,6 +113,7 @@ def create_printer(
color_mode=color_mode_bool,
paper_size=paper_size,
collate=collate_bool,
owner=owner,
client=client_fk,
driver=driver_fk,
)
@@ -116,7 +124,11 @@ def create_printer(
@router.delete("/{printer_id}", response_class=HTMLResponse)
def delete_printer(request: Request, printer_id: int) -> HTMLResponse:
"""Delete a printer by ID. Returns updated printer list partial."""
deleted = Printer.delete().where(Printer.id == printer_id).execute()
deleted = (
Printer.delete()
.where((Printer.id == printer_id) & (Printer.owner == request.state.owner))
.execute()
)
if not deleted:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
@@ -140,7 +152,8 @@ def update_printer(
"""Update an existing printer configuration in-place."""
from datetime import UTC, datetime
printer = Printer.get_or_none(Printer.id == printer_id)
owner = request.state.owner
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
if printer is None:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
@@ -159,6 +172,11 @@ def update_printer(
if paper_size not in _VALID_PAPER:
return _error_response(f"Invalid paper size: {paper_size}.")
client_fk = int(client_id) if client_id.strip() else None
if client_fk is not None:
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return _error_response(f"Client {client_fk} not found.", status_code=404)
printer.name = name
printer.ip_address = ip_address
printer.port_name = port_name
@@ -166,7 +184,7 @@ def update_printer(
printer.color_mode = color_mode == "on"
printer.paper_size = paper_size
printer.collate = collate == "on"
printer.client = int(client_id) if client_id.strip() else None
printer.client = client_fk
printer.driver = int(driver_id) if driver_id.strip() else None
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
printer.save()
+23 -23
View File
@@ -1,18 +1,18 @@
"""Script download endpoints — generates and serves PowerShell scripts for a printer."""
import json
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse
from imptune.db.models import Printer
from imptune.db.models import Owner, Printer
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Fetch printer and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none(Printer.id == printer_id)
def _get_printer_and_driver(printer_id: int, owner: Owner):
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
if printer is None:
return None, PlainTextResponse("Printer not found", status_code=404)
@@ -35,8 +35,8 @@ def _get_printer_and_driver(printer_id: int):
return (printer, driver, driver_name), None
def _install_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
def _install_response(printer_id: int, owner: Owner):
result, error = _get_printer_and_driver(printer_id, owner)
if error is not None:
return error
printer, driver, driver_name = result
@@ -57,8 +57,8 @@ def _install_response(printer_id: int):
)
def _uninstall_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
def _uninstall_response(printer_id: int, owner: Owner):
result, error = _get_printer_and_driver(printer_id, owner)
if error is not None:
return error
printer, driver, driver_name = result
@@ -73,8 +73,8 @@ def _uninstall_response(printer_id: int):
)
def _detect_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
def _detect_response(printer_id: int, owner: Owner):
result, error = _get_printer_and_driver(printer_id, owner)
if error is not None:
return error
printer, driver, driver_name = result
@@ -86,36 +86,36 @@ def _detect_response(printer_id: int):
@router.get("/{printer_id}/scripts/install")
def get_install_script(printer_id: int):
def get_install_script(request: Request, printer_id: int):
"""Download the PowerShell install script for a printer."""
return _install_response(printer_id)
return _install_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/install.ps1")
def get_install_script_ps1(printer_id: int):
def get_install_script_ps1(request: Request, printer_id: int):
"""Download the PowerShell install script for a printer (.ps1 alias)."""
return _install_response(printer_id)
return _install_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/uninstall")
def get_uninstall_script(printer_id: int):
def get_uninstall_script(request: Request, printer_id: int):
"""Download the PowerShell uninstall script for a printer."""
return _uninstall_response(printer_id)
return _uninstall_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/uninstall.ps1")
def get_uninstall_script_ps1(printer_id: int):
def get_uninstall_script_ps1(request: Request, printer_id: int):
"""Download the PowerShell uninstall script for a printer (.ps1 alias)."""
return _uninstall_response(printer_id)
return _uninstall_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/detect")
def get_detect_script(printer_id: int):
def get_detect_script(request: Request, printer_id: int):
"""Download the PowerShell detection script for a printer."""
return _detect_response(printer_id)
return _detect_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/detect.ps1")
def get_detect_script_ps1(printer_id: int):
def get_detect_script_ps1(request: Request, printer_id: int):
"""Download the PowerShell detection script for a printer (.ps1 alias)."""
return _detect_response(printer_id)
return _detect_response(printer_id, request.state.owner)
+73
View File
@@ -0,0 +1,73 @@
"""Owner session routes — backup-key download and restore-on-new-browser."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, 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_MAX_AGE, COOKIE_NAME, is_same_origin
router = APIRouter(prefix="/session")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
@router.get("/key/download")
def download_key(request: Request) -> PlainTextResponse:
"""Mark the current owner permanent and hand back its key as a backup file."""
owner: Owner = request.state.owner
if not owner.is_permanent:
owner.is_permanent = True
owner.save()
return PlainTextResponse(
content=owner.key,
headers={"Content-Disposition": 'attachment; filename="imptune-backup-key.txt"'},
)
@router.get("/restore", response_class=HTMLResponse)
def restore_page(request: Request, error: str = "") -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": error},
)
@router.post("/restore")
def restore_session(request: Request, key: str = Form(...)):
"""Re-associate this browser with a previously downloaded backup key."""
if not is_same_origin(request):
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": "Request rejected — please submit this form directly from this site."},
status_code=403,
)
owner = Owner.get_or_none(Owner.key == key.strip())
if owner is None:
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": "Key not found."},
status_code=404,
)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
COOKIE_NAME,
owner.key,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=cfg.COOKIE_SECURE,
)
return response
+2
View File
@@ -7,6 +7,8 @@ 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"
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
+37 -2
View File
@@ -16,7 +16,7 @@ def init_db() -> None:
Closes any existing connection before re-initializing so that test
fixtures can monkeypatch DB_PATH between test runs.
"""
from imptune.db.models import Client, Driver, Printer, Icon
from imptune.db.models import Client, Driver, Owner, Printer, Icon
# Re-read DB_PATH each time so tests can patch imptune.config.DB_PATH
import imptune.config as cfg
@@ -33,4 +33,39 @@ def init_db() -> None:
},
)
db.connect(reuse_if_open=True)
db.create_tables([Client, Driver, Printer, Icon], safe=True)
db.create_tables([Owner, Client, Driver, Printer, Icon], safe=True)
_migrate_owner_column(cfg.DATA_DIR)
def _migrate_owner_column(data_dir: str) -> None:
"""Add owner_id to printer/client if missing, backfilling pre-existing rows.
Runs against a DB created before per-owner scoping existed. Idempotent:
a no-op once the column exists and no rows are left with a NULL owner_id.
"""
from pathlib import Path
from imptune.db.models import Client, Owner, Printer
from imptune.services.session import generate_key
for table in ("printer", "client"):
columns = {row[1] for row in db.execute_sql(f"PRAGMA table_info({table})")}
if "owner_id" not in columns:
db.execute_sql(
f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER REFERENCES owner (id)"
)
orphaned = Printer.select().where(Printer.owner.is_null()).count() or Client.select().where(
Client.owner.is_null()
).count()
if not orphaned:
return
legacy_owner = Owner.create(key=generate_key(), is_permanent=True)
Printer.update(owner=legacy_owner.id).where(Printer.owner.is_null()).execute()
Client.update(owner=legacy_owner.id).where(Client.owner.is_null()).execute()
key_path = Path(data_dir) / "legacy_owner_key.txt"
key_path.write_text(legacy_owner.key, encoding="utf-8")
print(f"[imptune] Pre-existing printers/clients migrated to a legacy owner. "
f"Restore key written to {key_path} — paste it into /session/restore to reclaim them.")
+17 -3
View File
@@ -24,14 +24,27 @@ class BaseModel(Model):
database = db
class Client(BaseModel):
"""Represents a deployment target (AD client / OU)."""
class Owner(BaseModel):
"""Cookie-scoped identity — no accounts, just an opaque bearer key."""
name = CharField(unique=True)
key = CharField(unique=True, index=True)
is_permanent = BooleanField(default=False)
created_at = DateTimeField(default=_utcnow)
class Meta:
table_name = "owner"
class Client(BaseModel):
"""Represents a deployment target (AD client / OU), scoped to an Owner."""
name = CharField()
owner = ForeignKeyField(Owner, backref="clients")
created_at = DateTimeField(default=_utcnow)
class Meta:
table_name = "client"
indexes = ((("owner", "name"), True),)
class Driver(BaseModel):
@@ -56,6 +69,7 @@ class Printer(BaseModel):
name = CharField()
ip_address = CharField()
port_name = CharField()
owner = ForeignKeyField(Owner, backref="printers")
client = ForeignKeyField(Client, null=True, backref="printers")
driver = ForeignKeyField(Driver, null=True, backref="printers")
duplex_mode = CharField(default="OneSided")
+4 -1
View File
@@ -5,9 +5,10 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts, session
from imptune.config import DATA_DIR, DRIVERS_DIR, ICONS_DIR
from imptune.db.database import db, init_db
from imptune.services.session import OwnerSessionMiddleware
@asynccontextmanager
@@ -23,6 +24,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="ImpTune", lifespan=lifespan)
app.add_middleware(OwnerSessionMiddleware)
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
_static_dir = Path(__file__).parent / "static"
@@ -37,3 +39,4 @@ app.include_router(clients.router)
app.include_router(scripts.router)
app.include_router(packages.router)
app.include_router(icons.router)
app.include_router(session.router)
+84
View File
@@ -0,0 +1,84 @@
"""Cookie-scoped Owner session — opaque bearer key, no accounts."""
from __future__ import annotations
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
import imptune.config as cfg
from imptune.db.models import Owner
COOKIE_NAME = "imptune_owner_key"
COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60 # 10 years
def generate_key() -> str:
return secrets.token_urlsafe(32)
def is_same_origin(request: Request) -> bool:
"""Origin/Referer check — the CSRF guard for /session/restore.
Restoring a key re-points the cookie at a *different* Owner, so unlike
the rest of the app's unprotected POSTs (which only ever mutate the
caller's own data), a forged cross-site POST here is a login-CSRF /
session-fixation vector: an attacker who knows their own key can force
a victim's browser onto the attacker's Owner. Browsers always send
Origin (and usually Referer) on form POSTs, same-site or not, so
requiring a match — and rejecting when both are absent — blocks a plain
auto-submitting HTML form without needing a token.
"""
expected = f"{request.url.scheme}://{request.url.netloc}"
origin = request.headers.get("origin")
if origin is not None:
return origin == expected
referer = request.headers.get("referer")
if referer:
from urllib.parse import urlparse
parsed = urlparse(referer)
return f"{parsed.scheme}://{parsed.netloc}" == expected
return False
class OwnerSessionMiddleware(BaseHTTPMiddleware):
"""Resolves request.state.owner from a cookie, creating one on first visit."""
async def dispatch(self, request: Request, call_next):
if request.url.path == "/health":
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
if owner is None:
owner = Owner.create(key=generate_key(), is_permanent=False)
request.state.owner = owner
request.state.is_new_owner = is_new
response = await call_next(request)
# Routes like /session/restore intentionally set this cookie themselves
# (to a *different* owner than the one this middleware just minted) —
# don't clobber that with the auto-provisioned one.
route_already_set_cookie = any(
header.lower() == b"set-cookie" and value.startswith(f"{COOKIE_NAME}=".encode())
for header, value in response.raw_headers
)
if is_new and not route_already_set_cookie:
response.set_cookie(
COOKIE_NAME,
owner.key,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=cfg.COOKIE_SECURE,
)
return response
+18
View File
@@ -315,6 +315,10 @@
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
</ul>
<ul class="sidebar-nav sidebar-nav-footer">
<li><a href="/session/key/download">Download backup key</a></li>
<li><a href="/session/restore" {% if request.url.path == "/session/restore" %}class="active"{% endif %}>Restore from backup key</a></li>
</ul>
</nav>
<div class="main-wrapper">
<header class="topbar">
@@ -333,6 +337,20 @@
</div>
</header>
<main class="main-content">
{% if request.state.is_new_owner %}
<dialog open id="session-choice-modal">
<article>
<h3>Keep your printers?</h3>
<p>Your printers, configs, and groups are private to this browser
(drivers stay shared with everyone). If you clear your cookies
without a backup key, they're gone for good.</p>
<footer>
<a role="button" href="/session/key/download">Store permanently (download backup key)</a>
<button class="secondary" onclick="this.closest('dialog').close()">Keep temporary, this browser only</button>
</footer>
</article>
</dialog>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block content %}
<h1>Restore backup key</h1>
<p>Paste the key from your <code>imptune-backup-key.txt</code> backup file to
recover your printers, configs, and groups on this browser.</p>
{% if error %}
<div class="error"><p>{{ error }}</p></div>
{% endif %}
<form method="post" action="/session/restore">
<label>
Backup key
<input type="text" name="key" placeholder="paste your key here" required autofocus>
</label>
<button type="submit">Restore</button>
</form>
{% endblock %}