Files
ImpTune/imptune/api/session.py
T
kawaandClaude Opus 5 2c06806814 feat: driver rename, driver icons, web image + driver search
Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both
global/shared like the `Driver` row they hang off, so a rename or an icon is
what every Owner sees. The rename/icon dialog keeps its forms as siblings
(nested forms are invalid HTML) and the icon routes return an `hx-swap-oob`
thumbnail refresh rather than re-rendering the table, which would tear the open
`<dialog>` out of the DOM.

Web image picker: `GET /web/images` renders a pickable grid for a printer or a
driver icon, with the search term prefilled from the entity name and editable.
Picking one downloads it server-side and normalizes it.

Driver download search: `GET /web/drivers` searches for a vendor-wide driver
(the term is rewritten into the vendor's real product name for 15 brands) or for
the exact model as typed. Links only — nothing is downloaded, and the fragment
says the results are unvetted.

Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()`
letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256
PNG is returned byte-identical, because icon storage is content-addressed and
re-encoding would move the file on every save.

`fetch_image()` makes the request from the server, so `assert_fetchable()`
refuses any URL resolving to a private, loopback, or link-local address, and
re-runs on every redirect. ImpTune sits on the same LAN as the printers it
configures; an unguarded fetcher would be a port scanner for anyone who can
reach the UI.

DuckDuckGo is scraped, not called through an API — no key needed, but fragile,
so both search functions swallow parse failures and return [] instead of 500ing
a page. `WEB_SEARCH=false` disables every outbound request and hides the
controls, for air-gapped installs.

Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router
instances, so a template global is declared once; `_add_missing_columns()` in
`database.py` adds new nullable columns to a pre-existing table, which
`create_tables(safe=True)` skips; `db_env` in test_db.py now closes its
connection on teardown, or the next test's ORM writes land in the previous
test's DB file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:44:47 +02:00

75 lines
2.6 KiB
Python

"""Owner session routes — backup-key download and restore-on-new-browser."""
from __future__ import annotations
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
import imptune.config as cfg
from imptune.db.models import Owner
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
from imptune.templating import templates
router = APIRouter(prefix="/session")
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
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:
_require_cookie_sessions()
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."""
_require_cookie_sessions()
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, **cookie_kwargs())
return response