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,401 @@
|
|
|
1
|
+
"""Dashboard page rendering (status, to-dos, outbox, evidence)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from ..models import (
|
|
8
|
+
BrokerStatus,
|
|
9
|
+
EvidenceKind,
|
|
10
|
+
ListingState,
|
|
11
|
+
Profile,
|
|
12
|
+
Request,
|
|
13
|
+
Todo,
|
|
14
|
+
TodoKind,
|
|
15
|
+
)
|
|
16
|
+
from .ui import _check, chip, csrf_field, esc, layout
|
|
17
|
+
|
|
18
|
+
SUMMARY_ORDER = [ListingState.NEEDS_HUMAN, ListingState.FOUND, ListingState.REAPPEARED,
|
|
19
|
+
ListingState.AWAITING_VERIFICATION, ListingState.SUBMITTED,
|
|
20
|
+
ListingState.CONFIRMED, ListingState.REMOVED]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _dt(d) -> str:
|
|
24
|
+
return d.strftime("%Y-%m-%d") if d else "-"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DONE_STATES = (ListingState.REMOVED, ListingState.CONFIRMED)
|
|
28
|
+
WAITING_STATES = (ListingState.SUBMITTED, ListingState.AWAITING_VERIFICATION,
|
|
29
|
+
ListingState.FOUND, ListingState.REAPPEARED)
|
|
30
|
+
COMPOSE = {
|
|
31
|
+
"gmail": ("Gmail", "https://mail.google.com/mail/?view=cm&fs=1&to={to}&su={su}&body={body}"),
|
|
32
|
+
"outlook": ("Outlook", ("https://outlook.live.com/mail/0/deeplink/compose?to={to}"
|
|
33
|
+
"&subject={su}&body={body}")),
|
|
34
|
+
"yahoo": ("Yahoo Mail", "https://compose.mail.yahoo.com/?to={to}&subject={su}&body={body}"),
|
|
35
|
+
}
|
|
36
|
+
CANT = "I couldn't" # escaped by _post_btn
|
|
37
|
+
_MAX_COMPOSE_BODY = 1800 # long URLs get cut by some mail sites; Copy is the fallback
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compose_url(provider: str, to: str, subject: str, body: str) -> str | None:
|
|
41
|
+
if provider not in COMPOSE:
|
|
42
|
+
return None
|
|
43
|
+
return COMPOSE[provider][1].format(to=quote(to, safe=""), su=quote(subject, safe=""),
|
|
44
|
+
body=quote(body[:_MAX_COMPOSE_BODY], safe=""))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def email_parts(t: Todo) -> tuple[str, str, str]:
|
|
48
|
+
"""(to, subject, letter) of a SEND_EMAIL_MANUALLY todo. Keys in copy_fields: To, Subject."""
|
|
49
|
+
low = {k.strip().lower(): v for k, v in t.copy_fields.items()}
|
|
50
|
+
return low.get("to", ""), low.get("subject", ""), t.body
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def open_url_of(t: Todo, index: int = 0) -> str | None:
|
|
54
|
+
urls = [u for u in t.links if u.startswith("https://")]
|
|
55
|
+
return urls[index] if 0 <= index < len(urls) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def status_sentence(statuses: list[BrokerStatus], n_todos: int, running: bool,
|
|
59
|
+
has_people: bool = True) -> tuple[str, bool]:
|
|
60
|
+
"""(sentence, all_good)."""
|
|
61
|
+
if running:
|
|
62
|
+
return "Free My ID is checking now. This page updates by itself.", True
|
|
63
|
+
done = sum(1 for s in statuses if s.state in DONE_STATES)
|
|
64
|
+
waiting = sum(1 for s in statuses if s.state in WAITING_STATES)
|
|
65
|
+
if not statuses and not n_todos:
|
|
66
|
+
return "Free My ID is set up. Nothing to do yet.", True
|
|
67
|
+
if done == 0 and not any(s.state in (ListingState.SUBMITTED, ListingState.AWAITING_VERIFICATION,
|
|
68
|
+
ListingState.REAPPEARED) for s in statuses):
|
|
69
|
+
return ("Free My ID has made a list of sites to work through. Start with the first one "
|
|
70
|
+
"below when you have a few minutes." if n_todos else
|
|
71
|
+
"Free My ID has made a list of sites to work through. Nothing needs you yet.",
|
|
72
|
+
n_todos == 0)
|
|
73
|
+
sites = f"{done} site{'s have' if done != 1 else ' has'} confirmed or no longer list{'s' if done == 1 else ''} you"
|
|
74
|
+
wait = f"{waiting} {'is' if waiting == 1 else 'are'} still waiting"
|
|
75
|
+
if n_todos == 0:
|
|
76
|
+
return f"Free My ID is working. {sites}, {wait}. Nothing needs you right now.", True
|
|
77
|
+
return f"Free My ID is working. {sites}, {wait}, {n_todos} need{'s' if n_todos == 1 else ''} you.", False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _steps_list(body: str) -> list[str]:
|
|
81
|
+
out = []
|
|
82
|
+
for ln in body.splitlines():
|
|
83
|
+
ln = re.sub(r"^\s*(\d+[.)]|[-*])\s*", "", ln).strip()
|
|
84
|
+
if ln:
|
|
85
|
+
out.append(ln)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _copy_rows(fields: dict[str, str]) -> str:
|
|
90
|
+
return "".join(
|
|
91
|
+
f'<div class="copyrow"><span class="lbl">{esc(k)}</span><span class="val">{esc(v)}</span>'
|
|
92
|
+
f'<button type="button" class="secondary" data-copy="{esc(v)}">Copy</button></div>'
|
|
93
|
+
for k, v in fields.items())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _post_btn(action: str, csrf: str, label: str, cls: str = "", extra: str = "") -> str:
|
|
97
|
+
return (f'<form method="post" action="{esc(action)}" class="inline">{csrf_field(csrf)}'
|
|
98
|
+
f'{extra}<button type="submit" class="{cls}">{esc(label)}</button></form>')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def todo_card(t: Todo, who: str, bname: str, csrf: str, show_who: bool = False,
|
|
102
|
+
fill: bool = False) -> str:
|
|
103
|
+
tid = esc(t.id)
|
|
104
|
+
head = (f'<h2>{esc(bname) or esc(t.title)}</h2>'
|
|
105
|
+
+ (f'<p class="hint">For {esc(who)}</p>' if show_who else ""))
|
|
106
|
+
done = f"/todo/{tid}/done"
|
|
107
|
+
couldnt = f"/todo/{tid}/couldnt"
|
|
108
|
+
if t.kind == TodoKind.ASSISTED_OPT_OUT:
|
|
109
|
+
steps = _steps_list(t.body)
|
|
110
|
+
ol = ("<ol>" + "".join(f"<li>{esc(x)}</li>" for x in steps) + "</ol>") if steps else ""
|
|
111
|
+
url = open_url_of(t)
|
|
112
|
+
primary = (_post_btn(f"/todo/{tid}/open", csrf, "Open the site", "primary")
|
|
113
|
+
if url else "")
|
|
114
|
+
if fill and t.broker_id:
|
|
115
|
+
primary = (_post_btn(f"/todo/{tid}/fill", csrf, "Fill it in for me", "primary big")
|
|
116
|
+
+ (_post_btn(f"/todo/{tid}/open", csrf, "Open the site myself",
|
|
117
|
+
"secondary") if url else ""))
|
|
118
|
+
copies = _copy_rows(t.copy_fields)
|
|
119
|
+
if copies:
|
|
120
|
+
copies = ("<p><b>Copy and paste these when the site asks:</b></p>" + copies)
|
|
121
|
+
return (f'<div class="card todo" data-kind="assisted_opt_out">{head}'
|
|
122
|
+
f'<p>{esc(t.title)}</p>{ol}{copies}<div class="actions">{primary}'
|
|
123
|
+
f'{_post_btn(done, csrf, "I did it", "secondary")}'
|
|
124
|
+
f'{_post_btn(couldnt, csrf, CANT, "secondary")}</div></div>')
|
|
125
|
+
if t.kind == TodoKind.SEND_EMAIL_MANUALLY:
|
|
126
|
+
letter = email_parts(t)[2]
|
|
127
|
+
rest = dict(t.copy_fields)
|
|
128
|
+
buttons = "".join(
|
|
129
|
+
_post_btn(f"/todo/{tid}/compose/{k}", csrf, f"Open in {COMPOSE[k][0]}",
|
|
130
|
+
"primary" if k == "gmail" else "secondary") for k in COMPOSE)
|
|
131
|
+
return (f'<div class="card todo" data-kind="send_email_manually">{head}'
|
|
132
|
+
f'<p>{esc(t.title)}</p><p><b>This is the message. Send it from your own '
|
|
133
|
+
f'email.</b></p>'
|
|
134
|
+
f'<div class="letter" aria-label="Message preview">{esc(letter)}</div>'
|
|
135
|
+
f'<div class="actions"><button type="button" class="secondary" '
|
|
136
|
+
f'data-copy="{esc(letter)}">Copy the message</button></div>'
|
|
137
|
+
f'{_copy_rows(rest)}<div class="actions">{buttons}</div>'
|
|
138
|
+
f'<div class="actions">{_post_btn(done, csrf, "I sent it", "secondary")}'
|
|
139
|
+
f'{_post_btn(couldnt, csrf, CANT, "secondary")}</div></div>')
|
|
140
|
+
# everything else: one primary button
|
|
141
|
+
url = open_url_of(t)
|
|
142
|
+
opener = _post_btn(f"/todo/{tid}/open", csrf, "Open the page", "secondary") if url else ""
|
|
143
|
+
if t.resume_token:
|
|
144
|
+
primary = _post_btn(f"/todo/{tid}/resolve", csrf, "I finished it", "primary")
|
|
145
|
+
else:
|
|
146
|
+
primary = _post_btn(done, csrf, "Done", "primary")
|
|
147
|
+
return (f'<div class="card todo" data-kind="{esc(t.kind.value)}">{head}<p>{esc(t.title)}</p>'
|
|
148
|
+
f'<p class="hint">{esc(t.body)}</p><div class="actions">{opener}{primary}</div></div>')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def home_page(*, statuses: list[BrokerStatus], todos: list[tuple[Todo, str, str]],
|
|
152
|
+
people: list[dict], outbox_count: int, csrf: str, flash: str = "",
|
|
153
|
+
practice: bool = False, running: bool = False, fill: bool = False,
|
|
154
|
+
filling: bool = False) -> str:
|
|
155
|
+
"""todos = (todo, person label, broker name)."""
|
|
156
|
+
sentence, good = status_sentence(statuses, len(todos), running)
|
|
157
|
+
body = (f'{_flash(flash)}<p class="status-line{" ok" if good else ""}" role="status">'
|
|
158
|
+
f'{esc(sentence)}</p>')
|
|
159
|
+
if practice:
|
|
160
|
+
body += ('<div class="banner"><b>Practice mode.</b> Free My ID is looking but is not sending '
|
|
161
|
+
'anything yet. <a href="/settings">Change this in Settings</a> when you are '
|
|
162
|
+
'ready.</div>')
|
|
163
|
+
for r in people:
|
|
164
|
+
if r["consent_problem"]:
|
|
165
|
+
body += (f'<div class="note">Not running yet for {esc(r["profile"].label)}: '
|
|
166
|
+
f'{esc(r["consent_problem"])} <a href="/profile/{esc(r["profile"].id)}">'
|
|
167
|
+
'Fix it</a></div>')
|
|
168
|
+
if outbox_count:
|
|
169
|
+
body += (f'<div class="card todo"><h2>{outbox_count} email(s) waiting for your OK</h2>'
|
|
170
|
+
'<p>Look at each one, then press Approve. Nothing is sent until you do.</p><div class="actions">'
|
|
171
|
+
'<a class="btn primary" href="/outbox">Look at them</a></div></div>')
|
|
172
|
+
if filling:
|
|
173
|
+
body += ('<div class="banner">A form is being filled in. <a href="/fill">'
|
|
174
|
+
'Go to that window</a></div>')
|
|
175
|
+
multi = len({w for _, w, _ in todos}) > 1
|
|
176
|
+
for t, who, bname in todos:
|
|
177
|
+
body += todo_card(t, who, bname, csrf, multi, fill and not filling)
|
|
178
|
+
if not todos and not outbox_count and not running:
|
|
179
|
+
body += '<div class="card"><p>You have nothing to do right now. That is good.</p></div>'
|
|
180
|
+
body += '<h2>More</h2><div class="card">'
|
|
181
|
+
for r in people:
|
|
182
|
+
p = r["profile"]
|
|
183
|
+
body += (f'<p><a class="btn secondary" href="/profile/{esc(p.id)}">All the details for '
|
|
184
|
+
f'{esc(p.label)}</a></p>')
|
|
185
|
+
body += ('<p class="hint">A site that says it deleted you has done that for that one site only. '
|
|
186
|
+
'Details can reappear, which is why Free My ID keeps checking. Free My ID does '
|
|
187
|
+
'not change anything on Facebook or Google.</p></div>')
|
|
188
|
+
return layout("Home", body, refresh=6 if running else 0)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
FILL_WAIT = ("A window opened. Free My ID filled in the form. Tick the \"I'm not a robot\" box "
|
|
192
|
+
"and press Submit.")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def fill_page(state: str, tid: str, csrf: str, bname: str = "") -> str:
|
|
196
|
+
"""state: waiting | pressed (Submit seen but not confirmed) | none."""
|
|
197
|
+
who = f" for {esc(bname)}" if bname else ""
|
|
198
|
+
if state == "waiting":
|
|
199
|
+
return layout("Filling in the form", (
|
|
200
|
+
f'<div class="card"><h1>Your turn{who}</h1><p class="big">{esc(FILL_WAIT)}</p>'
|
|
201
|
+
'<p>This page will update by itself when you are done. You can take your time. '
|
|
202
|
+
'Free My ID never presses Submit for you.</p></div>'), refresh=3)
|
|
203
|
+
if state == "pressed":
|
|
204
|
+
return layout("Did it go through?", (
|
|
205
|
+
f'<div class="card"><h1>Did it go through{who}?</h1>'
|
|
206
|
+
'<p>Free My ID could not see a confirmation on the page. If you pressed Submit and the '
|
|
207
|
+
'site thanked you, tell us here. Otherwise your to-do stays on the list.</p>'
|
|
208
|
+
f'<div class="actions">{_post_btn(f"/todo/{esc(tid)}/done", csrf, "I did it", "primary")}'
|
|
209
|
+
'<a class="btn secondary" href="/">Not yet</a></div></div>'))
|
|
210
|
+
return layout("Fill it in", (
|
|
211
|
+
'<div class="card"><h1>Nothing is being filled in</h1><div class="actions">'
|
|
212
|
+
'<a class="btn primary" href="/">Back to home</a></div></div>'))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def fill_confirm_page(tid: str, csrf: str) -> str:
|
|
216
|
+
return layout("Before we fill it in", (
|
|
217
|
+
'<div class="card"><h1>This sends a real request to the site.</h1>'
|
|
218
|
+
'<p class="big">You are in practice mode, but this step is different. Free My ID will '
|
|
219
|
+
'open the site and fill in the form. When you press Submit on the site, your request '
|
|
220
|
+
'is real.</p><p>We only ask this once. Free My ID never presses Submit for you.</p>'
|
|
221
|
+
f'<form method="post" action="/todo/{esc(tid)}/fill" class="inline">{csrf_field(csrf)}'
|
|
222
|
+
'<input type="hidden" name="confirm" value="yes"><div class="actions">'
|
|
223
|
+
'<button type="submit" class="primary big">I understand, continue</button>'
|
|
224
|
+
'<a class="btn secondary" href="/">Not now</a></div></form></div>'))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _flash(msg: str) -> str:
|
|
228
|
+
return f'<div class="note" role="status">{esc(msg)}</div>' if msg else ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def profile_page(p: Profile, statuses: list[BrokerStatus], names: dict[str, str],
|
|
232
|
+
todos: list[Todo], evidence_by_broker: dict[str, list], csrf: str,
|
|
233
|
+
consent_problem: str, flash: str = "", fill: bool = False) -> str:
|
|
234
|
+
body = f'<h1>{esc(p.label)}</h1>{_flash(flash)}'
|
|
235
|
+
if consent_problem:
|
|
236
|
+
body += f'<div class="note">Not running yet: {esc(consent_problem)}</div>'
|
|
237
|
+
if "authorization" in consent_problem:
|
|
238
|
+
body += (f'<form class="card" method="post" action="/profile/{esc(p.id)}/authorization"'
|
|
239
|
+
f' enctype="multipart/form-data">{csrf_field(csrf)}'
|
|
240
|
+
'<label for="authorization">Attach the signed authorization (photo or PDF)'
|
|
241
|
+
'</label><input id="authorization" name="authorization" type="file" '
|
|
242
|
+
'required><div class="actions"><button type="submit">Attach</button></div>'
|
|
243
|
+
'</form>')
|
|
244
|
+
body += (f'<p><a class="btn secondary" href="/profile/{esc(p.id)}/edit">Edit details</a> '
|
|
245
|
+
f'<a class="btn secondary" href="/profile/{esc(p.id)}/report">Download report</a></p>')
|
|
246
|
+
# todos
|
|
247
|
+
body += "<h2>Needs you</h2><div>"
|
|
248
|
+
if not todos:
|
|
249
|
+
body += "<p>Nothing right now.</p>"
|
|
250
|
+
for t in todos:
|
|
251
|
+
body += todo_card(t, p.label, names.get(t.broker_id or "", ""), csrf, fill=fill)
|
|
252
|
+
body += "</div>"
|
|
253
|
+
# per-broker table
|
|
254
|
+
body += ('<div class="card"><h2>Sites</h2><table><tr><th>Site</th><th>State</th>'
|
|
255
|
+
'<th>Updated</th><th>Next check</th></tr>')
|
|
256
|
+
for st in sorted(statuses, key=lambda s: (names.get(s.broker_id, s.broker_id)).lower()):
|
|
257
|
+
extra = (f'<br><span class="hint">{esc(st.needs_human_reason)}</span>'
|
|
258
|
+
if st.needs_human_reason else "")
|
|
259
|
+
body += (f'<tr><td>{esc(names.get(st.broker_id, st.broker_id))}</td>'
|
|
260
|
+
f'<td>{chip(st.state)}{extra}</td><td>{_dt(st.updated_at)}</td>'
|
|
261
|
+
f'<td>{_dt(st.next_check_at)}</td></tr>')
|
|
262
|
+
if not statuses:
|
|
263
|
+
body += '<tr><td colspan="4">No sites checked yet.</td></tr>'
|
|
264
|
+
body += "</table></div>"
|
|
265
|
+
# evidence
|
|
266
|
+
body += '<div class="card"><h2>Receipts</h2><p class="hint">Pictures of what each site showed and replied. ' \
|
|
267
|
+
'Kept on this computer only, encrypted.</p>'
|
|
268
|
+
any_ev = False
|
|
269
|
+
for bid, evs in sorted(evidence_by_broker.items()):
|
|
270
|
+
shots = [e for e in evs if e.kind == EvidenceKind.SCREENSHOT]
|
|
271
|
+
others = [e for e in evs if e.kind != EvidenceKind.SCREENSHOT]
|
|
272
|
+
any_ev = True
|
|
273
|
+
body += f'<h3>{esc(names.get(bid, bid))}</h3><div class="gallery">'
|
|
274
|
+
for e in shots:
|
|
275
|
+
body += (f'<figure><a href="/evidence/{esc(p.id)}/{esc(e.id)}" target="_blank">'
|
|
276
|
+
f'<img loading="lazy" alt="{esc(e.caption or "screenshot")}" '
|
|
277
|
+
f'src="/evidence/{esc(p.id)}/{esc(e.id)}"></a>'
|
|
278
|
+
f'<figcaption class="hint">{_dt(e.created_at)} {esc(e.caption)}'
|
|
279
|
+
'</figcaption></figure>')
|
|
280
|
+
for e in others:
|
|
281
|
+
body += (f'<a class="btn secondary" href="/evidence/{esc(p.id)}/{esc(e.id)}">'
|
|
282
|
+
f'{esc(e.kind.value)} {_dt(e.created_at)}</a>')
|
|
283
|
+
body += "</div>"
|
|
284
|
+
if not any_ev:
|
|
285
|
+
body += "<p>No receipts yet.</p>"
|
|
286
|
+
body += "</div>"
|
|
287
|
+
return layout(p.label, body)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def outbox_page(items: list[tuple[Request, str, str, tuple[str, str, str]]], csrf: str, flash: str = "",
|
|
291
|
+
releases: dict[str, list[str]] | None = None) -> str:
|
|
292
|
+
"""`releases` maps a request id to the site names of the OTHER emails its approval would
|
|
293
|
+
also send (SEC-47, first-email release). Every value is escaped here."""
|
|
294
|
+
releases = releases or {}
|
|
295
|
+
body = f'<h1>Emails waiting for your approval</h1>{_flash(flash)}'
|
|
296
|
+
if not items:
|
|
297
|
+
body += "<p>Nothing is waiting.</p>"
|
|
298
|
+
for r, who, bname, (to, subj, text) in items:
|
|
299
|
+
others = releases.get(r.id) or []
|
|
300
|
+
n = len(others)
|
|
301
|
+
note = ""
|
|
302
|
+
ask = "Send this email now?"
|
|
303
|
+
if n:
|
|
304
|
+
s_ = "s" if n != 1 else ""
|
|
305
|
+
note = (f'<p class="note">This is the first email for {esc(who)}. Approving it also '
|
|
306
|
+
f'sends the {n} other email{s_} waiting (to: {esc(", ".join(sorted(set(others))))}). '
|
|
307
|
+
'After that, routine emails go out by themselves. Formal demands still wait '
|
|
308
|
+
'for you.</p>')
|
|
309
|
+
ask = f"Send this email and {n} other{s_} now?"
|
|
310
|
+
body += (f'<div class="card"><b>{esc(who)}</b> to <b>{esc(bname)}</b> '
|
|
311
|
+
f'<span class="chip">{esc(r.kind.value.replace("_", " "))}</span>'
|
|
312
|
+
f'<p><b>To:</b> {esc(to)}<br><b>Subject:</b> {esc(subj)}</p>'
|
|
313
|
+
f'<pre class="letter">{esc(text)}</pre>'
|
|
314
|
+
f'<p class="hint">Drafted {_dt(r.created_at)}. Approving sends this email from '
|
|
315
|
+
'the removal mailbox.</p>'
|
|
316
|
+
f'{note}'
|
|
317
|
+
f'<form method="post" action="/outbox/{esc(r.id)}/approve" class="actions">'
|
|
318
|
+
f'{csrf_field(csrf)}<button type="submit" data-confirm="{esc(ask)}">'
|
|
319
|
+
f'Approve and send</button>'
|
|
320
|
+
f'<button type="submit" class="secondary" '
|
|
321
|
+
f'formaction="/outbox/{esc(r.id)}/cancel">Cancel it</button></form></div>')
|
|
322
|
+
return layout("Emails", body)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def confirm_real_page(people: list[Profile], csrf: str) -> str:
|
|
326
|
+
names = ", ".join(esc(p.label) for p in people) or "nobody yet"
|
|
327
|
+
body = ('<h1>Start sending real requests?</h1>'
|
|
328
|
+
f'<div class="card"><p>This is for: <b>{names}</b>.</p>'
|
|
329
|
+
'<p>Free My ID will now send your requests to people-search sites by email and, where a '
|
|
330
|
+
'site allows it, fill in their forms. Each request names the person and only the '
|
|
331
|
+
'details the site already shows, plus the separate email address. Nothing is paid for.</p>'
|
|
332
|
+
'<p>Emails for people who chose "approve each" wait for your OK first. The first '
|
|
333
|
+
'email for every person always waits for your OK.</p>'
|
|
334
|
+
'<p>Some sites need steps by hand, and details can come back. You can switch '
|
|
335
|
+
'back to practice mode in Settings at any time. Requests already sent cannot be '
|
|
336
|
+
'taken back.</p>'
|
|
337
|
+
'<form method="post" action="/settings/submissions/on" class="actions">'
|
|
338
|
+
f'{csrf_field(csrf)}<input type="hidden" name="confirm" value="yes">'
|
|
339
|
+
'<button type="submit">Yes, start sending real requests</button>'
|
|
340
|
+
'<a class="btn secondary" href="/settings">No, stay in practice mode</a>'
|
|
341
|
+
'</form></div>')
|
|
342
|
+
return layout("Real requests", body)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def settings_page(*, people: list[Profile], schedule_status: str, practice: bool, csrf: str,
|
|
346
|
+
flash: str = "", can_quit: bool = False) -> str:
|
|
347
|
+
body = f'<h1>Settings</h1>{_flash(flash)}'
|
|
348
|
+
body += ('<div class="card"><h2>Run automatically</h2><p>Once a day Free My ID can check '
|
|
349
|
+
'quietly in the background. If the computer was off, it catches up when it is '
|
|
350
|
+
f'next on.</p><p><b>Right now:</b> {esc(schedule_status)}</p>'
|
|
351
|
+
f'<form method="post" action="/schedule/install" class="actions">{csrf_field(csrf)}'
|
|
352
|
+
'<button type="submit">Turn on</button>'
|
|
353
|
+
'<button type="submit" class="secondary" formaction="/schedule/remove">Turn off'
|
|
354
|
+
'</button></form></div>')
|
|
355
|
+
if practice:
|
|
356
|
+
body += ('<div class="card"><h2>Practice mode is on</h2><p>Free My ID looks for listings but '
|
|
357
|
+
'does not send any request. When you have looked at the results and are happy, '
|
|
358
|
+
f'turn on real requests.</p><form method="post" '
|
|
359
|
+
f'action="/settings/submissions/on" class="actions">{csrf_field(csrf)}'
|
|
360
|
+
'<a class="btn primary" href="/settings/submissions/confirm">'
|
|
361
|
+
'Start sending real requests</a></form></div>')
|
|
362
|
+
else:
|
|
363
|
+
body += ('<div class="card"><h2>Real requests are on</h2>'
|
|
364
|
+
f'<form method="post" action="/settings/submissions/off" class="actions">'
|
|
365
|
+
f'{csrf_field(csrf)}<button type="submit" class="secondary">Go back to '
|
|
366
|
+
'practice mode</button></form></div>')
|
|
367
|
+
for p in people:
|
|
368
|
+
mb = (f"{p.mailbox.address}" if p.mailbox else "Not set up (you send messages by hand)")
|
|
369
|
+
perms = p.permissions
|
|
370
|
+
body += (f'<div class="card"><h2>{esc(p.label)}</h2>'
|
|
371
|
+
f'<h3>Email for removals</h3><p>{esc(mb)}</p>'
|
|
372
|
+
f'<p><a class="btn secondary" href="/settings/{esc(p.id)}/mailbox">Change it'
|
|
373
|
+
'</a></p><h3>What Free My ID may do</h3>'
|
|
374
|
+
'<p class="hint">Everything here is off unless you tick it. If a website asks '
|
|
375
|
+
'for something you did not allow, Free My ID stops and asks you. It never pays. It will never ask for a Social Security number or bank or card details.</p>'
|
|
376
|
+
f'<form method="post" action="/settings/{esc(p.id)}/permissions">'
|
|
377
|
+
f'{csrf_field(csrf)}'
|
|
378
|
+
+ _check("allow_id_upload", "Free My ID may upload a copy of an ID if a website "
|
|
379
|
+
"requires it", perms.allow_id_upload)
|
|
380
|
+
+ _check("allow_account_creation", "Free My ID may create an account on a website "
|
|
381
|
+
"if required", perms.allow_account_creation)
|
|
382
|
+
+ _check("allow_phone_disclosure", "Free My ID may give a phone number if "
|
|
383
|
+
"required", perms.allow_phone_disclosure)
|
|
384
|
+
+ '<div class="actions"><button type="submit">Save</button></div></form></div>')
|
|
385
|
+
if not people:
|
|
386
|
+
body += "<p>No one has been added yet.</p>"
|
|
387
|
+
if can_quit:
|
|
388
|
+
body += (f'<div class="card"><h2>Close Free My ID</h2><form method="post" action="/quit" '
|
|
389
|
+
f'class="actions">{csrf_field(csrf)}<button type="submit" class="secondary" '
|
|
390
|
+
'data-confirm="Close Free My ID now?">Close Free My ID</button></form></div>')
|
|
391
|
+
return layout("Settings", body)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def mailbox_settings_page(p: Profile, body_html: str, csrf: str, errs: list[str]) -> str:
|
|
395
|
+
from .ui import errors_html
|
|
396
|
+
return layout("Email for removals", (
|
|
397
|
+
f'<h1>Email for removals: {esc(p.label)}</h1>{errors_html(errs)}'
|
|
398
|
+
f'<form method="post" action="/settings/{esc(p.id)}/mailbox" class="card" '
|
|
399
|
+
f'autocomplete="off">{csrf_field(csrf)}{body_html}<div class="actions">'
|
|
400
|
+
'<a class="btn secondary" href="/settings">Back</a><button type="submit" class="big">'
|
|
401
|
+
'Save</button></div></form>'))
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Desktop launcher: the app window users double-click (Start Menu / Desktop shortcut).
|
|
2
|
+
|
|
3
|
+
Starts the local server on a free 127.0.0.1 port in a background thread and shows it in a
|
|
4
|
+
native window (pywebview, which uses Edge WebView2 on Windows). If pywebview or its browser
|
|
5
|
+
engine is missing, falls back to the default web browser. The user never sees "localhost".
|
|
6
|
+
|
|
7
|
+
First launch with no profile lands in the wizard, otherwise the dashboard (the "/" route
|
|
8
|
+
already decides). A second launch finds the first through an instance file in FREEMYID_HOME,
|
|
9
|
+
asks it to come to the front, and exits.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import urllib.request
|
|
23
|
+
import webbrowser
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .. import paths
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
INSTANCE_FILE = "app-instance.json"
|
|
32
|
+
TITLE = "Free My ID"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _instance_path() -> Path:
|
|
36
|
+
return paths.home() / INSTANCE_FILE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _alive(port: int) -> bool:
|
|
40
|
+
try:
|
|
41
|
+
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
|
42
|
+
return True
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_instance() -> dict[str, Any] | None:
|
|
48
|
+
"""The running instance's {port, token}, or None (missing file or stale after a crash)."""
|
|
49
|
+
try:
|
|
50
|
+
info = json.loads(_instance_path().read_text())
|
|
51
|
+
port, token = int(info["port"]), str(info["token"])
|
|
52
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
53
|
+
return None
|
|
54
|
+
return {"port": port, "token": token} if _alive(port) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_instance(port: int, token: str) -> None:
|
|
58
|
+
p = _instance_path()
|
|
59
|
+
tmp = p.with_suffix(".tmp")
|
|
60
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
61
|
+
with os.fdopen(fd, "w") as f:
|
|
62
|
+
f.write(json.dumps({"port": port, "token": token}))
|
|
63
|
+
os.replace(tmp, p)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def clear_instance(port: int) -> None:
|
|
67
|
+
try:
|
|
68
|
+
if json.loads(_instance_path().read_text()).get("port") == port:
|
|
69
|
+
_instance_path().unlink()
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ask_running_instance_to_focus(info: dict[str, Any]) -> bool:
|
|
75
|
+
"""POST /_focus to the first instance (same token + CSRF scheme as the browser)."""
|
|
76
|
+
csrf = hmac.new(info["token"].encode(), b"freemyid-csrf", hashlib.sha256).hexdigest()
|
|
77
|
+
req = urllib.request.Request(
|
|
78
|
+
f"http://127.0.0.1:{info['port']}/_focus", data=b"", method="POST",
|
|
79
|
+
headers={"Cookie": f"freemyid_session={info['token']}", "X-CSRF": csrf})
|
|
80
|
+
try:
|
|
81
|
+
with urllib.request.urlopen(req, timeout=3) as r:
|
|
82
|
+
return r.status == 200
|
|
83
|
+
except OSError:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _wait_forever(stop: threading.Event) -> None:
|
|
88
|
+
try:
|
|
89
|
+
while not stop.wait(0.5):
|
|
90
|
+
pass
|
|
91
|
+
except KeyboardInterrupt:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def window_geometry(screen_w: int | None, screen_h: int | None, want_w: int = 1100,
|
|
96
|
+
want_h: int = 820, min_w: int = 760, min_h: int = 560
|
|
97
|
+
) -> tuple[int, int, int | None, int | None]:
|
|
98
|
+
"""(width, height, x, y) that fits the screen: at most 90% of it, centered.
|
|
99
|
+
|
|
100
|
+
x/y are None when the screen size is unknown (let the toolkit choose). Never larger than
|
|
101
|
+
the screen, even below the usual minimum size.
|
|
102
|
+
"""
|
|
103
|
+
if not screen_w or not screen_h or screen_w < 300 or screen_h < 300:
|
|
104
|
+
return want_w, want_h, None, None
|
|
105
|
+
w = min(want_w, int(screen_w * 0.9))
|
|
106
|
+
h = min(want_h, int(screen_h * 0.9))
|
|
107
|
+
return w, h, (screen_w - w) // 2, max(0, (screen_h - h) // 2 - 10)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _primary_screen_size(webview: Any) -> tuple[int | None, int | None]:
|
|
111
|
+
try:
|
|
112
|
+
scr = webview.screens[0]
|
|
113
|
+
return int(scr.width), int(scr.height)
|
|
114
|
+
except Exception as e: # noqa: BLE001 older pywebview or no screens
|
|
115
|
+
log.debug("webview.screens unavailable (%s)", type(e).__name__)
|
|
116
|
+
if sys.platform == "win32":
|
|
117
|
+
try:
|
|
118
|
+
import ctypes
|
|
119
|
+
u = ctypes.windll.user32 # type: ignore[attr-defined]
|
|
120
|
+
return int(u.GetSystemMetrics(0)), int(u.GetSystemMetrics(1))
|
|
121
|
+
except Exception as e: # noqa: BLE001
|
|
122
|
+
log.debug("GetSystemMetrics failed (%s)", type(e).__name__)
|
|
123
|
+
return None, None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _show_window(url: str, on_ready: Callable[[Any], None]) -> bool:
|
|
127
|
+
"""Open a pywebview window and block until it closes. False if pywebview is unusable."""
|
|
128
|
+
try:
|
|
129
|
+
import webview
|
|
130
|
+
except ImportError:
|
|
131
|
+
return False
|
|
132
|
+
try:
|
|
133
|
+
with contextlib.suppress(Exception):
|
|
134
|
+
webview.settings["ALLOW_DOWNLOADS"] = True
|
|
135
|
+
sw, sh = _primary_screen_size(webview)
|
|
136
|
+
w, h, x, y = window_geometry(sw, sh)
|
|
137
|
+
# Taskbar takes some height; 90% leaves room. Small screens get a smaller minimum.
|
|
138
|
+
mins = (min(760, w), min(560, h))
|
|
139
|
+
extra: dict[str, Any] = {} if x is None else {"x": x, "y": y}
|
|
140
|
+
win = webview.create_window(TITLE, url, width=w, height=h, min_size=mins, **extra)
|
|
141
|
+
on_ready(win)
|
|
142
|
+
webview.start()
|
|
143
|
+
return True
|
|
144
|
+
except Exception as e: # noqa: BLE001 no WebView2 / no GUI toolkit
|
|
145
|
+
log.warning("native window unavailable (%s); using the web browser", type(e).__name__)
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(window: bool = True) -> int:
|
|
150
|
+
"""Run the app. Returns a process exit code."""
|
|
151
|
+
from .server import start_background
|
|
152
|
+
|
|
153
|
+
paths.home()
|
|
154
|
+
if (info := read_instance()) is not None:
|
|
155
|
+
if not ask_running_instance_to_focus(info):
|
|
156
|
+
webbrowser.open(f"http://127.0.0.1:{info['port']}/?t={info['token']}")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
running = start_background(0)
|
|
160
|
+
write_instance(running.port, running.token)
|
|
161
|
+
stop = threading.Event()
|
|
162
|
+
try:
|
|
163
|
+
shown = False
|
|
164
|
+
if window and os.environ.get("FREEMYID_NO_WINDOW") != "1":
|
|
165
|
+
def on_ready(win: Any) -> None:
|
|
166
|
+
def focus() -> None:
|
|
167
|
+
for fn in ("restore", "show"):
|
|
168
|
+
with contextlib.suppress(Exception):
|
|
169
|
+
getattr(win, fn)()
|
|
170
|
+
with contextlib.suppress(Exception): # nudge to front
|
|
171
|
+
win.on_top = True
|
|
172
|
+
win.on_top = False
|
|
173
|
+
running.app.state.on_focus = focus
|
|
174
|
+
shown = _show_window(running.url, on_ready)
|
|
175
|
+
if not shown:
|
|
176
|
+
# Browser fallback: the program keeps running until "Close Free My ID" in Settings.
|
|
177
|
+
running.app.state.on_focus = lambda: webbrowser.open(running.url)
|
|
178
|
+
running.app.state.on_quit = stop.set
|
|
179
|
+
webbrowser.open(running.url)
|
|
180
|
+
_wait_forever(stop)
|
|
181
|
+
finally:
|
|
182
|
+
clear_instance(running.port)
|
|
183
|
+
running.stop()
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__": # python -m freemyid.wizard.desktop
|
|
188
|
+
sys.exit(main())
|