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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- 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)
|