interlatch 0.22.0__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.
- chronicle/__init__.py +17 -0
- chronicle/__main__.py +3 -0
- chronicle/access.py +93 -0
- chronicle/agents.py +23 -0
- chronicle/analyze.py +498 -0
- chronicle/antigravity_parser.py +311 -0
- chronicle/artifacts.py +621 -0
- chronicle/bob_parser.py +259 -0
- chronicle/cases.py +75 -0
- chronicle/chat_import.py +325 -0
- chronicle/chatgpt_export.py +127 -0
- chronicle/claude_export.py +129 -0
- chronicle/cli.py +2980 -0
- chronicle/codex_cloud.py +138 -0
- chronicle/codex_parser.py +753 -0
- chronicle/config.py +627 -0
- chronicle/connectors.py +666 -0
- chronicle/container.py +202 -0
- chronicle/copilot_parser.py +494 -0
- chronicle/db.py +598 -0
- chronicle/desktop.py +543 -0
- chronicle/diagram.py +488 -0
- chronicle/digest.py +194 -0
- chronicle/export_md.py +210 -0
- chronicle/friction.py +841 -0
- chronicle/glossary.py +522 -0
- chronicle/groups.py +181 -0
- chronicle/hooks.py +169 -0
- chronicle/hub.py +2291 -0
- chronicle/i18n.py +37 -0
- chronicle/i18n_ja.py +421 -0
- chronicle/ingest.py +1190 -0
- chronicle/install.py +590 -0
- chronicle/instructions.py +699 -0
- chronicle/ladder.py +244 -0
- chronicle/learning.py +170 -0
- chronicle/llm.py +570 -0
- chronicle/mcp_server.py +403 -0
- chronicle/menubar.py +771 -0
- chronicle/migrate.py +580 -0
- chronicle/mirror.py +453 -0
- chronicle/news.py +110 -0
- chronicle/notify.py +92 -0
- chronicle/parser.py +908 -0
- chronicle/people.py +536 -0
- chronicle/pg.py +122 -0
- chronicle/pricing.py +168 -0
- chronicle/providers.py +591 -0
- chronicle/redact.py +50 -0
- chronicle/reviews.py +251 -0
- chronicle/screen.py +308 -0
- chronicle/search.py +238 -0
- chronicle/server.py +3009 -0
- chronicle/session_export.py +178 -0
- chronicle/statusline.py +258 -0
- chronicle/suggest.py +643 -0
- chronicle/synthesize.py +365 -0
- chronicle/systems.py +1265 -0
- chronicle/tailnet.py +97 -0
- chronicle/team_store.py +450 -0
- chronicle/update.py +404 -0
- chronicle/util.py +217 -0
- chronicle/views.py +249 -0
- chronicle/web/PLEX-OFL.txt +93 -0
- chronicle/web/app.css +2350 -0
- chronicle/web/app.js +7587 -0
- chronicle/web/art-book.webp +0 -0
- chronicle/web/art-camera.webp +0 -0
- chronicle/web/art-chip.webp +0 -0
- chronicle/web/art-clock.webp +0 -0
- chronicle/web/art-cloud.webp +0 -0
- chronicle/web/art-coins.webp +0 -0
- chronicle/web/art-container.webp +0 -0
- chronicle/web/art-dashboard.webp +0 -0
- chronicle/web/art-dex.webp +0 -0
- chronicle/web/art-document.webp +0 -0
- chronicle/web/art-flag.webp +0 -0
- chronicle/web/art-folder.webp +0 -0
- chronicle/web/art-gateway.webp +0 -0
- chronicle/web/art-gears.webp +0 -0
- chronicle/web/art-glitch.webp +0 -0
- chronicle/web/art-globe.webp +0 -0
- chronicle/web/art-laptop.webp +0 -0
- chronicle/web/art-log.webp +0 -0
- chronicle/web/art-lumi.webp +0 -0
- chronicle/web/art-magnifier.webp +0 -0
- chronicle/web/art-pip.webp +0 -0
- chronicle/web/art-plug.webp +0 -0
- chronicle/web/art-rack.webp +0 -0
- chronicle/web/art-tink.webp +0 -0
- chronicle/web/boot.js +25 -0
- chronicle/web/favicon.png +0 -0
- chronicle/web/i18n.js +50 -0
- chronicle/web/icon-180.png +0 -0
- chronicle/web/icon-192.png +0 -0
- chronicle/web/icon-512.png +0 -0
- chronicle/web/icon.png +0 -0
- chronicle/web/index.html +92 -0
- chronicle/web/ja.js +1880 -0
- chronicle/web/learning.js +438 -0
- chronicle/web/manifest.webmanifest +15 -0
- chronicle/web/panel.css +175 -0
- chronicle/web/panel.html +14 -0
- chronicle/web/panel.js +241 -0
- chronicle/web/plex-mono-400.woff2 +0 -0
- chronicle/web/plex-mono-500.woff2 +0 -0
- chronicle/web/plex-mono-600.woff2 +0 -0
- chronicle/web/plex-sans-400.woff2 +0 -0
- chronicle/web/plex-sans-500.woff2 +0 -0
- chronicle/web/plex-sans-600.woff2 +0 -0
- chronicle/web/plex-sans-700.woff2 +0 -0
- chronicle/web/rough.js +2 -0
- chronicle/worker.py +390 -0
- interlatch-0.22.0.dist-info/METADATA +221 -0
- interlatch-0.22.0.dist-info/RECORD +118 -0
- interlatch-0.22.0.dist-info/WHEEL +4 -0
- interlatch-0.22.0.dist-info/entry_points.txt +3 -0
- interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
chronicle/people.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""People on a hub: who may send to it and open its dashboard, and with which role. No Tailscale needed.
|
|
2
|
+
|
|
3
|
+
Roles:
|
|
4
|
+
admin sees everything, invites people and changes roles, changes the hub's settings (team store, people)
|
|
5
|
+
member their computers send to the hub and get teammates' lessons back; sees the hub's dashboard
|
|
6
|
+
readonly sees the hub's dashboard; sends nothing, changes nothing
|
|
7
|
+
|
|
8
|
+
Projects: a member or read-only person sees the projects they were given (`interlatch hub invite … --project`), or
|
|
9
|
+
every project (`--all-projects`). Someone limited to projects sees only those projects' session summaries and project
|
|
10
|
+
lessons on the dashboard (access.py), and their computers share knowledge only, only for those projects (hub.py).
|
|
11
|
+
Admins always see everything. People added before projects existed see every project, as they did.
|
|
12
|
+
|
|
13
|
+
Everything starts with an invite. An admin adds a person with a role and gets a one-time code (expires after
|
|
14
|
+
INVITE_DAYS) to pass on by chat; the hub sends no email. The code is redeemed once, either by a computer
|
|
15
|
+
(`interlatch hub join <hub> --code …`: it becomes a push token for that person and that computer) or by a browser
|
|
16
|
+
(the invite link: it becomes a dashboard session). A person's own computer can later ask for a short sign-in link
|
|
17
|
+
(SIGNIN_MINUTES) to open the hub's dashboard without a password.
|
|
18
|
+
|
|
19
|
+
Only hashes of codes, tokens and sessions are stored; the secret itself is shown once (codes) or lives only on the
|
|
20
|
+
computer (tokens) or in the browser's cookie (sessions). Removing a person, or revoking a token or session, takes
|
|
21
|
+
effect at the next request. Whoever is at the hub computer itself is always an admin: that is how the first admin
|
|
22
|
+
is made, and how a hub whose admins are all gone is recovered.
|
|
23
|
+
|
|
24
|
+
While a hub has no people, it works as before: computers send with the hub's shared token. Once it has people the
|
|
25
|
+
shared token keeps working until `[hub] shared_token = false` (Devices › People, or the config), so computers that
|
|
26
|
+
joined before are not cut off.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import hmac
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import re
|
|
36
|
+
import secrets
|
|
37
|
+
import sqlite3
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
from datetime import datetime, timedelta, timezone
|
|
41
|
+
|
|
42
|
+
from .util import utcnow_iso
|
|
43
|
+
|
|
44
|
+
ROLES = ("admin", "member", "readonly")
|
|
45
|
+
ROLE_RANK = {"readonly": 0, "member": 1, "admin": 2}
|
|
46
|
+
INVITE_DAYS = 7
|
|
47
|
+
SIGNIN_MINUTES = 5
|
|
48
|
+
BROWSER_DAYS = 30 # a dashboard session lasts this long after it was last used
|
|
49
|
+
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O, 1/I: read aloud or retyped from chat
|
|
50
|
+
LOCAL = "this computer" # the actor for things done at the hub itself
|
|
51
|
+
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PeopleError(Exception):
|
|
55
|
+
"""A message template and its values, so the dashboard can show it in the viewer's language (`shown`)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, template: str, **values):
|
|
58
|
+
super().__init__(template.format(**values) if values else template)
|
|
59
|
+
self.template, self.values = template, values
|
|
60
|
+
|
|
61
|
+
def shown(self) -> str:
|
|
62
|
+
from .i18n import tr
|
|
63
|
+
|
|
64
|
+
return tr(self.template, **self.values)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnknownCode(PeopleError):
|
|
68
|
+
"""A code this hub never issued (or not of the kind asked for): what a guess gets, so CodeAttempts counts it."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CodeAttempts:
|
|
72
|
+
"""Wrong codes per address, for the two places a code is the credential (/signin?code= and /api/hub/join).
|
|
73
|
+
|
|
74
|
+
The first FREE wrong ones in WINDOW cost nothing. After them, a try from that address waits 1 s after the last wrong
|
|
75
|
+
one, then 2 s, 4 s and so on up to MAX_WAIT, and is answered without its code being looked at. Never a lockout: a
|
|
76
|
+
wait runs out, a quiet WINDOW forgets the address, and a code the hub issued (used or expired) doesn't count.
|
|
77
|
+
Codes are long and single-use, so this is belt and braces."""
|
|
78
|
+
|
|
79
|
+
FREE, WINDOW, MAX_WAIT, KEEP = 5, 15 * 60, 60, 10_000
|
|
80
|
+
|
|
81
|
+
def __init__(self, clock=time.monotonic):
|
|
82
|
+
self._clock, self._lock = clock, threading.Lock()
|
|
83
|
+
self._seen: dict[str, tuple[int, float]] = {} # address -> (wrong codes, when the last one came)
|
|
84
|
+
|
|
85
|
+
def _count(self, who: str, now: float) -> tuple[int, float]:
|
|
86
|
+
n, last = self._seen.get(who, (0, 0.0))
|
|
87
|
+
return (0, 0.0) if n and now - last > self.WINDOW else (n, last)
|
|
88
|
+
|
|
89
|
+
def wait(self, who: str) -> int:
|
|
90
|
+
"""Seconds `who` still waits before a code of theirs is looked at; 0 to go ahead."""
|
|
91
|
+
with self._lock:
|
|
92
|
+
now = self._clock()
|
|
93
|
+
n, last = self._count(who, now)
|
|
94
|
+
if n < self.FREE:
|
|
95
|
+
return 0
|
|
96
|
+
return max(0, math.ceil(min(self.MAX_WAIT, 2 ** (n - self.FREE)) - (now - last)))
|
|
97
|
+
|
|
98
|
+
def wrong(self, who: str) -> None:
|
|
99
|
+
with self._lock:
|
|
100
|
+
now = self._clock()
|
|
101
|
+
self._seen[who] = (self._count(who, now)[0] + 1, now)
|
|
102
|
+
if len(self._seen) > self.KEEP: # many addresses at once: forget the half that went quiet first
|
|
103
|
+
cut = sorted(last for _, last in self._seen.values())[len(self._seen) // 2]
|
|
104
|
+
self._seen = {k: v for k, v in self._seen.items() if v[1] > cut}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hash(secret: str) -> str:
|
|
108
|
+
return hashlib.sha256(secret.strip().encode()).hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _now() -> datetime:
|
|
112
|
+
return datetime.now(timezone.utc)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _iso(dt: datetime) -> str:
|
|
116
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _expired(at: str | None) -> bool:
|
|
120
|
+
return bool(at) and at < _iso(_now())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def new_code() -> str:
|
|
124
|
+
"""XXXX-XXXX-XXXX: 60 bits, easy to read out and retype."""
|
|
125
|
+
raw = "".join(secrets.choice(CODE_ALPHABET) for _ in range(12))
|
|
126
|
+
return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def normalize_code(code: str) -> str:
|
|
130
|
+
raw = re.sub(r"[^A-Za-z0-9]", "", code or "").upper()
|
|
131
|
+
return f"{raw[:4]}-{raw[4:8]}-{raw[8:]}" if len(raw) == 12 else raw
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def audit(conn: sqlite3.Connection, actor: str | None, action: str, person_id: int | None = None, **detail) -> None:
|
|
135
|
+
conn.execute("INSERT INTO people_audit(at, actor, action, person_id, detail) VALUES (?, ?, ?, ?, ?)",
|
|
136
|
+
(utcnow_iso(), actor, action, person_id, json.dumps(detail, ensure_ascii=False) if detail else None))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def actor_of(person: dict | None) -> str:
|
|
140
|
+
return f"person:{person['id']}" if person else LOCAL
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def clean_projects(projects) -> list[str] | None:
|
|
144
|
+
"""None (every project) or a sorted list of distinct project paths; anything else is refused."""
|
|
145
|
+
if projects is None:
|
|
146
|
+
return None
|
|
147
|
+
if isinstance(projects, str) or not isinstance(projects, (list, tuple, set)):
|
|
148
|
+
raise PeopleError("projects must be a list of project paths")
|
|
149
|
+
out = set()
|
|
150
|
+
for x in projects:
|
|
151
|
+
x = str(x or "").strip()
|
|
152
|
+
x = x.rstrip("/") or x
|
|
153
|
+
if not x.startswith("/") or len(x) > 1024 or "\0" in x:
|
|
154
|
+
raise PeopleError("{project} is not a project on this hub", project=x or "''")
|
|
155
|
+
out.add(x)
|
|
156
|
+
return sorted(out)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def projects_of(person: dict | None) -> list[str] | None:
|
|
160
|
+
"""The projects `person` may see: None for every project (an admin, someone at the hub itself, or someone given
|
|
161
|
+
every project), else their list, possibly empty."""
|
|
162
|
+
if person is None or person.get("role") == "admin":
|
|
163
|
+
return None
|
|
164
|
+
raw = person.get("projects_json")
|
|
165
|
+
if raw is None:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
got = json.loads(raw)
|
|
169
|
+
except ValueError:
|
|
170
|
+
return [] # unreadable: nothing, never everything
|
|
171
|
+
return [x for x in got if isinstance(x, str)] if isinstance(got, list) else []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def sees(person: dict | None, project_path: str | None) -> bool:
|
|
175
|
+
scope = projects_of(person)
|
|
176
|
+
return scope is None or (project_path is not None and project_path in scope)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ people
|
|
180
|
+
def has_people(conn: sqlite3.Connection) -> bool:
|
|
181
|
+
return conn.execute("SELECT 1 FROM people WHERE removed_at IS NULL LIMIT 1").fetchone() is not None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get(conn: sqlite3.Connection, person_id: int) -> dict | None:
|
|
185
|
+
r = conn.execute("SELECT * FROM people WHERE id = ?", (person_id,)).fetchone()
|
|
186
|
+
return dict(r) if r else None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def by_email(conn: sqlite3.Connection, email: str) -> dict | None:
|
|
190
|
+
r = conn.execute("SELECT * FROM people WHERE email = ? AND removed_at IS NULL", ((email or "").strip().lower(),)).fetchone()
|
|
191
|
+
return dict(r) if r else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def add(conn: sqlite3.Connection, name: str, email: str | None, role: str, *, by: dict | None = None,
|
|
195
|
+
projects: list[str] | None = None) -> dict:
|
|
196
|
+
"""Add a person. Email is optional but needed for a company sign-in (auth header) to find them. `projects`: the
|
|
197
|
+
project paths they see, None for every project (the command line and the dashboard make the inviter choose)."""
|
|
198
|
+
name, email = (name or "").strip(), (email or "").strip().lower() or None
|
|
199
|
+
projects = clean_projects(projects)
|
|
200
|
+
if not name:
|
|
201
|
+
raise PeopleError("a name is needed")
|
|
202
|
+
if role not in ROLES:
|
|
203
|
+
raise PeopleError("role must be one of: {roles}", roles=", ".join(ROLES))
|
|
204
|
+
if email and not EMAIL_RE.match(email):
|
|
205
|
+
raise PeopleError("{email} is not an email address", email=email)
|
|
206
|
+
if email and by_email(conn, email):
|
|
207
|
+
raise PeopleError("{email} is already on this hub", email=email)
|
|
208
|
+
if email: # someone removed earlier comes back as a new person: the old row keeps its history
|
|
209
|
+
conn.execute("UPDATE people SET email = NULL WHERE email = ? AND removed_at IS NOT NULL", (email,))
|
|
210
|
+
cur = conn.execute("INSERT INTO people(name, email, role, created_at, created_by, projects_json) VALUES (?, ?, ?, ?, ?, ?)",
|
|
211
|
+
(name[:120], email, role, utcnow_iso(), by["id"] if by else None,
|
|
212
|
+
None if projects is None else json.dumps(projects, ensure_ascii=False)))
|
|
213
|
+
audit(conn, actor_of(by), "add", cur.lastrowid, role=role, email=email, projects=projects)
|
|
214
|
+
conn.commit()
|
|
215
|
+
return get(conn, cur.lastrowid)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def set_role(conn: sqlite3.Connection, person_id: int, role: str, *, by: dict | None = None) -> dict:
|
|
219
|
+
p = get(conn, person_id)
|
|
220
|
+
if not p or p["removed_at"]:
|
|
221
|
+
raise PeopleError("no such person")
|
|
222
|
+
if role not in ROLES:
|
|
223
|
+
raise PeopleError("role must be one of: {roles}", roles=", ".join(ROLES))
|
|
224
|
+
if p["role"] == "admin" and role != "admin" and by is not None and _admins(conn) == [person_id]:
|
|
225
|
+
raise PeopleError("this is the hub's last admin: make someone else an admin first")
|
|
226
|
+
conn.execute("UPDATE people SET role = ? WHERE id = ?", (role, person_id))
|
|
227
|
+
audit(conn, actor_of(by), "role", person_id, before=p["role"], after=role)
|
|
228
|
+
conn.commit()
|
|
229
|
+
return get(conn, person_id)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def set_projects(conn: sqlite3.Connection, person_id: int, projects: list[str] | None, *, by: dict | None = None) -> dict:
|
|
233
|
+
"""Which projects a person sees: a list of project paths, or None for every project. Takes effect at their
|
|
234
|
+
next request; what their computers already sent stays."""
|
|
235
|
+
p = get(conn, person_id)
|
|
236
|
+
if not p or p["removed_at"]:
|
|
237
|
+
raise PeopleError("no such person")
|
|
238
|
+
projects = clean_projects(projects)
|
|
239
|
+
before = None if p.get("projects_json") is None else projects_of({**p, "role": "member"})
|
|
240
|
+
conn.execute("UPDATE people SET projects_json = ? WHERE id = ?",
|
|
241
|
+
(None if projects is None else json.dumps(projects, ensure_ascii=False), person_id))
|
|
242
|
+
audit(conn, actor_of(by), "projects", person_id, before=before, after=projects)
|
|
243
|
+
conn.commit()
|
|
244
|
+
return get(conn, person_id)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def remove(conn: sqlite3.Connection, person_id: int, *, by: dict | None = None) -> None:
|
|
248
|
+
"""Remove a person: their codes, computer tokens and browser sessions stop working at once. What their computers
|
|
249
|
+
sent stays on the hub."""
|
|
250
|
+
p = get(conn, person_id)
|
|
251
|
+
if not p or p["removed_at"]:
|
|
252
|
+
raise PeopleError("no such person")
|
|
253
|
+
if p["role"] == "admin" and by is not None and _admins(conn) == [person_id]:
|
|
254
|
+
raise PeopleError("this is the hub's last admin: make someone else an admin first")
|
|
255
|
+
now = utcnow_iso()
|
|
256
|
+
conn.execute("UPDATE people SET removed_at = ? WHERE id = ?", (now, person_id))
|
|
257
|
+
conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE person_id = ? AND revoked_at IS NULL", (now, person_id))
|
|
258
|
+
conn.execute("UPDATE people_codes SET used_at = ? WHERE person_id = ? AND used_at IS NULL", (now, person_id))
|
|
259
|
+
audit(conn, actor_of(by), "remove", person_id)
|
|
260
|
+
conn.commit()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _admins(conn: sqlite3.Connection) -> list[int]:
|
|
264
|
+
return [r[0] for r in conn.execute("SELECT id FROM people WHERE role = 'admin' AND removed_at IS NULL")]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def listing(conn: sqlite3.Connection) -> list[dict]:
|
|
268
|
+
"""Everyone on the hub, with their computers, open browser sessions and unused invites (no secrets)."""
|
|
269
|
+
now = _iso(_now())
|
|
270
|
+
out = []
|
|
271
|
+
for p in conn.execute("SELECT * FROM people WHERE removed_at IS NULL ORDER BY role = 'admin' DESC, name"):
|
|
272
|
+
p = dict(p)
|
|
273
|
+
p["projects"] = projects_of(p)
|
|
274
|
+
toks = [dict(t) for t in conn.execute(
|
|
275
|
+
"SELECT t.token_hash, t.kind, t.machine_id, t.label, t.created_at, t.last_used, t.expires_at, m.name AS machine_name "
|
|
276
|
+
"FROM people_tokens t LEFT JOIN machines m ON m.id = t.machine_id "
|
|
277
|
+
"WHERE t.person_id = ? AND t.revoked_at IS NULL ORDER BY t.created_at", (p["id"],))]
|
|
278
|
+
p["computers"] = [{"id": t["token_hash"][:16], "machine_id": t["machine_id"], "name": t["machine_name"] or t["label"],
|
|
279
|
+
"since": t["created_at"], "last_used": t["last_used"]} for t in toks if t["kind"] == "computer"]
|
|
280
|
+
p["browsers"] = [{"id": t["token_hash"][:16], "label": t["label"], "since": t["created_at"], "last_used": t["last_used"]}
|
|
281
|
+
for t in toks if t["kind"] == "browser" and not (t["expires_at"] and t["expires_at"] < now)]
|
|
282
|
+
p["invites"] = [{"expires_at": r[0], "created_at": r[1]} for r in conn.execute(
|
|
283
|
+
"SELECT expires_at, created_at FROM people_codes WHERE person_id = ? AND kind = 'invite' AND used_at IS NULL "
|
|
284
|
+
"AND expires_at > ? ORDER BY created_at", (p["id"], now))]
|
|
285
|
+
out.append(p)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------ one-time codes
|
|
290
|
+
def invite(conn: sqlite3.Connection, person_id: int, *, by: dict | None = None, days: int = INVITE_DAYS,
|
|
291
|
+
machine: str | None = None) -> str:
|
|
292
|
+
"""A one-time code for this person: redeemed by `interlatch hub join … --code` (a computer) or the invite link
|
|
293
|
+
(a browser). Returns the code; only its hash is kept. With `machine`, the invite is for that one computer the hub
|
|
294
|
+
already knows: an admin's word that it is this person's, for a computer that can't show its key (may_claim)."""
|
|
295
|
+
p = get(conn, person_id)
|
|
296
|
+
if not p or p["removed_at"]:
|
|
297
|
+
raise PeopleError("no such person")
|
|
298
|
+
if machine and not conn.execute("SELECT 1 FROM machines WHERE id = ?", (machine,)).fetchone():
|
|
299
|
+
raise PeopleError("this hub doesn't know a computer {machine}", machine=machine)
|
|
300
|
+
code = new_code()
|
|
301
|
+
now = _now()
|
|
302
|
+
conn.execute("INSERT INTO people_codes(code_hash, person_id, kind, created_at, expires_at, machine_id) "
|
|
303
|
+
"VALUES (?, ?, 'invite', ?, ?, ?)", (_hash(code), person_id, _iso(now), _iso(now + timedelta(days=days)), machine))
|
|
304
|
+
audit(conn, actor_of(by), "invite", person_id, days=days, **({"machine": machine} if machine else {}))
|
|
305
|
+
conn.commit()
|
|
306
|
+
return code
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def signin_code(conn: sqlite3.Connection, person: dict) -> str:
|
|
310
|
+
"""A short-lived code that opens the dashboard as this person, asked for by one of their computers."""
|
|
311
|
+
code = secrets.token_urlsafe(24)
|
|
312
|
+
now = _now()
|
|
313
|
+
conn.execute("INSERT INTO people_codes(code_hash, person_id, kind, created_at, expires_at) VALUES (?, ?, 'signin', ?, ?)",
|
|
314
|
+
(_hash(code), person["id"], _iso(now), _iso(now + timedelta(minutes=SIGNIN_MINUTES))))
|
|
315
|
+
conn.commit()
|
|
316
|
+
return code
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def peek_invite(conn: sqlite3.Connection, code: str) -> dict | None:
|
|
320
|
+
"""The person an unused invite code is for, without using it up (e.g. to refuse a read-only person's computer
|
|
321
|
+
while the code still opens a browser); None when the code isn't a live invite."""
|
|
322
|
+
r = conn.execute("SELECT person_id, expires_at FROM people_codes WHERE kind = 'invite' AND used_at IS NULL "
|
|
323
|
+
"AND code_hash IN (?, ?)", (_hash(normalize_code(code)), _hash(code or ""))).fetchone()
|
|
324
|
+
if not r or _expired(r["expires_at"]):
|
|
325
|
+
return None
|
|
326
|
+
p = get(conn, r["person_id"])
|
|
327
|
+
return p if p and not p["removed_at"] else None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _take_code(conn: sqlite3.Connection, code: str, kinds: tuple[str, ...]) -> dict:
|
|
331
|
+
# an invite as typed (any case, with or without dashes), or a sign-in code exactly as issued
|
|
332
|
+
r = conn.execute("SELECT * FROM people_codes WHERE code_hash IN (?, ?)",
|
|
333
|
+
(_hash(normalize_code(code)), _hash(code or ""))).fetchone()
|
|
334
|
+
h = r["code_hash"] if r else None
|
|
335
|
+
if not r or r["kind"] not in kinds:
|
|
336
|
+
raise UnknownCode("that code isn't known on this hub")
|
|
337
|
+
if r["used_at"]:
|
|
338
|
+
raise PeopleError("that code was already used; ask an admin for a new one")
|
|
339
|
+
if _expired(r["expires_at"]):
|
|
340
|
+
raise PeopleError("that code has expired; ask an admin for a new one")
|
|
341
|
+
p = get(conn, r["person_id"])
|
|
342
|
+
if not p or p["removed_at"]:
|
|
343
|
+
raise PeopleError("that person is no longer on this hub")
|
|
344
|
+
cur = conn.execute("UPDATE people_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL", (utcnow_iso(), h))
|
|
345
|
+
if cur.rowcount != 1: # two redemptions at once: only one wins
|
|
346
|
+
raise PeopleError("that code was already used; ask an admin for a new one")
|
|
347
|
+
return p
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def key_hash(key) -> str | None:
|
|
351
|
+
"""What a computer's key (its machine-key file) is kept as; None for anything that isn't one."""
|
|
352
|
+
return _hash(key) if isinstance(key, str) and 32 <= len(key) <= 200 else None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def record_key(conn: sqlite3.Connection, machine_id: str, key) -> None:
|
|
356
|
+
"""Keep a computer's key, as its hash, the first time it shows one (it says hello, or joins). One on record never
|
|
357
|
+
changes: a computer that later shows another isn't that computer."""
|
|
358
|
+
if h := key_hash(key):
|
|
359
|
+
conn.execute("UPDATE machines SET key_hash = ? WHERE id = ? AND key_hash IS NULL", (h, machine_id))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def invite_machine(conn: sqlite3.Connection, code: str) -> str | None:
|
|
363
|
+
"""The one computer an invite was made for (`interlatch hub invite --computer`), if it was."""
|
|
364
|
+
r = conn.execute("SELECT machine_id FROM people_codes WHERE kind = 'invite' AND code_hash IN (?, ?)",
|
|
365
|
+
(_hash(normalize_code(code)), _hash(code or ""))).fetchone()
|
|
366
|
+
return r["machine_id"] if r else None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def may_claim(conn: sqlite3.Connection, code: str, machine_id: str, key) -> bool:
|
|
370
|
+
"""Whether a (live) invite may make `machine_id` its person's computer. One the hub has never heard of may join. One
|
|
371
|
+
it knows (it said hello or sent sessions) only by showing the key the hub recorded from it, or with an invite an
|
|
372
|
+
admin made for that computer: anyone with an invite could otherwise claim another computer's id, and its sessions
|
|
373
|
+
with it, and lock it out (a computer that joined as a person sends with its own token only)."""
|
|
374
|
+
known = conn.execute("SELECT key_hash FROM machines WHERE id = ?", (machine_id,)).fetchone()
|
|
375
|
+
if known is None and not conn.execute("SELECT 1 FROM sessions WHERE machine_id = ? LIMIT 1", (machine_id,)).fetchone():
|
|
376
|
+
return True
|
|
377
|
+
if invite_machine(conn, code) == machine_id:
|
|
378
|
+
return True
|
|
379
|
+
stored, shown = known["key_hash"] if known else None, key_hash(key)
|
|
380
|
+
return bool(stored and shown and hmac.compare_digest(stored, shown))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
NOT_THAT_COMPUTER = ("this hub already knows a computer with this id ({machine}) and can't tell this is it: an admin "
|
|
384
|
+
"makes an invite for that computer (`interlatch hub invite <name> --computer {machine}`), and you "
|
|
385
|
+
"join with that code")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def join_computer(conn: sqlite3.Connection, code: str, machine_id: str, machine_name: str | None = None,
|
|
389
|
+
key: str | None = None) -> tuple[dict, str]:
|
|
390
|
+
"""Redeem an invite for a computer: (person, push token). The token replaces any earlier one of that computer of
|
|
391
|
+
the same person. A computer someone else's live token is bound to is refused: the invite would take it over, and
|
|
392
|
+
send (or take back) sessions as that computer. An admin removes that computer first. A computer the hub already
|
|
393
|
+
knows must show its key (may_claim). Both are checked only for a live invite, before it is used up: a wrong code
|
|
394
|
+
learns nothing about the computer."""
|
|
395
|
+
owner = peek_invite(conn, code)
|
|
396
|
+
if owner and conn.execute(
|
|
397
|
+
"SELECT 1 FROM people_tokens t JOIN people x ON x.id = t.person_id WHERE t.machine_id = ? AND t.kind = 'computer' "
|
|
398
|
+
"AND t.revoked_at IS NULL AND x.removed_at IS NULL AND t.person_id != ?", (machine_id, owner["id"])).fetchone():
|
|
399
|
+
raise PeopleError("this computer already joined this hub as someone else; an admin removes it from that "
|
|
400
|
+
"person first (Team › People)")
|
|
401
|
+
if owner and (meant := invite_machine(conn, code)) and meant != machine_id:
|
|
402
|
+
raise PeopleError("this invite is for another computer ({machine}); join from that one", machine=meant)
|
|
403
|
+
if owner and not may_claim(conn, code, machine_id, key):
|
|
404
|
+
raise PeopleError(NOT_THAT_COMPUTER, machine=machine_id)
|
|
405
|
+
p = _take_code(conn, code, ("invite",))
|
|
406
|
+
token = secrets.token_urlsafe(32)
|
|
407
|
+
now = utcnow_iso()
|
|
408
|
+
conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE machine_id = ? AND kind = 'computer' AND revoked_at IS NULL",
|
|
409
|
+
(now, machine_id))
|
|
410
|
+
conn.execute("INSERT INTO people_tokens(token_hash, person_id, kind, machine_id, label, created_at) "
|
|
411
|
+
"VALUES (?, ?, 'computer', ?, ?, ?)", (_hash(token), p["id"], machine_id, (machine_name or "")[:120] or None, now))
|
|
412
|
+
conn.execute("UPDATE machines SET person_id = ? WHERE id = ?", (p["id"], machine_id))
|
|
413
|
+
audit(conn, actor_of(p), "join", p["id"], machine=machine_id, name=machine_name)
|
|
414
|
+
conn.commit()
|
|
415
|
+
return p, token
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def open_browser(conn: sqlite3.Connection, code: str, label: str | None = None) -> tuple[dict, str]:
|
|
419
|
+
"""Redeem an invite or a sign-in code in a browser: (person, session secret for the cookie)."""
|
|
420
|
+
p = _take_code(conn, code, ("invite", "signin"))
|
|
421
|
+
session = secrets.token_urlsafe(32)
|
|
422
|
+
now = _now()
|
|
423
|
+
conn.execute("INSERT INTO people_tokens(token_hash, person_id, kind, label, created_at, expires_at, last_used) "
|
|
424
|
+
"VALUES (?, ?, 'browser', ?, ?, ?, ?)",
|
|
425
|
+
(_hash(session), p["id"], (label or "")[:200] or None, _iso(now), _iso(now + timedelta(days=BROWSER_DAYS)), _iso(now)))
|
|
426
|
+
audit(conn, actor_of(p), "signin", p["id"])
|
|
427
|
+
conn.commit()
|
|
428
|
+
return p, session
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
LANDING_RE = re.compile(r"/[A-Za-z0-9/_.~%=&?+-]{0,500}") # a dashboard page: a hash route, such as /project?path=%2F…
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def landing(page) -> str:
|
|
435
|
+
"""Where a sign-in link opens the dashboard: the page its next asks for (a hash route), or Home. Only a route's own
|
|
436
|
+
characters pass, so the link can't lead off the dashboard or into the response's headers."""
|
|
437
|
+
return f"/#{page}" if isinstance(page, str) and LANDING_RE.fullmatch(page) else "/#/"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------------ who is asking
|
|
441
|
+
def _by_token(conn: sqlite3.Connection, secret: str, kind: str) -> tuple[dict, dict] | None:
|
|
442
|
+
if not secret:
|
|
443
|
+
return None
|
|
444
|
+
t = conn.execute("SELECT * FROM people_tokens WHERE token_hash = ? AND kind = ?", (_hash(secret), kind)).fetchone()
|
|
445
|
+
if not t or t["revoked_at"] or _expired(t["expires_at"]):
|
|
446
|
+
return None
|
|
447
|
+
p = get(conn, t["person_id"])
|
|
448
|
+
if not p or p["removed_at"]:
|
|
449
|
+
return None
|
|
450
|
+
return p, dict(t)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def computer_bound(conn: sqlite3.Connection, machine_id: str) -> bool:
|
|
454
|
+
"""Whether a computer joined this hub as a person who is still on it (a live computer token is bound to it).
|
|
455
|
+
Such a computer sends with its own token: the hub's shared token may not act as it (hub.authorize)."""
|
|
456
|
+
return conn.execute(
|
|
457
|
+
"SELECT 1 FROM people_tokens t JOIN people x ON x.id = t.person_id WHERE t.machine_id = ? AND t.kind = 'computer' "
|
|
458
|
+
"AND t.revoked_at IS NULL AND x.removed_at IS NULL", (machine_id,)).fetchone() is not None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def computer_person(conn: sqlite3.Connection, token: str, machine_id: str | None = None) -> dict | None:
|
|
462
|
+
"""The person a computer's push token belongs to; None when it is no (longer a) valid token. A token presented by
|
|
463
|
+
another computer than the one it was issued to is refused."""
|
|
464
|
+
hit = _by_token(conn, token, "computer")
|
|
465
|
+
if not hit:
|
|
466
|
+
return None
|
|
467
|
+
p, t = hit
|
|
468
|
+
if machine_id and t["machine_id"] and t["machine_id"] != machine_id:
|
|
469
|
+
return None
|
|
470
|
+
if not t["last_used"] or t["last_used"][:16] != utcnow_iso()[:16]: # at most one write a minute
|
|
471
|
+
conn.execute("UPDATE people_tokens SET last_used = ? WHERE token_hash = ?", (utcnow_iso(), t["token_hash"]))
|
|
472
|
+
conn.commit()
|
|
473
|
+
return p
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def browser_person(conn: sqlite3.Connection, session: str) -> dict | None:
|
|
477
|
+
"""The person a dashboard session cookie belongs to, sliding its expiry forward; None when it isn't valid."""
|
|
478
|
+
hit = _by_token(conn, session, "browser")
|
|
479
|
+
if not hit:
|
|
480
|
+
return None
|
|
481
|
+
p, t = hit
|
|
482
|
+
now = _now()
|
|
483
|
+
if not t["last_used"] or t["last_used"][:13] != _iso(now)[:13]: # at most one write an hour
|
|
484
|
+
conn.execute("UPDATE people_tokens SET last_used = ?, expires_at = ? WHERE token_hash = ?",
|
|
485
|
+
(_iso(now), _iso(now + timedelta(days=BROWSER_DAYS)), t["token_hash"]))
|
|
486
|
+
conn.commit()
|
|
487
|
+
return p
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def sign_out(conn: sqlite3.Connection, session: str) -> None:
|
|
491
|
+
conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE token_hash = ? AND kind = 'browser' AND revoked_at IS NULL",
|
|
492
|
+
(utcnow_iso(), _hash(session)))
|
|
493
|
+
conn.commit()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def revoke(conn: sqlite3.Connection, person_id: int, token_id: str, *, by: dict | None = None) -> bool:
|
|
497
|
+
"""Revoke one of a person's computer tokens or browser sessions by the short id listing() shows."""
|
|
498
|
+
if not re.fullmatch(r"[0-9a-f]{16}", token_id or ""):
|
|
499
|
+
return False
|
|
500
|
+
cur = conn.execute("UPDATE people_tokens SET revoked_at = ? WHERE person_id = ? AND substr(token_hash, 1, 16) = ? "
|
|
501
|
+
"AND revoked_at IS NULL", (utcnow_iso(), person_id, token_id))
|
|
502
|
+
if cur.rowcount:
|
|
503
|
+
audit(conn, actor_of(by), "revoke", person_id, token=token_id)
|
|
504
|
+
conn.commit()
|
|
505
|
+
return bool(cur.rowcount)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def allows(person: dict | None, need: str) -> bool:
|
|
509
|
+
"""`person` (None: someone at the hub computer itself, always an admin) has at least role `need`."""
|
|
510
|
+
if person is None:
|
|
511
|
+
return True
|
|
512
|
+
return ROLE_RANK.get(person.get("role"), -1) >= ROLE_RANK[need]
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def public(person: dict | None) -> dict:
|
|
516
|
+
"""What the dashboard may know about who is viewing it."""
|
|
517
|
+
if person is None:
|
|
518
|
+
return {"id": None, "name": LOCAL, "email": None, "role": "admin", "here": True, "projects": None}
|
|
519
|
+
return {"id": person["id"], "name": person["name"], "email": person.get("email"), "role": person["role"], "here": False,
|
|
520
|
+
"projects": projects_of(person)}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def audit_log(conn: sqlite3.Connection, limit: int = 200) -> list[dict]:
|
|
524
|
+
rows = conn.execute("SELECT a.*, p.name AS person_name FROM people_audit a LEFT JOIN people p ON p.id = a.person_id "
|
|
525
|
+
"ORDER BY a.id DESC LIMIT ?", (limit,)).fetchall()
|
|
526
|
+
out = []
|
|
527
|
+
for r in rows:
|
|
528
|
+
d = dict(r)
|
|
529
|
+
d["detail"] = json.loads(d["detail"]) if d["detail"] else {}
|
|
530
|
+
if d["actor"] and d["actor"].startswith("person:"):
|
|
531
|
+
who = get(conn, int(d["actor"][7:]))
|
|
532
|
+
d["actor_name"] = who["name"] if who else d["actor"]
|
|
533
|
+
else:
|
|
534
|
+
d["actor_name"] = d["actor"]
|
|
535
|
+
out.append(d)
|
|
536
|
+
return out
|
chronicle/pg.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Connecting to a Postgres database: the settings file both the hub's team store (team_store.py) and the personal
|
|
2
|
+
mirror (mirror.py) keep their connection in, and checking what the dashboard sends for it.
|
|
3
|
+
|
|
4
|
+
A settings file holds PG* lines (PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE), readable by this user
|
|
5
|
+
only. The password goes in from the dashboard and never comes back out. The driver (psycopg) is an optional extra.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("chronicle.pg")
|
|
15
|
+
|
|
16
|
+
PG_KEYS = {"PGHOST": "host", "PGPORT": "port", "PGDATABASE": "dbname", "PGUSER": "user", "PGPASSWORD": "password",
|
|
17
|
+
"PGSSLMODE": "sslmode"}
|
|
18
|
+
SSLMODES = ("require", "verify-ca", "verify-full", "prefer", "disable")
|
|
19
|
+
SETTINGS = ("host", "port", "dbname", "user", "sslmode") # what the dashboard shows and edits; the password only goes in
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PgError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_env(path: Path) -> dict[str, str]:
|
|
27
|
+
"""PG* lines of an env file: KEY=value, a value optionally in one pair of quotes; # starts a comment line."""
|
|
28
|
+
raw: dict[str, str] = {}
|
|
29
|
+
if path.stat().st_mode & 0o077:
|
|
30
|
+
log.warning("%s can be read by other users of this computer; chmod 600 it", path)
|
|
31
|
+
for line in path.read_text().splitlines():
|
|
32
|
+
if line.strip() and not line.lstrip().startswith("#") and "=" in line:
|
|
33
|
+
k, v = line.split("=", 1)
|
|
34
|
+
v = v.strip()
|
|
35
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
|
36
|
+
v = v[1:-1]
|
|
37
|
+
raw[k.strip()] = v
|
|
38
|
+
return raw
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def params_from(path: Path, *, environ: bool = True) -> dict:
|
|
42
|
+
"""psycopg connect() arguments from the env file at `path`, else (`environ`) the PG* environment; {} when neither
|
|
43
|
+
names a server and a database. Never logged: it holds a password."""
|
|
44
|
+
raw = read_env(path) if path.exists() else {k: v for k, v in os.environ.items() if k in PG_KEYS} if environ else {}
|
|
45
|
+
params = {PG_KEYS[k]: v for k, v in raw.items() if k in PG_KEYS and v}
|
|
46
|
+
if not params.get("host") or not params.get("dbname"):
|
|
47
|
+
return {}
|
|
48
|
+
params.setdefault("sslmode", "require")
|
|
49
|
+
return params
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def shown(params: dict | None) -> dict | None:
|
|
53
|
+
"""The connection as the dashboard shows it: everything but the password, which is only said to be there."""
|
|
54
|
+
if not params:
|
|
55
|
+
return None
|
|
56
|
+
return {**{k: params.get(k) or "" for k in SETTINGS}, "password_set": bool(params.get("password"))}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_settings(values: dict, saved: dict | None, error: type[Exception] = PgError) -> dict:
|
|
60
|
+
"""psycopg connect() arguments from what the dashboard sent: a password left empty keeps the saved one."""
|
|
61
|
+
params = {}
|
|
62
|
+
for k in (*SETTINGS, "password"):
|
|
63
|
+
v = values.get(k)
|
|
64
|
+
v = "" if v is None else str(v)
|
|
65
|
+
if "\n" in v or "\r" in v or "\0" in v:
|
|
66
|
+
raise error(f"{k} can't contain a line break")
|
|
67
|
+
if k != "password":
|
|
68
|
+
v = v.strip()
|
|
69
|
+
if v:
|
|
70
|
+
params[k] = v
|
|
71
|
+
for k in ("host", "dbname", "user"):
|
|
72
|
+
if not params.get(k):
|
|
73
|
+
raise error({"host": "the database server's address is missing", "dbname": "the database name is missing",
|
|
74
|
+
"user": "the user name is missing"}[k])
|
|
75
|
+
port = params.get("port", "5432")
|
|
76
|
+
if not port.isdigit() or not 0 < int(port) < 65536:
|
|
77
|
+
raise error("the port must be a number from 1 to 65535")
|
|
78
|
+
params["port"] = port
|
|
79
|
+
params.setdefault("sslmode", "require")
|
|
80
|
+
if params["sslmode"] not in SSLMODES:
|
|
81
|
+
raise error(f"SSL mode must be one of: {', '.join(SSLMODES)}")
|
|
82
|
+
if "password" not in params:
|
|
83
|
+
if not saved or not saved.get("password"):
|
|
84
|
+
raise error("the password is missing")
|
|
85
|
+
params["password"] = saved["password"]
|
|
86
|
+
return params
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_settings(path: Path, params: dict, header: str) -> None:
|
|
90
|
+
"""Write an env file (readable by this user only), replacing it in one step."""
|
|
91
|
+
def quoted(v: str) -> str: # a value that reading would change keeps its exact text inside one pair of quotes
|
|
92
|
+
return f"'{v}'" if v != v.strip() or (len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'") else v
|
|
93
|
+
|
|
94
|
+
keys = {v: k for k, v in PG_KEYS.items()}
|
|
95
|
+
lines = [f"# {header}"]
|
|
96
|
+
lines += [f"{keys[k]}={quoted(str(params[k]))}" for k in (*SETTINGS[:4], "password", "sslmode") if params.get(k)]
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
tmp = path.with_name(path.name + f".tmp{os.getpid()}")
|
|
99
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
100
|
+
with os.fdopen(fd, "w") as fh:
|
|
101
|
+
fh.write("\n".join(lines) + "\n")
|
|
102
|
+
os.replace(tmp, path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def driver_available() -> bool:
|
|
106
|
+
import importlib.util
|
|
107
|
+
|
|
108
|
+
return importlib.util.find_spec("psycopg") is not None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def where(params: dict) -> str:
|
|
112
|
+
return f"{params.get('dbname')} on {params.get('host')}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def first_line(exc: BaseException) -> str:
|
|
116
|
+
return (str(exc).strip().splitlines() or [type(exc).__name__])[0]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def iso(value) -> str | None:
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
return value.isoformat().replace("+00:00", "Z") if hasattr(value, "isoformat") else str(value)
|