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
freemyid/storage/db.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Encrypted SQLite store. OWNER: orchestrator.
|
|
2
|
+
|
|
3
|
+
Layout: each table has clear-text index columns (ids, enums, timestamps: no PII) plus
|
|
4
|
+
`data BLOB`, which is the AES-GCM-encrypted JSON of the full pydantic model. AAD binds each
|
|
5
|
+
blob to (table, id), so rows can't be swapped between tables or ids.
|
|
6
|
+
|
|
7
|
+
Public API (stable; other modules use only this):
|
|
8
|
+
Store(path=None, cipher=None)
|
|
9
|
+
.save_profile(p) / .get_profile(id) / .list_profiles() / .delete_profile(id)
|
|
10
|
+
.get_status(pid, bid) / .save_status(st) / .list_statuses(pid=None)
|
|
11
|
+
.save_listing(l) / .list_listings(pid, bid=None)
|
|
12
|
+
.save_request(r) / .get_request(id) / .list_requests(pid=None, bid=None, status=None)
|
|
13
|
+
.save_evidence(e, plaintext: bytes) / .read_evidence(e) / .list_evidence(pid, bid=None)
|
|
14
|
+
.save_todo(t) / .list_todos(pid=None, open_only=True) / .complete_todo(id)
|
|
15
|
+
.log(event) (append-only) / .events(pid=None, bid=None, limit=500)
|
|
16
|
+
.transaction() context manager
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TypeVar
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
|
|
32
|
+
from .. import paths
|
|
33
|
+
from ..crypto import Cipher, KeyUnavailable
|
|
34
|
+
from ..logging_setup import register_profile
|
|
35
|
+
from ..models import (
|
|
36
|
+
BrokerStatus,
|
|
37
|
+
Event,
|
|
38
|
+
Evidence,
|
|
39
|
+
Listing,
|
|
40
|
+
Profile,
|
|
41
|
+
Request,
|
|
42
|
+
RequestStatus,
|
|
43
|
+
Todo,
|
|
44
|
+
utcnow,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
M = TypeVar("M", bound=BaseModel)
|
|
48
|
+
|
|
49
|
+
SCHEMA_VERSION = 1
|
|
50
|
+
_DDL = """
|
|
51
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
53
|
+
id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, data BLOB NOT NULL);
|
|
54
|
+
CREATE TABLE IF NOT EXISTS brokers ( -- snapshot of registry entries used in runs
|
|
55
|
+
id TEXT PRIMARY KEY, registry_version TEXT, data TEXT NOT NULL); -- public info, not PII
|
|
56
|
+
CREATE TABLE IF NOT EXISTS broker_status (
|
|
57
|
+
profile_id TEXT NOT NULL, broker_id TEXT NOT NULL, state TEXT NOT NULL,
|
|
58
|
+
updated_at TEXT NOT NULL, next_check_at TEXT, data BLOB NOT NULL,
|
|
59
|
+
PRIMARY KEY (profile_id, broker_id));
|
|
60
|
+
CREATE TABLE IF NOT EXISTS listings_found (
|
|
61
|
+
id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, broker_id TEXT NOT NULL,
|
|
62
|
+
first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, data BLOB NOT NULL);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
64
|
+
id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, broker_id TEXT NOT NULL, kind TEXT NOT NULL,
|
|
65
|
+
status TEXT NOT NULL, created_at TEXT NOT NULL, sent_at TEXT, data BLOB NOT NULL);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS evidence (
|
|
67
|
+
id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, broker_id TEXT NOT NULL, kind TEXT NOT NULL,
|
|
68
|
+
created_at TEXT NOT NULL, data BLOB NOT NULL);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
70
|
+
id TEXT PRIMARY KEY, profile_id TEXT NOT NULL, broker_id TEXT, kind TEXT NOT NULL,
|
|
71
|
+
created_at TEXT NOT NULL, done_at TEXT, data BLOB NOT NULL);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
73
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT UNIQUE NOT NULL, ts TEXT NOT NULL,
|
|
74
|
+
profile_id TEXT, broker_id TEXT, actor TEXT NOT NULL, action TEXT NOT NULL,
|
|
75
|
+
from_state TEXT, to_state TEXT, data BLOB NOT NULL);
|
|
76
|
+
CREATE TRIGGER IF NOT EXISTS events_no_update BEFORE UPDATE ON events
|
|
77
|
+
BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
|
|
78
|
+
DROP TRIGGER IF EXISTS events_no_delete;
|
|
79
|
+
CREATE TRIGGER events_no_delete BEFORE DELETE ON events
|
|
80
|
+
BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
|
|
81
|
+
CREATE TRIGGER IF NOT EXISTS events_no_replace BEFORE INSERT ON events
|
|
82
|
+
WHEN EXISTS (SELECT 1 FROM events WHERE id = NEW.id)
|
|
83
|
+
BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
|
|
84
|
+
CREATE INDEX IF NOT EXISTS ix_listings_pb ON listings_found(profile_id, broker_id);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS ix_requests_pb ON requests(profile_id, broker_id);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS ix_events_pb ON events(profile_id, broker_id);
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ts(d: datetime | None) -> str | None:
|
|
91
|
+
return d.isoformat() if d else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_HEX_ID = re.compile(r"^[0-9a-f]{32}$")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _confined(base: Path, rel: str) -> Path:
|
|
98
|
+
"""Resolve `rel` under `base`; refuse anything that escapes it (SEC-02)."""
|
|
99
|
+
root = base.resolve()
|
|
100
|
+
p = (root / rel).resolve()
|
|
101
|
+
if p == root or not p.is_relative_to(root):
|
|
102
|
+
raise ValueError("path escapes its data directory")
|
|
103
|
+
return p
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _rm_tree_files(d: Path) -> None:
|
|
107
|
+
"""Delete every file below `d` (no symlink following), then the empty dirs."""
|
|
108
|
+
if not d.exists() or d.is_symlink():
|
|
109
|
+
if d.is_symlink():
|
|
110
|
+
d.unlink()
|
|
111
|
+
return
|
|
112
|
+
for f in sorted(d.rglob("*"), key=lambda x: len(x.parts), reverse=True):
|
|
113
|
+
if f.is_symlink() or f.is_file():
|
|
114
|
+
f.unlink()
|
|
115
|
+
elif f.is_dir():
|
|
116
|
+
f.rmdir()
|
|
117
|
+
d.rmdir()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Store:
|
|
121
|
+
def __init__(self, path: Path | None = None, cipher: Cipher | None = None) -> None:
|
|
122
|
+
self.path = path or paths.db_path()
|
|
123
|
+
self._cipher = cipher or Cipher()
|
|
124
|
+
self._lock = threading.RLock()
|
|
125
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False, isolation_level=None)
|
|
126
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
127
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
128
|
+
self._conn.execute("PRAGMA secure_delete=ON") # SEC-03: zero freed pages
|
|
129
|
+
self._conn.execute("PRAGMA recursive_triggers=ON") # SEC-11: REPLACE fires delete trigger
|
|
130
|
+
self._conn.executescript(_DDL)
|
|
131
|
+
self._conn.execute(
|
|
132
|
+
"INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),)
|
|
133
|
+
)
|
|
134
|
+
self._check_key()
|
|
135
|
+
try:
|
|
136
|
+
self.path.chmod(0o600)
|
|
137
|
+
except OSError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def _check_key(self) -> None:
|
|
141
|
+
"""SEC-10: refuse to run with a key that doesn't match this database."""
|
|
142
|
+
row = self._conn.execute("SELECT v FROM meta WHERE k='key_check'").fetchone()
|
|
143
|
+
if row is None:
|
|
144
|
+
old = self._conn.execute("SELECT id, data FROM profiles LIMIT 1").fetchone()
|
|
145
|
+
if old is not None: # legacy DB without a check value: prove the key first
|
|
146
|
+
try:
|
|
147
|
+
self._dec("profiles", old[0], old[1], Profile)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self._conn.close()
|
|
150
|
+
raise KeyUnavailable("encryption key does not match this database") from e
|
|
151
|
+
self._conn.execute("INSERT OR IGNORE INTO meta VALUES ('key_check', ?)",
|
|
152
|
+
(self._cipher.key_check(),))
|
|
153
|
+
row = self._conn.execute("SELECT v FROM meta WHERE k='key_check'").fetchone()
|
|
154
|
+
if not self._cipher.verify_key_check(row[0]):
|
|
155
|
+
self._conn.close()
|
|
156
|
+
raise KeyUnavailable("encryption key does not match this database")
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ plumbing
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
self._conn.close()
|
|
161
|
+
|
|
162
|
+
@contextmanager
|
|
163
|
+
def transaction(self) -> Iterator[None]:
|
|
164
|
+
with self._lock:
|
|
165
|
+
self._conn.execute("BEGIN IMMEDIATE")
|
|
166
|
+
try:
|
|
167
|
+
yield
|
|
168
|
+
except BaseException:
|
|
169
|
+
self._conn.execute("ROLLBACK")
|
|
170
|
+
raise
|
|
171
|
+
self._conn.execute("COMMIT")
|
|
172
|
+
|
|
173
|
+
def _enc(self, table: str, key: str, model: BaseModel) -> bytes:
|
|
174
|
+
return self._cipher.encrypt(model.model_dump_json().encode(), f"{table}:{key}".encode())
|
|
175
|
+
|
|
176
|
+
def _dec(self, table: str, key: str, blob: bytes, cls: type[M]) -> M:
|
|
177
|
+
return cls.model_validate_json(self._cipher.decrypt(blob, f"{table}:{key}".encode()))
|
|
178
|
+
|
|
179
|
+
def _x(self, sql: str, args: tuple = ()) -> sqlite3.Cursor:
|
|
180
|
+
with self._lock:
|
|
181
|
+
return self._conn.execute(sql, args)
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ profiles
|
|
184
|
+
def save_profile(self, p: Profile) -> None:
|
|
185
|
+
p = p.model_copy(update={"updated_at": utcnow()})
|
|
186
|
+
register_profile(p)
|
|
187
|
+
self._x(
|
|
188
|
+
"INSERT INTO profiles VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
|
|
189
|
+
"updated_at=excluded.updated_at, data=excluded.data",
|
|
190
|
+
(p.id, _ts(p.created_at), _ts(p.updated_at), self._enc("profiles", p.id, p)),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def get_profile(self, pid: str) -> Profile | None:
|
|
194
|
+
r = self._x("SELECT data FROM profiles WHERE id=?", (pid,)).fetchone()
|
|
195
|
+
if not r:
|
|
196
|
+
return None
|
|
197
|
+
p = self._dec("profiles", pid, r[0], Profile)
|
|
198
|
+
register_profile(p)
|
|
199
|
+
return p
|
|
200
|
+
|
|
201
|
+
def list_profiles(self) -> list[Profile]:
|
|
202
|
+
rows = self._x("SELECT id, data FROM profiles ORDER BY created_at").fetchall()
|
|
203
|
+
out = [self._dec("profiles", i, d, Profile) for i, d in rows]
|
|
204
|
+
for p in out:
|
|
205
|
+
register_profile(p)
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
def delete_profile(self, pid: str) -> None:
|
|
209
|
+
"""Hard-delete a person's PII (SEC-03).
|
|
210
|
+
|
|
211
|
+
Removes DB rows, evidence files, letters/call scripts, the mailbox password in the OS
|
|
212
|
+
keychain, then checkpoints the WAL and VACUUMs so no ciphertext lingers. Events keep
|
|
213
|
+
only ids (no PII) for the audit trail.
|
|
214
|
+
"""
|
|
215
|
+
if not _HEX_ID.match(pid):
|
|
216
|
+
raise ValueError("invalid profile id")
|
|
217
|
+
prof = self.get_profile(pid)
|
|
218
|
+
ev_files = [e.relpath for e in self.list_evidence(pid)]
|
|
219
|
+
art_files = [t.artifact_relpath for t in self.list_todos(pid, open_only=False)
|
|
220
|
+
if t.artifact_relpath]
|
|
221
|
+
with self.transaction():
|
|
222
|
+
for t in ("listings_found", "requests", "evidence", "todos", "broker_status"):
|
|
223
|
+
self._conn.execute(f"DELETE FROM {t} WHERE profile_id=?", (pid,))
|
|
224
|
+
self._conn.execute("DELETE FROM profiles WHERE id=?", (pid,))
|
|
225
|
+
for base, rels in ((paths.evidence_dir(), ev_files), (paths.artifacts_dir(), art_files)):
|
|
226
|
+
for rel in rels:
|
|
227
|
+
try:
|
|
228
|
+
f = _confined(base, rel)
|
|
229
|
+
except ValueError:
|
|
230
|
+
continue
|
|
231
|
+
if f.is_file() or f.is_symlink():
|
|
232
|
+
f.unlink()
|
|
233
|
+
_rm_tree_files(_confined(base, pid))
|
|
234
|
+
if prof and prof.mailbox:
|
|
235
|
+
from ..secrets import delete_mailbox_password
|
|
236
|
+
delete_mailbox_password(pid, prof.mailbox)
|
|
237
|
+
with self._lock:
|
|
238
|
+
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
239
|
+
self._conn.execute("VACUUM")
|
|
240
|
+
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------ status
|
|
243
|
+
def get_status(self, pid: str, bid: str) -> BrokerStatus:
|
|
244
|
+
r = self._x(
|
|
245
|
+
"SELECT data FROM broker_status WHERE profile_id=? AND broker_id=?", (pid, bid)
|
|
246
|
+
).fetchone()
|
|
247
|
+
if not r:
|
|
248
|
+
return BrokerStatus(profile_id=pid, broker_id=bid)
|
|
249
|
+
return self._dec("broker_status", f"{pid}/{bid}", r[0], BrokerStatus)
|
|
250
|
+
|
|
251
|
+
def save_status(self, st: BrokerStatus) -> None:
|
|
252
|
+
k = f"{st.profile_id}/{st.broker_id}"
|
|
253
|
+
self._x(
|
|
254
|
+
"INSERT INTO broker_status VALUES (?,?,?,?,?,?) ON CONFLICT(profile_id, broker_id) "
|
|
255
|
+
"DO UPDATE SET state=excluded.state, updated_at=excluded.updated_at, "
|
|
256
|
+
"next_check_at=excluded.next_check_at, data=excluded.data",
|
|
257
|
+
(st.profile_id, st.broker_id, st.state.value, _ts(st.updated_at),
|
|
258
|
+
_ts(st.next_check_at), self._enc("broker_status", k, st)),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def list_statuses(self, pid: str | None = None) -> list[BrokerStatus]:
|
|
262
|
+
q = "SELECT profile_id, broker_id, data FROM broker_status"
|
|
263
|
+
a: tuple[str, ...] = ()
|
|
264
|
+
if pid:
|
|
265
|
+
q, a = q + " WHERE profile_id=?", (pid,)
|
|
266
|
+
return [self._dec("broker_status", f"{p}/{b}", d, BrokerStatus)
|
|
267
|
+
for p, b, d in self._x(q, a).fetchall()]
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------ listings
|
|
270
|
+
def save_listing(self, li: Listing) -> None:
|
|
271
|
+
self._x(
|
|
272
|
+
"INSERT INTO listings_found VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
|
|
273
|
+
"last_seen=excluded.last_seen, data=excluded.data",
|
|
274
|
+
(li.id, li.profile_id, li.broker_id, _ts(li.first_seen), _ts(li.last_seen),
|
|
275
|
+
self._enc("listings_found", li.id, li)),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def list_listings(self, pid: str, bid: str | None = None) -> list[Listing]:
|
|
279
|
+
q = "SELECT id, data FROM listings_found WHERE profile_id=?"
|
|
280
|
+
a: tuple[str, ...] = (pid,)
|
|
281
|
+
if bid:
|
|
282
|
+
q, a = q + " AND broker_id=?", (pid, bid)
|
|
283
|
+
return [self._dec("listings_found", i, d, Listing) for i, d in self._x(q, a).fetchall()]
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------ requests
|
|
286
|
+
def save_request(self, r: Request) -> None:
|
|
287
|
+
self._x(
|
|
288
|
+
"INSERT INTO requests VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
|
|
289
|
+
"status=excluded.status, sent_at=excluded.sent_at, data=excluded.data",
|
|
290
|
+
(r.id, r.profile_id, r.broker_id, r.kind.value, r.status.value, _ts(r.created_at),
|
|
291
|
+
_ts(r.sent_at), self._enc("requests", r.id, r)),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def get_request(self, rid: str) -> Request | None:
|
|
295
|
+
row = self._x("SELECT data FROM requests WHERE id=?", (rid,)).fetchone()
|
|
296
|
+
return self._dec("requests", rid, row[0], Request) if row else None
|
|
297
|
+
|
|
298
|
+
def list_requests(self, pid: str | None = None, bid: str | None = None,
|
|
299
|
+
status: RequestStatus | None = None) -> list[Request]:
|
|
300
|
+
q, a = "SELECT id, data FROM requests WHERE 1=1", []
|
|
301
|
+
for col, val in (("profile_id", pid), ("broker_id", bid),
|
|
302
|
+
("status", status.value if status else None)):
|
|
303
|
+
if val:
|
|
304
|
+
q += f" AND {col}=?"
|
|
305
|
+
a.append(val)
|
|
306
|
+
q += " ORDER BY created_at"
|
|
307
|
+
return [self._dec("requests", i, d, Request) for i, d in self._x(q, tuple(a)).fetchall()]
|
|
308
|
+
|
|
309
|
+
# ------------------------------------------------------------------ evidence
|
|
310
|
+
def save_evidence(self, e: Evidence, plaintext: bytes) -> Evidence:
|
|
311
|
+
"""Encrypts `plaintext` to FREEMYID_HOME/evidence/<relpath>; sets sha256."""
|
|
312
|
+
dest = _confined(paths.evidence_dir(), e.relpath)
|
|
313
|
+
dest.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
314
|
+
sha = self._cipher.encrypt_file(plaintext, dest)
|
|
315
|
+
e = e.model_copy(update={"sha256": sha})
|
|
316
|
+
self._x(
|
|
317
|
+
"INSERT INTO evidence VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
|
|
318
|
+
"data=excluded.data",
|
|
319
|
+
(e.id, e.profile_id, e.broker_id, e.kind.value, _ts(e.created_at),
|
|
320
|
+
self._enc("evidence", e.id, e)),
|
|
321
|
+
)
|
|
322
|
+
return e
|
|
323
|
+
|
|
324
|
+
def read_evidence(self, e: Evidence) -> bytes:
|
|
325
|
+
return self._cipher.decrypt_file(_confined(paths.evidence_dir(), e.relpath))
|
|
326
|
+
|
|
327
|
+
def list_evidence(self, pid: str, bid: str | None = None) -> list[Evidence]:
|
|
328
|
+
q = "SELECT id, data FROM evidence WHERE profile_id=?"
|
|
329
|
+
a: tuple[str, ...] = (pid,)
|
|
330
|
+
if bid:
|
|
331
|
+
q, a = q + " AND broker_id=?", (pid, bid)
|
|
332
|
+
q += " ORDER BY created_at"
|
|
333
|
+
return [self._dec("evidence", i, d, Evidence) for i, d in self._x(q, a).fetchall()]
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------ todos
|
|
336
|
+
def save_todo(self, t: Todo) -> None:
|
|
337
|
+
self._x(
|
|
338
|
+
"INSERT INTO todos VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
|
|
339
|
+
"done_at=excluded.done_at, data=excluded.data",
|
|
340
|
+
(t.id, t.profile_id, t.broker_id, t.kind.value, _ts(t.created_at), _ts(t.done_at),
|
|
341
|
+
self._enc("todos", t.id, t)),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def list_todos(self, pid: str | None = None, open_only: bool = True) -> list[Todo]:
|
|
345
|
+
q, a = "SELECT id, data FROM todos WHERE 1=1", []
|
|
346
|
+
if pid:
|
|
347
|
+
q += " AND profile_id=?"
|
|
348
|
+
a.append(pid)
|
|
349
|
+
if open_only:
|
|
350
|
+
q += " AND done_at IS NULL"
|
|
351
|
+
q += " ORDER BY created_at"
|
|
352
|
+
return [self._dec("todos", i, d, Todo) for i, d in self._x(q, tuple(a)).fetchall()]
|
|
353
|
+
|
|
354
|
+
def complete_todo(self, tid: str) -> Todo | None:
|
|
355
|
+
row = self._x("SELECT data FROM todos WHERE id=?", (tid,)).fetchone()
|
|
356
|
+
if not row:
|
|
357
|
+
return None
|
|
358
|
+
t = self._dec("todos", tid, row[0], Todo).model_copy(update={"done_at": utcnow()})
|
|
359
|
+
self.save_todo(t)
|
|
360
|
+
return t
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------ events
|
|
363
|
+
def log(self, ev: Event) -> None:
|
|
364
|
+
self._x(
|
|
365
|
+
"INSERT INTO events (id, ts, profile_id, broker_id, actor, action, from_state, "
|
|
366
|
+
"to_state, data) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
367
|
+
(ev.id, _ts(ev.ts), ev.profile_id, ev.broker_id, ev.actor.value, ev.action,
|
|
368
|
+
ev.from_state.value if ev.from_state else None,
|
|
369
|
+
ev.to_state.value if ev.to_state else None, self._enc("events", ev.id, ev)),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def events(self, pid: str | None = None, bid: str | None = None,
|
|
373
|
+
limit: int = 500) -> list[Event]:
|
|
374
|
+
q = "SELECT id, data FROM events WHERE 1=1"
|
|
375
|
+
a: list[str | int] = []
|
|
376
|
+
for col, val in (("profile_id", pid), ("broker_id", bid)):
|
|
377
|
+
if val:
|
|
378
|
+
q += f" AND {col}=?"
|
|
379
|
+
a.append(val)
|
|
380
|
+
q += " ORDER BY seq DESC LIMIT ?"
|
|
381
|
+
a.append(limit)
|
|
382
|
+
return [self._dec("events", i, d, Event) for i, d in self._x(q, tuple(a)).fetchall()]
|
|
383
|
+
|
|
384
|
+
# ------------------------------------------------------------------ brokers snapshot
|
|
385
|
+
def snapshot_brokers(self, registry_version: str, brokers_json: dict[str, str]) -> None:
|
|
386
|
+
with self.transaction():
|
|
387
|
+
for bid, data in brokers_json.items():
|
|
388
|
+
self._conn.execute(
|
|
389
|
+
"INSERT OR REPLACE INTO brokers VALUES (?,?,?)", (bid, registry_version, data)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def dumps(obj: object) -> str:
|
|
394
|
+
return json.dumps(obj, default=str)
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Assisted tier ("Do it with me"): build to-dos for the user to do in their own browser.
|
|
2
|
+
|
|
3
|
+
Nothing here touches the network. Todos are deduplicated by `handoff.persist_todo`.
|
|
4
|
+
|
|
5
|
+
* ASSISTED_OPT_OUT web form / account-required / captcha brokers: steps from the
|
|
6
|
+
registry, the opt-out link and data-minimized Copy fields.
|
|
7
|
+
* SEND_EMAIL_MANUALLY email brokers when the profile has no removal mailbox (D2): the
|
|
8
|
+
rendered letter plus Gmail / Outlook / Yahoo compose links.
|
|
9
|
+
* POSTAL_LETTER / PHONE_CALL for postal / phone-only brokers.
|
|
10
|
+
* GOVERNMENT_PORTAL California DROP for CA residents (D3).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from ..engine import letters
|
|
17
|
+
from ..interfaces import RunContext, Submitter
|
|
18
|
+
from ..models import (
|
|
19
|
+
Broker,
|
|
20
|
+
Listing,
|
|
21
|
+
ListingState,
|
|
22
|
+
Method,
|
|
23
|
+
Profile,
|
|
24
|
+
StateLaw,
|
|
25
|
+
SubmitOutcome,
|
|
26
|
+
Todo,
|
|
27
|
+
TodoKind,
|
|
28
|
+
)
|
|
29
|
+
from . import base
|
|
30
|
+
|
|
31
|
+
DROP_BROKER_ID = "ca_drop"
|
|
32
|
+
DROP_URL = "https://consumer.drop.privacy.ca.gov"
|
|
33
|
+
DROP_STEPS = [
|
|
34
|
+
"Open the California DROP website with the button below.",
|
|
35
|
+
"Sign in or create a free account and confirm you live in California.",
|
|
36
|
+
"Fill in the details you are comfortable sharing. More details help brokers find you.",
|
|
37
|
+
"Send one deletion request. It goes to more than 500 data brokers at once.",
|
|
38
|
+
"Come back here and click \"I did it\".",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_REMOVAL_EMAIL = re.compile(r"\b(?:your |the )?removal email(?: address)?\b", re.IGNORECASE)
|
|
43
|
+
OWN_EMAIL = "an email address you can check"
|
|
44
|
+
NO_MAILBOX_NOTE = ("About the email box: use an email address you can check (your own is "
|
|
45
|
+
"fine). If the site says email is optional, you can leave it empty.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def no_mailbox_step(step: str) -> str:
|
|
49
|
+
"""Registry steps say "removal email" (the dedicated mailbox). Without one, point the
|
|
50
|
+
person at an address they can actually check (VM walkthrough finding 7)."""
|
|
51
|
+
return _REMOVAL_EMAIL.sub(OWN_EMAIL, step)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _steps(broker: Broker, reason: str | None, has_mailbox: bool = True) -> str:
|
|
55
|
+
lines: list[str] = []
|
|
56
|
+
if reason == "challenge":
|
|
57
|
+
lines.append("Free My ID stopped because the site asked for a human check. "
|
|
58
|
+
"Please finish it yourself.")
|
|
59
|
+
elif reason == "error":
|
|
60
|
+
lines.append("Free My ID could not finish this one automatically. Please do it yourself.")
|
|
61
|
+
steps = broker.steps or [
|
|
62
|
+
"Open the opt-out page with the button below.",
|
|
63
|
+
"Find your listing and follow the site's removal steps.",
|
|
64
|
+
"Use the Copy buttons to fill in the form.",
|
|
65
|
+
]
|
|
66
|
+
if not has_mailbox:
|
|
67
|
+
steps = [no_mailbox_step(st) for st in steps]
|
|
68
|
+
lines += [f"{i}. {s}" for i, s in enumerate(steps, 1)]
|
|
69
|
+
if broker.needs_email_verification:
|
|
70
|
+
lines.append(f"{len(steps) + 1}. Open the confirmation email the site sends and "
|
|
71
|
+
"click its link.")
|
|
72
|
+
if not has_mailbox and any(OWN_EMAIL in st for st in steps):
|
|
73
|
+
lines.append(NO_MAILBOX_NOTE)
|
|
74
|
+
lines.append("Never pay. Removal is free.")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def opt_out_link(broker: Broker) -> str:
|
|
79
|
+
return str(broker.opt_out_url or broker.url)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def assisted_todo(profile: Profile, broker: Broker, listings: list[Listing], *,
|
|
83
|
+
reason: str | None = None, again: bool = False) -> Todo:
|
|
84
|
+
title = f"Remove {profile.label} from {broker.name}"
|
|
85
|
+
if again:
|
|
86
|
+
title = f"Ask {broker.name} again to remove {profile.label}"
|
|
87
|
+
return Todo(profile_id=profile.id, broker_id=broker.id, kind=TodoKind.ASSISTED_OPT_OUT,
|
|
88
|
+
title=title,
|
|
89
|
+
body=_steps(broker, reason, has_mailbox=profile.mailbox is not None), links=[opt_out_link(broker)],
|
|
90
|
+
copy_fields=base.copy_fields(profile, broker, listings),
|
|
91
|
+
resume_token=f"assisted:{broker.id}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def manual_email_todo(profile: Profile, broker: Broker, subject: str, body: str, *,
|
|
95
|
+
title: str | None = None) -> Todo:
|
|
96
|
+
from ..engine.outbox import compose_links
|
|
97
|
+
to = broker.opt_out_email or ""
|
|
98
|
+
links = compose_links(to, subject, body)
|
|
99
|
+
intro = ("Free My ID wrote this letter for you. Click Gmail, Outlook or Yahoo to open it "
|
|
100
|
+
"ready to send, or copy it into any email program. Then click \"I did it\".\n\n")
|
|
101
|
+
return Todo(profile_id=profile.id, broker_id=broker.id, kind=TodoKind.SEND_EMAIL_MANUALLY,
|
|
102
|
+
title=title or f"Send {profile.label}'s removal letter to {broker.name}",
|
|
103
|
+
body=intro + body, links=list(links.values()),
|
|
104
|
+
copy_fields={"To": to, "Subject": subject, "Letter": body},
|
|
105
|
+
resume_token=f"manual_email:{broker.id}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def postal_todo(profile: Profile, broker: Broker, listings: list[Listing],
|
|
109
|
+
state_law: StateLaw | None) -> Todo:
|
|
110
|
+
from ..engine.outbox import effective_law
|
|
111
|
+
v = letters.letter_vars(profile, broker, listings, effective_law(profile, state_law))
|
|
112
|
+
text = letters.render("postal_letter", **v)
|
|
113
|
+
return Todo(profile_id=profile.id, broker_id=broker.id, kind=TodoKind.POSTAL_LETTER,
|
|
114
|
+
title=f"Mail a removal letter to {broker.name} for {profile.label}",
|
|
115
|
+
body="Print this letter, sign it and mail it.\n\n" + text,
|
|
116
|
+
links=[str(broker.url)], copy_fields={"Letter": text},
|
|
117
|
+
resume_token=f"postal:{broker.id}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def phone_todo(profile: Profile, broker: Broker, listings: list[Listing],
|
|
121
|
+
state_law: StateLaw | None) -> Todo:
|
|
122
|
+
from ..engine.outbox import effective_law
|
|
123
|
+
v = letters.letter_vars(profile, broker, listings, effective_law(profile, state_law))
|
|
124
|
+
text = letters.render("phone_script", broker_phone=broker.phone, **v)
|
|
125
|
+
cf = {"Phone number to call": broker.phone} if broker.phone else {}
|
|
126
|
+
return Todo(profile_id=profile.id, broker_id=broker.id, kind=TodoKind.PHONE_CALL,
|
|
127
|
+
title=f"Call {broker.name} to remove {profile.label}",
|
|
128
|
+
body=text, links=[str(broker.url)], copy_fields=cf,
|
|
129
|
+
resume_token=f"phone:{broker.id}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def drop_todo(profile: Profile, broker: Broker | None = None) -> Todo:
|
|
133
|
+
"""California DROP: one request reaches every registered data broker (D3)."""
|
|
134
|
+
link = str(broker.opt_out_url) if broker and broker.opt_out_url else DROP_URL
|
|
135
|
+
return Todo(profile_id=profile.id, broker_id=broker.id if broker else None,
|
|
136
|
+
kind=TodoKind.GOVERNMENT_PORTAL,
|
|
137
|
+
title=f"First step for {profile.label}: California's free removal website",
|
|
138
|
+
body="\n".join(f"{i}. {s}" for i, s in enumerate(DROP_STEPS, 1)),
|
|
139
|
+
links=[link], resume_token="gov:ca_drop")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def todo_for(profile: Profile, broker: Broker, listings: list[Listing],
|
|
143
|
+
state_law: StateLaw | None, *, reason: str | None = None,
|
|
144
|
+
again: bool = False) -> Todo:
|
|
145
|
+
"""The right human to-do for a broker handled in the Assisted tier."""
|
|
146
|
+
from ..engine.outbox import compose
|
|
147
|
+
from ..engine.tiers import has_email_route
|
|
148
|
+
if broker.method == Method.EMAIL and has_email_route(broker):
|
|
149
|
+
subject, body = compose(profile, broker, base.confirmed(listings) or listings,
|
|
150
|
+
state_law)
|
|
151
|
+
return manual_email_todo(profile, broker, subject, body)
|
|
152
|
+
if broker.method == Method.POSTAL and broker.postal:
|
|
153
|
+
return postal_todo(profile, broker, listings, state_law)
|
|
154
|
+
if broker.method == Method.PHONE and broker.phone and not broker.opt_out_url:
|
|
155
|
+
return phone_todo(profile, broker, listings, state_law)
|
|
156
|
+
return assisted_todo(profile, broker, listings, reason=reason, again=again)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AssistedSubmitter(Submitter):
|
|
160
|
+
"""Generic Assisted submitter: never searches, `submit` returns a to-do."""
|
|
161
|
+
|
|
162
|
+
method = "assisted"
|
|
163
|
+
|
|
164
|
+
async def search(self, ctx: RunContext) -> list[Listing]:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
async def submit(self, ctx: RunContext, listings: list[Listing]) -> SubmitOutcome:
|
|
168
|
+
todo = todo_for(ctx.profile, ctx.broker, listings, ctx.state_law,
|
|
169
|
+
reason=ctx.scratch.get("reason"), again=ctx.scratch.get("again", False))
|
|
170
|
+
return SubmitOutcome(new_state=ListingState.NEEDS_HUMAN, todos=[todo],
|
|
171
|
+
message=f"assisted:{todo.kind.value}")
|