11"""User-Agent template CRUD + JSON export/import.
22
3- A `UserAgentTemplate` is the client fingerprint a subscription fetch
4- presents: the `User-Agent` string plus any extra request headers the
5- panel gates on. The catalogue used to be two hardcoded dicts in
6- `api/subscriptions.py`; Alembic 018 seeds the table with exactly those
7- presets, and everything here exists so the operator can edit, extend and
8- move them between installs without a redeploy.
9-
10- Header-merge order, the reason Happ's X-* bundle is still generated in
11- code, and the validation ruleset all live in `app/core/ua_templates.py`.
12-
13- Endpoint summary
14- ----------------
15- * `GET /api/user-agents` — list, dropdown order, with usage counts
16- * `POST /api/user-agents` — create
17- * `GET /api/user-agents/export-json` — downloadable bundle
18- * `POST /api/user-agents/import-json` — restore a bundle (`?replace=`, `?overwrite=`)
19- * `GET /api/user-agents/{id}` — read one
20- * `PATCH /api/user-agents/{id}` — edit; a key rename re-points its subscriptions
21- * `DELETE /api/user-agents/{id}` — delete; 409 while subscriptions use it unless `?force=true`
3+ A template is the fingerprint a subscription fetch presents: the
4+ `User-Agent` string plus any extra headers the panel gates on. Header
5+ assembly and the validation ruleset live in `app/core/ua_templates.py`.
226"""
237import logging
248from datetime import datetime , timezone
4731EXPORT_KIND = "pitun-ua-templates-export"
4832EXPORT_VERSION = 1
4933
50- # Sanity ceiling on the table. The dropdown becomes unusable long before
51- # this, and it bounds an import bundle's blast radius.
34+ # Bounds the dropdown and an import bundle's blast radius.
5235MAX_TEMPLATES = 200
5336
5437
5740async def _usage_counts (session : AsyncSession ) -> Dict [str , int ]:
5841 """How many subscriptions reference each template key.
5942
60- One query for the whole table, counted in Python — the subscription
61- count is in the tens at most, and this avoids a GROUP BY that would
62- need a raw-SQL escape hatch under SQLModel's async session.
43+ Counted in Python: subscriptions number in the tens, and a GROUP BY
44+ would need a raw-SQL escape hatch under SQLModel's async session.
6345 """
6446 keys = (await session .exec (select (Subscription .ua ))).all ()
6547 counts : Dict [str , int ] = {}
@@ -91,14 +73,12 @@ async def _get_or_404(session: AsyncSession, tpl_id: int) -> UserAgentTemplate:
9173
9274
9375def _row_label (entry : Any ) -> str :
94- """Safe identifier for an import row, for use in logs and error text.
95-
96- Taken from the row's raw `key` because that is the only field present
97- on a row that failed validation. Sanitised before it goes anywhere:
98- control characters are stripped (CWE-117 log injection) and the value
99- is truncated, since a bundle is untrusted input. Only the key is ever
100- surfaced — never `user_agent` or `headers`, which can carry the
101- operator's panel credentials.
76+ """Safe identifier for an import row, for logs and error text.
77+
78+ The raw `key` is the only field present on a row that failed
79+ validation. Stripped of control characters (CWE-117) and truncated,
80+ since a bundle is untrusted. Never surfaces `user_agent` or
81+ `headers` — those can carry panel credentials.
10282 """
10383 if not isinstance (entry , dict ):
10484 return "?"
@@ -162,9 +142,7 @@ async def create_ua_template(
162142 return _to_read (row , 0 )
163143
164144
165- # Declared BEFORE `/{tpl_id}` so FastAPI matches the literal path instead
166- # of reading "export-json" as an int path param (same trick as
167- # routing_sets' /capacity).
145+ # Must precede `/{tpl_id}` or FastAPI reads "export-json" as an int param.
168146@router .get ("/export-json" )
169147async def export_ua_templates (session : AsyncSession = Depends (get_session )):
170148 """Return every template as a downloadable JSON bundle."""
@@ -182,8 +160,7 @@ async def export_ua_templates(session: AsyncSession = Depends(get_session)):
182160 "key" : r .key ,
183161 "name" : r .name ,
184162 "user_agent" : r .user_agent ,
185- # Sanitised, not raw: an exported bundle must be
186- # re-importable, and import re-validates every header.
163+ # Sanitised so the bundle survives import re-validation.
187164 "headers" : sanitize_headers (r .headers ),
188165 "description" : r .description ,
189166 "order" : r .order ,
@@ -222,18 +199,12 @@ async def import_ua_templates(
222199 ),
223200 session : AsyncSession = Depends (get_session ),
224201):
225- """Restore templates from a previously-exported bundle.
226-
227- Three behaviours, in increasing destructiveness:
228-
229- * default — additive; a key that already exists is **skipped**.
230- * `overwrite=true` — additive, but a matching key is **updated** in
231- place. Keeps its id, so subscriptions pointing at it are unaffected.
232- * `replace=true` — wipe the table first, then insert. The only way to
233- drop templates that aren't in the bundle.
202+ """Restore templates from an exported bundle.
234203
235- Rows are validated individually: one bad entry lands in `errors` and
236- the rest still import.
204+ Additive by default (existing key skipped); `overwrite` updates a
205+ match in place, keeping its id so subscriptions stay attached;
206+ `replace` wipes first. Rows validate individually — one bad entry
207+ lands in `errors` and the rest still import.
237208 """
238209 if not isinstance (payload , dict ):
239210 raise HTTPException (400 , "Invalid bundle: expected a JSON object at the top level" )
@@ -261,21 +232,18 @@ async def import_ua_templates(
261232 await session .delete (row )
262233 await session .flush ()
263234
264- # Track keys seen in this bundle so a bundle containing the same key
265- # twice reports a clean per-row error instead of tripping the UNIQUE
266- # constraint and rolling back the whole request.
235+ # A key twice in one bundle should be a per-row error, not a UNIQUE
236+ # violation that rolls back the whole request.
267237 seen_keys : set [str ] = set ()
268238
269239 for entry in entries :
270- # Label the row from its RAW key before validating, so a row that
271- # fails validation is still identifiable in `errors` — deriving it
272- # from the validated object would leave every rejected row as "?".
240+ # Label from the RAW key: deriving it from the validated object
241+ # would leave every rejected row as "?".
273242 row_label = _row_label (entry )
274243 try :
275244 if not isinstance (entry , dict ):
276245 raise ValueError ("entry is not an object" )
277- # Drop unknown fields (forward-compat: a newer export with
278- # extra columns still imports into an older PiTun).
246+ # Forward-compat: a newer export with extra columns still imports.
279247 allowed = set (UserAgentTemplateCreate .model_fields .keys ())
280248 clean = {k : v for k , v in entry .items () if k in allowed }
281249 validated = UserAgentTemplateCreate (** clean )
@@ -319,12 +287,9 @@ async def import_ua_templates(
319287 await session .flush ()
320288 imported += 1
321289 except Exception as exc : # noqa: BLE001 — surface per-row errors
322- # Log the exception *type* only, never the value: a bundle row
323- # can carry operator secrets (panel API keys live in the
324- # headers object) and a pydantic ValidationError message
325- # echoes the offending input verbatim. Same envelope as nodes'
326- # import-json (CWE-209/532/117). `row_label` is already
327- # sanitised by `_row_label`.
290+ # Type only, never the value: a ValidationError echoes its
291+ # input and a row's headers can hold a panel API key
292+ # (CWE-209/532/117).
328293 logger .warning (
329294 "UA template import row failed: key=%r err_type=%s" ,
330295 row_label , type (exc ).__name__ ,
@@ -354,12 +319,12 @@ async def update_ua_template(
354319 body : UserAgentTemplateUpdate ,
355320 session : AsyncSession = Depends (get_session ),
356321):
357- """Edit a template, including the built-in ones .
322+ """Edit a template, built-ins included .
358323
359- Renaming the `key` would orphan every subscription pointing at the
360- old value — those would silently fall back to the built-in UA map and
361- start presenting a different fingerprint. So we re-point them in the
362- same transaction instead .
324+ Renaming the `key` would orphan every subscription pointing at the old
325+ value — they would fall back to the built-in map and start presenting
326+ a different fingerprint — so they are re-pointed in the same
327+ transaction.
363328 """
364329 row = await _get_or_404 (session , tpl_id )
365330 patch = body .model_dump (exclude_unset = True )
0 commit comments