freemyid 0.2.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,834 @@
1
+ """Local web wizard + dashboard. FastAPI bound to 127.0.0.1 only.
2
+
3
+ Security model:
4
+ * random per-launch token in the URL, exchanged once for an HttpOnly SameSite=Strict cookie;
5
+ * Host header allow-list (defeats DNS rebinding), Origin check and per-session CSRF token
6
+ on every POST;
7
+ * strict CSP, no external assets of any kind, no analytics.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import logging
14
+ import os
15
+ import secrets as pysecrets
16
+ import socket
17
+ import sys
18
+ import threading
19
+ import webbrowser
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any
23
+ from urllib.parse import urlsplit
24
+
25
+ from fastapi import FastAPI, Request
26
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
27
+ from fastapi.staticfiles import StaticFiles
28
+
29
+ from .. import paths
30
+ from ..models import (
31
+ Actor,
32
+ Event,
33
+ RequestStatus,
34
+ TodoKind,
35
+ )
36
+ from . import dashboard as dash
37
+ from . import draft as dr
38
+ from . import ui
39
+
40
+ log = logging.getLogger(__name__)
41
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
42
+ COOKIE = "freemyid_session"
43
+ CSP = ("default-src 'none'; img-src 'self'; style-src 'self'; script-src 'self'; "
44
+ "connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
45
+ MSGS = {
46
+ "created": "All set. Free My ID is starting its first check now.",
47
+ "updated": "Changes saved.", "done": "Great, thank you. Marked as done.",
48
+ "resolved": "Thank you. Free My ID will carry on.",
49
+ "couldnt": "No problem. Free My ID will try again later or suggest another way.",
50
+ "opened": "Opened in your web browser. Come back here when you are finished.",
51
+ "openfail": "Could not open your browser. Try again.",
52
+ "sent": "Email sent.", "cancelled": "Email cancelled.",
53
+ "sendfail": "Could not send that email. Check the email settings and try again.",
54
+ "noengine": "Sending is not switched on in this copy of Free My ID yet.",
55
+ "attached": "Authorization attached.", "sched_on": "Automatic checking is on.",
56
+ "sched_off": "Automatic checking is off.", "sched_fail": "Could not change that setting.",
57
+ "saved": "Saved.", "real_on": "Real requests are on.", "real_off": "Practice mode is on.",
58
+ "fill_done": "That one is done. Thank you.",
59
+ "fill_busy": "Another form is already being filled in. Please finish that one first.",
60
+ "fill_none": ("The form was not sent, so nothing changed. Your to-do is still on the "
61
+ "list. You can try again or use \"Open the site myself\"."),
62
+ "no_practice": "Please let a practice check finish first, then look at the results.",
63
+ }
64
+
65
+
66
+ def _csrf_for(token: str) -> str:
67
+ return hmac.new(token.encode(), b"freemyid-csrf", hashlib.sha256).hexdigest()
68
+
69
+
70
+ def _load_registry():
71
+ try:
72
+ from ..registry import load_registry
73
+ return load_registry()
74
+ except Exception: # noqa: BLE001 dashboard still works without broker names
75
+ log.warning("registry unavailable; showing broker ids")
76
+ return None
77
+
78
+
79
+ def _consent_problem(p) -> str:
80
+ from ..cli import consent_ok
81
+ ok, why = consent_ok(p)
82
+ return "" if ok else why
83
+
84
+
85
+ def _background_disabled() -> bool:
86
+ """Never touch the system (scheduler, first run) under pytest or when told not to."""
87
+ return bool(os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("FREEMYID_NO_BACKGROUND"))
88
+
89
+
90
+ def _default_finish(app: FastAPI, registry_loader: Callable[[], Any], profile,
91
+ schedule_on: bool) -> None:
92
+ """After the wizard saves: install the scheduled job if asked, start the first run."""
93
+ if _background_disabled():
94
+ return
95
+ if schedule_on:
96
+ try:
97
+ from .. import scheduler
98
+ scheduler.install()
99
+ except Exception as e: # noqa: BLE001 never block finishing on this
100
+ log.warning("scheduler install failed: %s", type(e).__name__)
101
+ pid = profile.id
102
+
103
+ def job() -> None:
104
+ from .. import config as cfgmod
105
+ from ..storage import Store
106
+ app.state.running = True
107
+ try:
108
+ from ..engine.runner import run as engine_run
109
+ reg = registry_loader()
110
+ if reg is None:
111
+ return
112
+ st = Store()
113
+ prof = st.get_profile(pid)
114
+ if prof is None:
115
+ return
116
+ # Practice (dry) run until the user turns on real requests in Settings.
117
+ engine_run(st, reg, [prof], scan_only=False,
118
+ dry_run=not cfgmod.load().real_submissions_enabled, scheduled=False)
119
+ except ImportError:
120
+ log.info("engine not installed; skipping first run")
121
+ except Exception as e: # noqa: BLE001
122
+ log.warning("first run failed: %s", type(e).__name__)
123
+ finally:
124
+ app.state.running = False
125
+ threading.Thread(target=job, name="freemyid-first-run", daemon=True).start()
126
+
127
+
128
+ def _open_in_browser(url: str) -> bool:
129
+ return bool(webbrowser.open(url))
130
+
131
+
132
+ def create_app(store, token: str, allowed_hosts: set[str],
133
+ registry_loader: Callable[[], Any] = _load_registry, *,
134
+ open_url: Callable[[str], bool] = _open_in_browser,
135
+ on_finish: Callable[[Any, bool], None] | None = None) -> FastAPI:
136
+ """`open_url` opens a page in the user's default browser (server side, so it works from a
137
+ pywebview window too). `on_finish(profile, schedule_on)` runs after the wizard saves."""
138
+ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
139
+ app.state.draft = None
140
+ app.state.running = False
141
+ app.state.on_focus = None # desktop launcher: bring the window to the front
142
+ app.state.fill = {"running": False, "tid": None, "result": None}
143
+ fill_lock = threading.Lock()
144
+ app.state.on_quit = None # browser fallback: lets the user stop the program
145
+ csrf_token = _csrf_for(token)
146
+
147
+ def names() -> dict[str, str]:
148
+ reg = registry_loader()
149
+ return {b.id: b.name for b in reg.brokers} if reg else {}
150
+
151
+ def redirect(url: str, msg: str = "") -> RedirectResponse:
152
+ if msg:
153
+ url += ("&" if "?" in url else "?") + "msg=" + msg
154
+ return RedirectResponse(url, status_code=303)
155
+
156
+ def flash(request: Request) -> str:
157
+ return MSGS.get(request.query_params.get("msg", ""), "")
158
+
159
+ def get_draft(new: bool = False) -> dr.Draft:
160
+ if new or app.state.draft is None:
161
+ app.state.draft = dr.Draft()
162
+ app.state.pending_pin = None
163
+ return app.state.draft
164
+
165
+ @app.middleware("http")
166
+ async def guard(request: Request, call_next):
167
+ host = (request.headers.get("host") or "").lower()
168
+ if host not in allowed_hosts:
169
+ return Response("Forbidden", status_code=403)
170
+ authed = hmac.compare_digest(request.cookies.get(COOKIE, ""), token)
171
+ qt = request.query_params.get("t")
172
+ if not authed and qt and hmac.compare_digest(qt, token) and request.method == "GET":
173
+ r = RedirectResponse(request.url.path, status_code=303)
174
+ r.set_cookie(COOKIE, token, httponly=True, samesite="strict", path="/")
175
+ return _hardened(r)
176
+ if not authed:
177
+ return _hardened(html(ui.error_page(
178
+ "Please open Free My ID from its icon",
179
+ ["Please open Free My ID from its icon on your desktop."]), 403))
180
+ if request.method == "POST":
181
+ origin = request.headers.get("origin")
182
+ if origin and urlsplit(origin).netloc.lower() not in allowed_hosts:
183
+ return _hardened(html(ui.error_page(
184
+ "That didn't work",
185
+ ["Free My ID could not tell that this request came from its own window."]),
186
+ 403))
187
+ sent = request.headers.get("x-csrf")
188
+ if sent is None:
189
+ body = await request.body()
190
+
191
+ async def replay(body=body):
192
+ return {"type": "http.request", "body": body, "more_body": False}
193
+ request._receive = replay # let the route re-read the body
194
+ form = await request.form()
195
+ sent = str(form.get("csrf", ""))
196
+ if not hmac.compare_digest(sent, csrf_token):
197
+ return _hardened(html(ui.error_page(
198
+ "That page timed out",
199
+ ["That page timed out. Go back and try again."]), 403))
200
+ return _hardened(await call_next(request))
201
+
202
+ def _hardened(resp: Response) -> Response:
203
+ resp.headers["Content-Security-Policy"] = CSP
204
+ resp.headers["X-Content-Type-Options"] = "nosniff"
205
+ resp.headers["Referrer-Policy"] = "same-origin"
206
+ resp.headers["Cache-Control"] = "no-store"
207
+ resp.headers["X-Frame-Options"] = "DENY"
208
+ return resp
209
+
210
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
211
+
212
+ def html(s: str, status: int = 200) -> HTMLResponse:
213
+ return HTMLResponse(s, status_code=status)
214
+
215
+ # ---------------------------------------------------------------- dashboard
216
+ @app.get("/")
217
+ async def home(request: Request):
218
+ from .. import config as cfgmod
219
+ profiles = store.list_profiles()
220
+ if not profiles:
221
+ return redirect("/wizard/consent?new=1")
222
+ queued = store.list_requests(status=RequestStatus.QUEUED_FOR_APPROVAL)
223
+ nm = names()
224
+ who = {p.id: p.label for p in profiles}
225
+ people = [{"profile": p, "consent_problem": _consent_problem(p)} for p in profiles]
226
+ todos = [(t, who.get(t.profile_id, ""), nm.get(t.broker_id or "", ""))
227
+ for t in store.list_todos()]
228
+ return html(dash.home_page(
229
+ statuses=store.list_statuses(), todos=todos, people=people,
230
+ outbox_count=len(queued), csrf=csrf_token, flash=flash(request),
231
+ practice=not cfgmod.load().real_submissions_enabled,
232
+ running=bool(app.state.running), fill=True,
233
+ filling=bool(app.state.fill["running"])))
234
+
235
+ @app.get("/profile/{pid}")
236
+ async def profile_view(pid: str, request: Request):
237
+ p = store.get_profile(pid)
238
+ if not p:
239
+ return html(ui.layout("Not found", "<h1>Not found</h1>"), 404)
240
+ ev: dict[str, list] = {}
241
+ for e in store.list_evidence(pid):
242
+ if e.broker_id != "_consent":
243
+ ev.setdefault(e.broker_id, []).append(e)
244
+ return html(dash.profile_page(p, store.list_statuses(pid), names(), store.list_todos(pid),
245
+ ev, csrf_token, _consent_problem(p), flash(request),
246
+ fill=not app.state.fill["running"]))
247
+
248
+ @app.get("/evidence/{pid}/{eid}")
249
+ async def evidence(pid: str, eid: str):
250
+ for e in store.list_evidence(pid):
251
+ if e.id == eid:
252
+ try:
253
+ data = store.read_evidence(e) # decrypted in memory only
254
+ except Exception: # noqa: BLE001
255
+ return Response("Could not decrypt this item.", status_code=500)
256
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
257
+ hdr: dict[str, str]
258
+ mt, hdr = "image/png", {}
259
+ elif data[:3] == b"\xff\xd8\xff":
260
+ mt, hdr = "image/jpeg", {}
261
+ else:
262
+ mt = "application/octet-stream"
263
+ hdr = {"Content-Disposition": f'attachment; filename="evidence-{e.id[:8]}"'}
264
+ hdr["Content-Security-Policy"] = "sandbox; default-src 'none'"
265
+ return Response(data, media_type=mt, headers=hdr)
266
+ return Response("Not found", status_code=404)
267
+
268
+ def _todo(tid: str):
269
+ return next((t for t in store.list_todos() if t.id == tid), None)
270
+
271
+ def _back(t) -> str:
272
+ return "/"
273
+
274
+ @app.post("/todo/{tid}/done")
275
+ async def todo_done(tid: str):
276
+ t = _todo(tid)
277
+ if not t:
278
+ return redirect("/")
279
+ if t.kind in (TodoKind.ASSISTED_OPT_OUT, TodoKind.SEND_EMAIL_MANUALLY) and t.broker_id:
280
+ try: # the person did the opt-out themselves: broker moves to "submitted"
281
+ from ..engine.handoff import mark_manual_submitted
282
+ mark_manual_submitted(store, t.profile_id, t.broker_id, t.request_id)
283
+ except Exception as e: # noqa: BLE001 e.g. engine missing or odd state
284
+ log.info("mark_manual_submitted skipped: %s", type(e).__name__)
285
+ store.complete_todo(t.id)
286
+ store.log(Event(profile_id=t.profile_id, broker_id=t.broker_id, actor=Actor.USER,
287
+ action="todo_done", detail={"todo_kind": t.kind.value}))
288
+ return redirect(_back(t), "done")
289
+
290
+ # "Fill it in for me" (D9): interactive only, one at a time, in a background thread.
291
+ def _fill_ack_path():
292
+ from .. import paths
293
+ return paths.home() / ".fill-confirmed"
294
+
295
+ def _needs_fill_confirm() -> bool:
296
+ from .. import config as cfgmod
297
+ return not cfgmod.load().real_submissions_enabled and not _fill_ack_path().exists()
298
+
299
+ @app.get("/todo/{tid}/fill/confirm")
300
+ async def todo_fill_confirm(tid: str):
301
+ t = _todo(tid)
302
+ if not t or t.kind != TodoKind.ASSISTED_OPT_OUT or not t.broker_id:
303
+ return redirect("/")
304
+ return html(dash.fill_confirm_page(t.id, csrf_token))
305
+
306
+ @app.post("/todo/{tid}/fill")
307
+ async def todo_fill(tid: str, request: Request):
308
+ t = _todo(tid)
309
+ if not t or t.kind != TodoKind.ASSISTED_OPT_OUT or not t.broker_id:
310
+ return redirect("/")
311
+ if _needs_fill_confirm(): # SEC-38: one-time plain-language confirmation
312
+ form = await request.form()
313
+ if form.get("confirm") != "yes":
314
+ return redirect(f"/todo/{t.id}/fill/confirm")
315
+ try:
316
+ _fill_ack_path().write_text("1", encoding="utf-8")
317
+ except OSError:
318
+ pass
319
+ with fill_lock:
320
+ if app.state.fill["running"]:
321
+ return redirect("/", "fill_busy")
322
+ app.state.fill = {"running": True, "tid": t.id, "result": None}
323
+ pid, bid = t.profile_id, t.broker_id
324
+
325
+ def job() -> None:
326
+ res = None
327
+ try:
328
+ from ..engine import fill_for_me as ffm
329
+ from ..storage import Store
330
+ res = ffm.fill_for_me_sync(Store(), pid, bid)
331
+ except Exception as e: # noqa: BLE001 never leave the page waiting forever
332
+ log.warning("fill failed: %s", type(e).__name__)
333
+ finally:
334
+ app.state.fill = {"running": False, "tid": t.id, "result": res}
335
+ threading.Thread(target=job, name="freemyid-fill-ui", daemon=True).start()
336
+ return redirect("/fill")
337
+
338
+ @app.get("/fill")
339
+ async def fill_status():
340
+ f = app.state.fill
341
+ if f["tid"] is None:
342
+ return html(dash.fill_page("none", "", csrf_token))
343
+ if f["running"]:
344
+ return html(dash.fill_page("waiting", f["tid"], csrf_token))
345
+ res, tid = f["result"], f["tid"]
346
+ app.state.fill = {"running": False, "tid": None, "result": None}
347
+ if res is not None and getattr(res, "status", "") == "submitted":
348
+ return redirect("/", "fill_done")
349
+ if res is not None and getattr(res, "status", "") == "fallback" \
350
+ and getattr(res, "submit_seen", False):
351
+ return html(dash.fill_page("pressed", getattr(res, "todo_id", None) or tid,
352
+ csrf_token))
353
+ return redirect("/", "fill_none")
354
+
355
+ @app.post("/todo/{tid}/couldnt")
356
+ async def todo_couldnt(tid: str):
357
+ t = _todo(tid)
358
+ if t:
359
+ store.complete_todo(t.id)
360
+ store.log(Event(profile_id=t.profile_id, broker_id=t.broker_id, actor=Actor.USER,
361
+ action="todo_could_not", detail={"todo_kind": t.kind.value}))
362
+ return redirect("/", "couldnt")
363
+
364
+ @app.post("/todo/{tid}/open")
365
+ async def todo_open(tid: str):
366
+ t = _todo(tid)
367
+ url = dash.open_url_of(t) if t else None
368
+ if not url:
369
+ return redirect("/")
370
+ ok = False
371
+ try:
372
+ ok = open_url(url)
373
+ except Exception as e: # noqa: BLE001
374
+ log.warning("open browser failed: %s", type(e).__name__)
375
+ return redirect("/", "opened" if ok else "openfail")
376
+
377
+ @app.post("/todo/{tid}/compose/{provider}")
378
+ async def todo_compose(tid: str, provider: str):
379
+ t = _todo(tid)
380
+ if not t or t.kind != TodoKind.SEND_EMAIL_MANUALLY:
381
+ return redirect("/")
382
+ to, subject, letter = dash.email_parts(t)
383
+ url = dash.compose_url(provider, to, subject, letter)
384
+ if not url:
385
+ return redirect("/")
386
+ ok = False
387
+ try:
388
+ ok = open_url(url)
389
+ except Exception as e: # noqa: BLE001
390
+ log.warning("open browser failed: %s", type(e).__name__)
391
+ return redirect("/", "opened" if ok else "openfail")
392
+
393
+ @app.post("/todo/{tid}/resolve")
394
+ async def todo_resolve(tid: str):
395
+ t = _todo(tid)
396
+ if not t:
397
+ return redirect("/")
398
+ try: # let the engine resume the paused flow if it exposes a hook
399
+ from ..engine import handoff
400
+ hook = getattr(handoff, "mark_resolved", None)
401
+ if hook and t.resume_token:
402
+ hook(store, t)
403
+ except ImportError:
404
+ pass
405
+ store.complete_todo(t.id)
406
+ store.log(Event(profile_id=t.profile_id, broker_id=t.broker_id, actor=Actor.USER,
407
+ action="todo_resolved", detail={"todo_kind": t.kind.value}))
408
+ return redirect("/", "resolved")
409
+
410
+ @app.get("/outbox")
411
+ async def outbox(request: Request):
412
+ who = {p.id: p.label for p in store.list_profiles()}
413
+ nm = names()
414
+ try:
415
+ from ..engine.outbox import letter_for
416
+ except ImportError:
417
+ letter_for = None # type: ignore[assignment]
418
+ items = []
419
+ for r in store.list_requests(status=RequestStatus.QUEUED_FOR_APPROVAL):
420
+ try:
421
+ letter = (letter_for(store, r) if letter_for # type: ignore[truthy-function]
422
+ else ("", "", ""))
423
+ except Exception: # noqa: BLE001
424
+ letter = ("", "", "(the letter could not be read)")
425
+ items.append((r, who.get(r.profile_id, "?"), nm.get(r.broker_id, r.broker_id),
426
+ letter))
427
+ releases: dict[str, list[str]] = {}
428
+ try:
429
+ from ..engine.outbox import released_by_approval
430
+ profiles = {p.id: p for p in store.list_profiles()}
431
+ for r, *_ in items:
432
+ prof = profiles.get(r.profile_id)
433
+ others = released_by_approval(store, prof, r) if prof else []
434
+ if others:
435
+ releases[r.id] = [nm.get(o.broker_id, o.broker_id) for o in others]
436
+ except ImportError:
437
+ pass
438
+ return html(dash.outbox_page(items, csrf_token, flash(request), releases))
439
+
440
+ @app.post("/outbox/{rid}/approve")
441
+ async def outbox_approve(rid: str):
442
+ r = store.get_request(rid)
443
+ if not r or r.status != RequestStatus.QUEUED_FOR_APPROVAL:
444
+ return redirect("/outbox")
445
+ try:
446
+ from ..engine.outbox import approve_and_send
447
+ except ImportError:
448
+ return redirect("/outbox", "noengine")
449
+ try:
450
+ # SEC-48: approving can send a burst of emails (SEC-27 release); never block
451
+ # the event loop with SMTP. The store is thread-safe (RLock).
452
+ from starlette.concurrency import run_in_threadpool
453
+ await run_in_threadpool(approve_and_send, store, rid)
454
+ except Exception as e: # noqa: BLE001
455
+ log.warning("approve_and_send failed: %s", type(e).__name__)
456
+ return redirect("/outbox", "sendfail")
457
+ return redirect("/outbox", "sent")
458
+
459
+ @app.post("/outbox/{rid}/cancel")
460
+ async def outbox_cancel(rid: str):
461
+ r = store.get_request(rid)
462
+ if r and r.status == RequestStatus.QUEUED_FOR_APPROVAL:
463
+ store.save_request(r.model_copy(update={"status": RequestStatus.CANCELLED}))
464
+ store.log(Event(profile_id=r.profile_id, broker_id=r.broker_id, actor=Actor.USER,
465
+ action="email_cancelled"))
466
+ return redirect("/outbox", "cancelled")
467
+
468
+ @app.get("/settings")
469
+ async def settings(request: Request):
470
+ from .. import config as cfgmod
471
+ from .. import scheduler
472
+ try:
473
+ sched = scheduler.status()
474
+ except Exception: # noqa: BLE001
475
+ sched = "unknown"
476
+ return html(dash.settings_page(
477
+ people=store.list_profiles(), schedule_status=sched,
478
+ practice=not cfgmod.load().real_submissions_enabled, csrf=csrf_token,
479
+ flash=flash(request), can_quit=app.state.on_quit is not None))
480
+
481
+ @app.post("/_focus")
482
+ async def focus():
483
+ cb = app.state.on_focus
484
+ if cb:
485
+ try:
486
+ cb()
487
+ except Exception as e: # noqa: BLE001
488
+ log.info("focus failed: %s", type(e).__name__)
489
+ return Response("ok", media_type="text/plain")
490
+
491
+ @app.post("/quit")
492
+ async def quit_app():
493
+ cb = app.state.on_quit
494
+ if cb:
495
+ threading.Timer(0.3, cb).start()
496
+ return html(ui.layout("Closed", "<h1>Free My ID is closing.</h1><p>You can close this "
497
+ "tab.</p>", nav=False))
498
+ return redirect("/settings")
499
+
500
+ def _practice_done() -> bool:
501
+ return (any(st.last_scan_at for st in store.list_statuses())
502
+ or any(r.dry_run for r in store.list_requests()))
503
+
504
+ @app.get("/settings/submissions/confirm")
505
+ async def submissions_confirm(request: Request):
506
+ people = store.list_profiles()
507
+ if not _practice_done():
508
+ return redirect("/settings", "no_practice")
509
+ return html(dash.confirm_real_page(people, csrf_token))
510
+
511
+ @app.post("/settings/submissions/{mode}")
512
+ async def submissions(mode: str, request: Request):
513
+ from .. import config as cfgmod
514
+ if mode == "on":
515
+ form = await request.form()
516
+ if form.get("confirm") != "yes":
517
+ return redirect("/settings/submissions/confirm")
518
+ if not _practice_done():
519
+ return redirect("/settings", "no_practice")
520
+ if mode in ("on", "off"):
521
+ cfg = cfgmod.load()
522
+ cfgmod.save(cfg.model_copy(update={"real_submissions_enabled": mode == "on"}))
523
+ store.log(Event(actor=Actor.USER, action=f"real_submissions_{mode}"))
524
+ return redirect("/settings", "real_on" if mode == "on" else "real_off")
525
+
526
+ @app.post("/settings/{pid}/permissions")
527
+ async def set_permissions(pid: str, request: Request):
528
+ p = store.get_profile(pid)
529
+ if not p:
530
+ return redirect("/settings")
531
+ d = dr.draft_from_profile(p)
532
+ dr.apply_step(d, "permissions", await request.form())
533
+ dr.save_draft(store, d)
534
+ return redirect("/settings", "saved")
535
+
536
+ @app.get("/settings/{pid}/mailbox")
537
+ async def mailbox_get(pid: str):
538
+ p = store.get_profile(pid)
539
+ if not p:
540
+ return redirect("/settings")
541
+ d = dr.draft_from_profile(p)
542
+ # The pin lives on this draft only; replacing the draft drops any other pin/pending one.
543
+ app.state.draft = d
544
+ app.state.pending_pin = None
545
+ return html(dash.mailbox_settings_page(p, ui._mailbox_body(d), csrf_token, []))
546
+
547
+ @app.post("/settings/{pid}/mailbox")
548
+ async def mailbox_post(pid: str, request: Request):
549
+ p = store.get_profile(pid)
550
+ if not p:
551
+ return redirect("/settings")
552
+ d = dr.draft_from_profile(p)
553
+ cur = app.state.draft
554
+ if cur is not None and cur.id == p.id and cur.editing and cur.tls_pin:
555
+ d.tls_pin = cur.tls_pin # approved for this very profile in this session
556
+ errs = dr.apply_step(d, "mailbox", await request.form())
557
+ if not errs:
558
+ try:
559
+ dr.save_draft(store, d)
560
+ except dr.SaveError as e:
561
+ errs = [str(e)]
562
+ if errs:
563
+ return html(dash.mailbox_settings_page(p, ui._mailbox_body(d), csrf_token, errs), 400)
564
+ app.state.draft = None
565
+ app.state.pending_pin = None
566
+ return redirect("/settings", "saved")
567
+
568
+ @app.post("/schedule/{action}")
569
+ async def schedule(action: str):
570
+ from .. import scheduler
571
+ if action not in ("install", "remove"):
572
+ return redirect("/settings")
573
+ try:
574
+ getattr(scheduler, action)()
575
+ except Exception as e: # noqa: BLE001
576
+ log.warning("scheduler %s failed: %s", action, type(e).__name__)
577
+ return redirect("/settings", "sched_fail")
578
+ return redirect("/settings", "sched_on" if action == "install" else "sched_off")
579
+
580
+ @app.get("/profile/{pid}/report")
581
+ async def report(pid: str):
582
+ from .report import build_report
583
+ reg = registry_loader()
584
+ if not store.get_profile(pid) or reg is None:
585
+ return Response("Report unavailable.", status_code=404)
586
+ path = build_report(store, reg, pid, "html")
587
+ return Response(path.read_bytes(), media_type="text/html; charset=utf-8",
588
+ headers={"Content-Disposition": f'attachment; filename="{path.name}"'})
589
+
590
+ @app.post("/profile/{pid}/authorization")
591
+ async def attach_auth(pid: str, request: Request):
592
+ p = store.get_profile(pid)
593
+ form = await request.form()
594
+ up = form.get("authorization")
595
+ if not p or not hasattr(up, "read"):
596
+ return redirect("/")
597
+ data = await up.read() # type: ignore[union-attr]
598
+ if not data or len(data) > dr.MAX_AUTH_BYTES:
599
+ return redirect(f"/profile/{pid}")
600
+ doc = dr.save_authorization(store, pid, getattr(up, "filename", "") or "", data)
601
+ p = p.model_copy(update={"consent": p.consent.model_copy(
602
+ update={"authorization_doc": doc})})
603
+ store.save_profile(p)
604
+ store.log(Event(profile_id=pid, actor=Actor.USER, action="authorization_attached"))
605
+ return redirect(f"/profile/{pid}", "attached")
606
+
607
+ @app.get("/profile/{pid}/edit")
608
+ async def edit(pid: str):
609
+ p = store.get_profile(pid)
610
+ if not p:
611
+ return redirect("/")
612
+ app.state.draft = dr.draft_from_profile(p)
613
+ app.state.pending_pin = None
614
+ return redirect("/wizard/name")
615
+
616
+ # ---------------------------------------------------------------- wizard
617
+ @app.get("/wizard/authorization-form")
618
+ async def auth_form():
619
+ d = get_draft()
620
+ person = " ".join(x for x in (d.first, d.middle, d.last) if x)
621
+ return html(ui.authorization_form(person, d.attested_by))
622
+
623
+ @app.post("/wizard/test-connection")
624
+ async def test_conn(request: Request):
625
+ form = await request.form()
626
+ d = dr.Draft(editing=True)
627
+ d.password = app.state.draft.password if app.state.draft else ""
628
+ d.tls_pin = app.state.draft.tls_pin if app.state.draft else ""
629
+ # Test connection is where trust is established, so it must not demand a pin first.
630
+ errs = dr._apply_mailbox(d, form, require_trust=False)
631
+ if errs and not d.mailbox:
632
+ return JSONResponse({"ok": False, "status": "fail", "message": errs[0]})
633
+ mb = dict(d.mailbox or {})
634
+ if d.tls_pin:
635
+ mb["tls_cert_sha256"] = d.tls_pin
636
+ ok, msg, status = dr.check_mailbox(mb, d.password)
637
+ if status in ("needs_trust", "cert_changed"):
638
+ return JSONResponse(offer_trust(mb, status))
639
+ return JSONResponse({"ok": ok, "status": status, "message": msg})
640
+
641
+ def offer_trust(mb: dict, status: str) -> dict:
642
+ """Read (without any password) the local mail app's certificate and ask the user."""
643
+ from ..engine.mail_tls import TlsPinError, trust_on_first_use
644
+ from ..models import MailboxConfig
645
+ try:
646
+ from ..netpolicy import NetPolicy
647
+ res = trust_on_first_use(MailboxConfig(
648
+ **{k: v for k, v in mb.items() if k != "tls_cert_sha256"},
649
+ keyring_service="freemyid-mailbox"),
650
+ netpolicy=NetPolicy(None, allow_loopback=True))
651
+ except TlsPinError:
652
+ return {"ok": False, "status": "fail",
653
+ "message": dr.unreachable_message(mb.get("imap_host", ""))}
654
+ except ValueError:
655
+ return {"ok": False, "status": "fail",
656
+ "message": "Those mail settings are not complete yet."}
657
+ app.state.pending_pin = res.fingerprint
658
+ changed = status == "cert_changed"
659
+ if changed:
660
+ text = ("The security code of your mail app has changed since you last approved "
661
+ "it. This is normal if you just reinstalled or updated Proton Mail "
662
+ "Bridge. If you did not, say No.")
663
+ else:
664
+ text = ("Free My ID found your mail app on this computer. Please check that "
665
+ "the code below matches the one in Proton Mail Bridge.")
666
+ return {"ok": False, "status": "trust", "changed": changed, "message": text,
667
+ "display": res.display, "question": (
668
+ "Do you trust the new code?" if changed else "Do you trust this code?")}
669
+
670
+ @app.post("/wizard/trust-certificate")
671
+ async def trust_cert(request: Request):
672
+ form = await request.form()
673
+ pending = getattr(app.state, "pending_pin", None)
674
+ if form.get("answer") != "yes" or not pending:
675
+ app.state.pending_pin = None
676
+ return JSONResponse({"ok": False, "message": (
677
+ "Not trusted. Nothing was saved and no password was sent.")})
678
+ if app.state.draft is not None:
679
+ app.state.draft.tls_pin = pending
680
+ app.state.pending_pin = None
681
+ return JSONResponse({"ok": True, "message": "Saved. Testing again..."})
682
+
683
+ @app.get("/wizard/import")
684
+ async def import_form():
685
+ from .flows import find_family_bundle
686
+ found = find_family_bundle()
687
+ pick = ('<p>A setup file came with this program. Just type its passphrase.</p>'
688
+ if found else
689
+ '<label for="bundle">Setup file (.freemyid-profile)</label>'
690
+ '<input id="bundle" name="bundle" type="file" required>')
691
+ body = ('<h1>Use a family setup file</h1><form class="card" method="post" '
692
+ 'enctype="multipart/form-data" action="/wizard/import">'
693
+ f'{ui.csrf_field(csrf_token)}{pick}'
694
+ '<label for="passphrase">Password for the setup file</label>'
695
+ '<input id="passphrase" name="passphrase" type="password" required>'
696
+ '<p class="hint">You will review everything and give your own permission '
697
+ 'before anything is saved.</p><div class="actions"><button type="submit" '
698
+ 'class="big">Continue</button></div></form>')
699
+ return html(ui.layout("Import", body, nav=False))
700
+
701
+ @app.post("/wizard/import")
702
+ async def import_post(request: Request):
703
+ from .flows import find_family_bundle, load_bundle
704
+ form = await request.form()
705
+ up = form.get("bundle")
706
+ try:
707
+ found = find_family_bundle()
708
+ data = found.read_bytes() if found and not hasattr(up, "read") else await up.read() # type: ignore[union-attr]
709
+ app.state.draft = load_bundle(data, str(form.get("passphrase", "")))
710
+ app.state.pending_pin = None # SEC-56: no stale trust prompt for the new draft
711
+ except Exception: # noqa: BLE001
712
+ return html(ui.layout("Import", ui.errors_html(
713
+ ["That file or passphrase did not work."]) + '<a href="/wizard/import">Back</a>'),
714
+ 400)
715
+ return redirect("/wizard/consent")
716
+
717
+ @app.get("/wizard/{step}")
718
+ async def wizard_get(step: str, request: Request):
719
+ if step not in dr.STEPS:
720
+ return redirect("/")
721
+ d = get_draft(new=request.query_params.get("new") == "1" and step == "consent")
722
+ if step not in ui.visible_steps(d):
723
+ return redirect("/wizard/name")
724
+ if step != "consent" and not d.editing and not d.consent_ack:
725
+ return redirect("/wizard/consent")
726
+ return html(ui.step_page(step, d, csrf_token, []))
727
+
728
+ @app.post("/wizard/{step}")
729
+ async def wizard_post(step: str, request: Request):
730
+ if step not in dr.STEPS:
731
+ return redirect("/")
732
+ d = get_draft()
733
+ form = await request.form()
734
+ files = {}
735
+ up = form.get("authorization")
736
+ if hasattr(up, "read"):
737
+ files["authorization"] = (getattr(up, "filename", "") or "", await up.read()) # type: ignore[union-attr]
738
+ errs = dr.apply_step(d, step, form, files)
739
+ if errs:
740
+ return html(ui.step_page(step, d, csrf_token, errs), 400)
741
+ if step != "finish":
742
+ vs = ui.visible_steps(d)
743
+ return redirect(f"/wizard/{vs[vs.index(step) + 1]}")
744
+ try:
745
+ prof = dr.save_draft(store, d)
746
+ except dr.SaveError as e:
747
+ return html(ui.step_page("finish", d, csrf_token, [str(e)]), 400)
748
+ was_edit, sched_on = d.editing, d.weekly_recheck and d.schedule_consent
749
+ app.state.draft = None
750
+ app.state.pending_pin = None
751
+ if was_edit:
752
+ return redirect("/", "updated")
753
+ (on_finish or (lambda p, on: _default_finish(app, registry_loader, p, on)))(prof, sched_on)
754
+ return redirect("/", "created")
755
+
756
+ return app
757
+
758
+
759
+ def _bind(port: int) -> socket.socket:
760
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
761
+ if sys.platform == "win32":
762
+ # REUSEADDR on Windows would let another local process share the port.
763
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
764
+ else:
765
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
766
+ try:
767
+ s.bind(("127.0.0.1", port))
768
+ except OSError:
769
+ s.bind(("127.0.0.1", 0)) # configured port busy: pick a free one
770
+ s.listen(16)
771
+ return s
772
+
773
+
774
+ def serve(port: int, open_browser: bool = True) -> None:
775
+ """Run the wizard/dashboard on 127.0.0.1 until interrupted."""
776
+ import uvicorn
777
+
778
+ from ..storage import Store
779
+
780
+ sock = _bind(port)
781
+ real_port = sock.getsockname()[1]
782
+ token = pysecrets.token_urlsafe(32)
783
+ allowed = {f"127.0.0.1:{real_port}", f"localhost:{real_port}"}
784
+ app = create_app(Store(), token, allowed)
785
+ url = f"http://127.0.0.1:{real_port}/?t={token}"
786
+ print(f"Free My ID is running. If your browser does not open, visit:\n {url}\n"
787
+ "Press Ctrl+C to stop.")
788
+ if open_browser:
789
+ threading.Timer(0.8, lambda: webbrowser.open(url)).start()
790
+ paths.home()
791
+ server = uvicorn.Server(uvicorn.Config(app, log_level="warning", access_log=False))
792
+ server.run(sockets=[sock])
793
+
794
+
795
+ class Running:
796
+ """A server running on a background thread (used by the desktop launcher)."""
797
+
798
+ def __init__(self, app: FastAPI, server: Any, thread: threading.Thread, port: int,
799
+ token: str) -> None:
800
+ self.app, self.server, self.thread, self.port, self.token = (
801
+ app, server, thread, port, token)
802
+
803
+ @property
804
+ def url(self) -> str:
805
+ return f"http://127.0.0.1:{self.port}/?t={self.token}"
806
+
807
+ def stop(self) -> None:
808
+ self.server.should_exit = True
809
+ self.thread.join(timeout=5)
810
+
811
+
812
+ def start_background(port: int = 0, store=None, registry_loader=_load_registry) -> Running:
813
+ """Start the app on 127.0.0.1 (port 0 = any free port) without blocking."""
814
+ import time
815
+
816
+ import uvicorn
817
+
818
+ from ..storage import Store
819
+
820
+ sock = _bind(port)
821
+ real_port = sock.getsockname()[1]
822
+ token = pysecrets.token_urlsafe(32)
823
+ allowed = {f"127.0.0.1:{real_port}", f"localhost:{real_port}"}
824
+ paths.home()
825
+ app = create_app(store or Store(), token, allowed, registry_loader)
826
+ server = uvicorn.Server(uvicorn.Config(app, log_level="warning", access_log=False))
827
+ t = threading.Thread(target=lambda: server.run(sockets=[sock]), name="freemyid-server",
828
+ daemon=True)
829
+ t.start()
830
+ for _ in range(100):
831
+ if server.started:
832
+ break
833
+ time.sleep(0.05)
834
+ return Running(app, server, t, real_port, token)