pubkit 0.1.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.
- pubkit/__init__.py +14 -0
- pubkit/__main__.py +7 -0
- pubkit/adapters/__init__.py +2 -0
- pubkit/adapters/api_base.py +67 -0
- pubkit/adapters/devto.py +195 -0
- pubkit/adapters/medium.py +184 -0
- pubkit/adapters/substack.py +136 -0
- pubkit/adapters/x.py +216 -0
- pubkit/browserctl.py +78 -0
- pubkit/cli.py +306 -0
- pubkit/core/__init__.py +2 -0
- pubkit/core/adapter.py +206 -0
- pubkit/core/anchors.py +89 -0
- pubkit/core/auth.py +209 -0
- pubkit/core/browser.py +426 -0
- pubkit/core/capabilities.py +256 -0
- pubkit/core/checks.py +344 -0
- pubkit/core/ir.py +240 -0
- pubkit/core/loader.py +278 -0
- pubkit/core/runner.py +268 -0
- pubkit/core/state.py +213 -0
- pubkit/core/transport.py +170 -0
- pubkit/py.typed +0 -0
- pubkit/registry.py +81 -0
- pubkit/render/__init__.py +2 -0
- pubkit/render/html.py +221 -0
- pubkit/scaffold.py +271 -0
- pubkit/workflows/__init__.py +2 -0
- pubkit/workflows/airflow.py +115 -0
- pubkit-0.1.0.dist-info/METADATA +291 -0
- pubkit-0.1.0.dist-info/RECORD +35 -0
- pubkit-0.1.0.dist-info/WHEEL +4 -0
- pubkit-0.1.0.dist-info/entry_points.txt +2 -0
- pubkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- pubkit-0.1.0.dist-info/licenses/NOTICE +7 -0
pubkit/core/adapter.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Copyright 2026 The pubkit Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The adapter contract, plus the two base classes that do the hard parts.
|
|
4
|
+
|
|
5
|
+
Six methods is the entire surface a new platform has to implement. Everything
|
|
6
|
+
painful — verified transport, anchor resolution, image insertion, fingerprint
|
|
7
|
+
verification, rate limiting, retries — lives in the base classes, because those
|
|
8
|
+
are exactly the things that went wrong the first time and nobody should have to
|
|
9
|
+
rediscover them per platform.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import hashlib
|
|
15
|
+
import random
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from .auth import CredentialError, SessionStore, TokenStore
|
|
21
|
+
from .capabilities import Capabilities, PublishPlan
|
|
22
|
+
from .ir import Document
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RemoteRef:
|
|
27
|
+
"""Where a document lives on a platform before publication."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
url: str | None = None
|
|
31
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PublishedRef:
|
|
36
|
+
id: str
|
|
37
|
+
url: str
|
|
38
|
+
published_at: float = field(default_factory=time.time)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Fingerprint:
|
|
43
|
+
"""A cheap, stable summary of remote state.
|
|
44
|
+
|
|
45
|
+
Compared after a reload to prove a mutation actually stuck. Editors lie:
|
|
46
|
+
a delete that visibly worked came back after a refresh, and DOM edits to
|
|
47
|
+
link hrefs never persisted at all (failures B3, B4).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
words: int
|
|
51
|
+
headings: list[str]
|
|
52
|
+
images: int
|
|
53
|
+
links: int
|
|
54
|
+
markers: int = 0
|
|
55
|
+
|
|
56
|
+
def digest(self) -> str:
|
|
57
|
+
payload = f"{self.words}|{'|'.join(self.headings)}|{self.images}|{self.links}|{self.markers}"
|
|
58
|
+
return hashlib.blake2b(payload.encode(), digest_size=8).hexdigest()
|
|
59
|
+
|
|
60
|
+
def assert_matches(self, expected: Fingerprint, *, tolerance: float = 0.02) -> None:
|
|
61
|
+
problems = []
|
|
62
|
+
if expected.words and abs(self.words - expected.words) / expected.words > tolerance:
|
|
63
|
+
problems.append(f"words {self.words} vs expected {expected.words}")
|
|
64
|
+
if self.images != expected.images:
|
|
65
|
+
problems.append(f"images {self.images} vs expected {expected.images}")
|
|
66
|
+
if self.markers:
|
|
67
|
+
problems.append(f"{self.markers} unresolved marker(s) still present")
|
|
68
|
+
if expected.headings and self.headings[: len(expected.headings)] != expected.headings:
|
|
69
|
+
problems.append("heading list diverges")
|
|
70
|
+
if problems:
|
|
71
|
+
raise VerificationFailed("; ".join(problems))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class VerificationFailed(RuntimeError):
|
|
75
|
+
"""Remote state does not match what we pushed. Never publish past this."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AdapterError(RuntimeError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NotRetryable(AdapterError):
|
|
83
|
+
"""A failure that retrying cannot fix.
|
|
84
|
+
|
|
85
|
+
A missing credential is not a transient error. Backing off four times
|
|
86
|
+
before telling the user to run `pubkit auth login` wastes 30 seconds and
|
|
87
|
+
buries the one line that actually helps them.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RateLimited(AdapterError):
|
|
92
|
+
def __init__(self, retry_after: float) -> None:
|
|
93
|
+
super().__init__(f"rate limited, retry after {retry_after:.1f}s")
|
|
94
|
+
self.retry_after = retry_after
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Context:
|
|
99
|
+
"""What an adapter is handed. Deliberately narrow.
|
|
100
|
+
|
|
101
|
+
An adapter gets credentials for its own platform and nothing else.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
platform: str
|
|
105
|
+
tokens: TokenStore
|
|
106
|
+
sessions: SessionStore
|
|
107
|
+
dry_run: bool = False
|
|
108
|
+
confirm: bool = False
|
|
109
|
+
headless: bool = True
|
|
110
|
+
workdir: str = ".pubkit"
|
|
111
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@runtime_checkable
|
|
115
|
+
class Adapter(Protocol):
|
|
116
|
+
name: str
|
|
117
|
+
capabilities: Capabilities
|
|
118
|
+
|
|
119
|
+
async def authenticate(self, ctx: Context) -> None: ...
|
|
120
|
+
async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef: ...
|
|
121
|
+
async def push_content(self, doc: Document, plan: PublishPlan, ref: RemoteRef, ctx: Context) -> None: ...
|
|
122
|
+
async def push_media(self, doc: Document, plan: PublishPlan, ref: RemoteRef, ctx: Context) -> None: ...
|
|
123
|
+
async def verify(self, doc: Document, plan: PublishPlan, ref: RemoteRef, ctx: Context) -> Fingerprint: ...
|
|
124
|
+
async def publish(self, doc: Document, ref: RemoteRef, ctx: Context) -> PublishedRef: ...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# rate limiting + retry, shared by every adapter
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
class TokenBucket:
|
|
131
|
+
def __init__(self, rate: float, burst: int) -> None:
|
|
132
|
+
self.rate, self.burst = rate, burst
|
|
133
|
+
self._tokens = float(burst)
|
|
134
|
+
self._last = time.monotonic()
|
|
135
|
+
self._lock = asyncio.Lock()
|
|
136
|
+
|
|
137
|
+
async def acquire(self, n: int = 1) -> None:
|
|
138
|
+
async with self._lock:
|
|
139
|
+
while True:
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
self._tokens = min(self.burst, self._tokens + (now - self._last) * self.rate)
|
|
142
|
+
self._last = now
|
|
143
|
+
if self._tokens >= n:
|
|
144
|
+
self._tokens -= n
|
|
145
|
+
return
|
|
146
|
+
await asyncio.sleep((n - self._tokens) / self.rate)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def with_retry(fn, *, attempts: int = 4, base: float = 1.0, cap: float = 30.0, on_retry=None):
|
|
150
|
+
"""Bounded exponential backoff with full jitter.
|
|
151
|
+
|
|
152
|
+
Full jitter rather than fixed backoff on purpose: when five platform legs
|
|
153
|
+
fail at once because a shared dependency blipped, synchronised retries make
|
|
154
|
+
it worse.
|
|
155
|
+
"""
|
|
156
|
+
last: Exception | None = None
|
|
157
|
+
for attempt in range(attempts):
|
|
158
|
+
try:
|
|
159
|
+
return await fn()
|
|
160
|
+
except (NotRetryable, PermissionError, CredentialError):
|
|
161
|
+
raise
|
|
162
|
+
except RateLimited as exc:
|
|
163
|
+
delay = exc.retry_after
|
|
164
|
+
except AdapterError as exc:
|
|
165
|
+
last = exc
|
|
166
|
+
delay = min(cap, base * 2**attempt) * random.random()
|
|
167
|
+
except Exception as exc: # noqa: BLE001 - adapters raise anything
|
|
168
|
+
last = exc
|
|
169
|
+
delay = min(cap, base * 2**attempt) * random.random()
|
|
170
|
+
else: # pragma: no cover
|
|
171
|
+
break
|
|
172
|
+
if attempt == attempts - 1:
|
|
173
|
+
break
|
|
174
|
+
if on_retry:
|
|
175
|
+
on_retry(attempt, delay, last)
|
|
176
|
+
await asyncio.sleep(delay)
|
|
177
|
+
raise AdapterError(f"gave up after {attempts} attempts: {last}") from last
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class BaseAdapter:
|
|
181
|
+
"""Shared plumbing. Adapters subclass either this or a browser/API base."""
|
|
182
|
+
|
|
183
|
+
name: str = "base"
|
|
184
|
+
capabilities: Capabilities = Capabilities()
|
|
185
|
+
#: Requests per second and burst, tuned per platform.
|
|
186
|
+
rate: float = 1.0
|
|
187
|
+
burst: int = 3
|
|
188
|
+
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
self.bucket = TokenBucket(self.rate, self.burst)
|
|
191
|
+
|
|
192
|
+
async def authenticate(self, ctx: Context) -> None: # pragma: no cover - default
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
async def push_media(self, doc, plan, ref, ctx) -> None: # pragma: no cover - default
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def guard_publish(self, ctx: Context) -> None:
|
|
199
|
+
"""Publishing is public and hard to undo (failure D3)."""
|
|
200
|
+
if ctx.dry_run:
|
|
201
|
+
raise AdapterError("refusing to publish during a dry run")
|
|
202
|
+
if not ctx.confirm:
|
|
203
|
+
raise AdapterError(
|
|
204
|
+
"publishing requires explicit confirmation: pass --confirm "
|
|
205
|
+
"(or set PUBKIT_CONFIRM=1 in CI)"
|
|
206
|
+
)
|
pubkit/core/anchors.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Copyright 2026 The pubkit Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Finding a place in a document you do not control.
|
|
4
|
+
|
|
5
|
+
Rich-text editors normalise what you give them. A caption written as
|
|
6
|
+
|
|
7
|
+
Where 80 GB goes. The cores never run out - the memory does.
|
|
8
|
+
|
|
9
|
+
came back from the editor as
|
|
10
|
+
|
|
11
|
+
Where 80 GB goes. The cores never run out — the memory does.
|
|
12
|
+
|
|
13
|
+
—hair spaces around an em dash. Exact string equality failed, the anchor was
|
|
14
|
+
not found, and one image out of fifteen silently did not get placed. Matching
|
|
15
|
+
text against a live editor needs to be forgiving in exactly the ways editors
|
|
16
|
+
are aggressive.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import unicodedata
|
|
22
|
+
from difflib import SequenceMatcher
|
|
23
|
+
|
|
24
|
+
_DASHES = dict.fromkeys(map(ord, "‐‑‒–—―−"), "-")
|
|
25
|
+
_QUOTES = {
|
|
26
|
+
ord("‘"): "'", ord("’"): "'", ord("‚"): "'", ord("‛"): "'",
|
|
27
|
+
ord("“"): '"', ord("”"): '"', ord("„"): '"', ord("″"): '"',
|
|
28
|
+
}
|
|
29
|
+
_SPACES = dict.fromkeys(
|
|
30
|
+
map(ord, " "),
|
|
31
|
+
" ",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalise(s: str) -> str:
|
|
36
|
+
"""Fold everything an editor is likely to have changed."""
|
|
37
|
+
s = unicodedata.normalize("NFKC", s)
|
|
38
|
+
s = s.translate(_SPACES).translate(_DASHES).translate(_QUOTES)
|
|
39
|
+
s = s.replace("", "").replace("", "")
|
|
40
|
+
s = re.sub(r"\s*-\s*", " - ", s)
|
|
41
|
+
s = re.sub(r"\s+", " ", s)
|
|
42
|
+
return s.strip().casefold()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def similarity(a: str, b: str) -> float:
|
|
46
|
+
return SequenceMatcher(None, normalise(a), normalise(b)).ratio()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_anchor(
|
|
50
|
+
candidates: list[str],
|
|
51
|
+
target: str,
|
|
52
|
+
*,
|
|
53
|
+
floor: float = 0.82,
|
|
54
|
+
used: set[int] | None = None,
|
|
55
|
+
) -> int:
|
|
56
|
+
"""Locate `target` among `candidates`, returning an index or -1.
|
|
57
|
+
|
|
58
|
+
Three passes, cheapest first: exact-after-normalisation, prefix, then
|
|
59
|
+
fuzzy above `floor`. `used` lets a caller place several identical captions
|
|
60
|
+
without every one resolving to the first match.
|
|
61
|
+
"""
|
|
62
|
+
used = used or set()
|
|
63
|
+
norm = [normalise(c) for c in candidates]
|
|
64
|
+
t = normalise(target)
|
|
65
|
+
|
|
66
|
+
for i, c in enumerate(norm):
|
|
67
|
+
if i not in used and c == t:
|
|
68
|
+
return i
|
|
69
|
+
|
|
70
|
+
if len(t) >= 16:
|
|
71
|
+
for i, c in enumerate(norm):
|
|
72
|
+
if i not in used and (c.startswith(t[:40]) or t.startswith(c[:40])):
|
|
73
|
+
return i
|
|
74
|
+
|
|
75
|
+
best, best_score = -1, floor
|
|
76
|
+
for i, c in enumerate(norm):
|
|
77
|
+
if i in used:
|
|
78
|
+
continue
|
|
79
|
+
score = SequenceMatcher(None, c, t).ratio()
|
|
80
|
+
if score > best_score:
|
|
81
|
+
best, best_score = i, score
|
|
82
|
+
return best
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AnchorNotFound(LookupError):
|
|
86
|
+
def __init__(self, target: str, closest: str | None, score: float) -> None:
|
|
87
|
+
hint = f" closest was {closest!r} at {score:.2f}" if closest else ""
|
|
88
|
+
super().__init__(f"no anchor for {target[:60]!r}.{hint}")
|
|
89
|
+
self.target, self.closest, self.score = target, closest, score
|
pubkit/core/auth.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Copyright 2026 The pubkit Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Credentials.
|
|
4
|
+
|
|
5
|
+
Design rule, and it is not negotiable: **pubkit never accepts a password.**
|
|
6
|
+
|
|
7
|
+
For API platforms the user pastes a token once and it goes into the OS
|
|
8
|
+
keychain. For browser platforms the user signs in themselves, in a real visible
|
|
9
|
+
browser window, and pubkit persists only the resulting session state — which it
|
|
10
|
+
encrypts, with the key in the keychain.
|
|
11
|
+
|
|
12
|
+
This is not only a security posture. It is also the only thing that works:
|
|
13
|
+
platforms increasingly gate login behind CAPTCHA, device confirmation and MFA,
|
|
14
|
+
none of which an automation layer should be trying to get past.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
try: # pragma: no cover - environment dependent
|
|
26
|
+
import keyring
|
|
27
|
+
|
|
28
|
+
_HAVE_KEYRING = True
|
|
29
|
+
except Exception: # pragma: no cover
|
|
30
|
+
keyring = None # type: ignore[assignment]
|
|
31
|
+
_HAVE_KEYRING = False
|
|
32
|
+
|
|
33
|
+
try: # pragma: no cover - environment dependent
|
|
34
|
+
from cryptography.fernet import Fernet
|
|
35
|
+
|
|
36
|
+
_HAVE_FERNET = True
|
|
37
|
+
except Exception: # pragma: no cover
|
|
38
|
+
Fernet = None # type: ignore[assignment]
|
|
39
|
+
_HAVE_FERNET = False
|
|
40
|
+
|
|
41
|
+
SERVICE = "pubkit"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CredentialError(RuntimeError):
|
|
45
|
+
"""Missing or unusable credentials. Never retried — see adapter.NotRetryable."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class TokenStore:
|
|
50
|
+
"""API tokens. Keychain first, encrypted vault as the CI fallback."""
|
|
51
|
+
|
|
52
|
+
vault_path: Path = Path(".pubkit/vault.json")
|
|
53
|
+
service: str = SERVICE
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ read
|
|
56
|
+
def get(self, platform: str, key: str = "token") -> str | None:
|
|
57
|
+
env = os.environ.get(f"PUBKIT_{platform.upper()}_{key.upper()}")
|
|
58
|
+
if env:
|
|
59
|
+
return env
|
|
60
|
+
if _HAVE_KEYRING:
|
|
61
|
+
try:
|
|
62
|
+
val = keyring.get_password(self.service, f"{platform}:{key}")
|
|
63
|
+
if val:
|
|
64
|
+
return val
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
return self._vault_get(platform, key)
|
|
68
|
+
|
|
69
|
+
def require(self, platform: str, key: str = "token") -> str:
|
|
70
|
+
val = self.get(platform, key)
|
|
71
|
+
if not val:
|
|
72
|
+
raise CredentialError(
|
|
73
|
+
f"no {key} for {platform}. Run `pubkit auth login {platform}`, "
|
|
74
|
+
f"or set PUBKIT_{platform.upper()}_{key.upper()}."
|
|
75
|
+
)
|
|
76
|
+
return val
|
|
77
|
+
|
|
78
|
+
# ----------------------------------------------------------------- write
|
|
79
|
+
def set(self, platform: str, value: str, key: str = "token") -> None:
|
|
80
|
+
if _HAVE_KEYRING:
|
|
81
|
+
try:
|
|
82
|
+
keyring.set_password(self.service, f"{platform}:{key}", value)
|
|
83
|
+
return
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
self._vault_set(platform, key, value)
|
|
87
|
+
|
|
88
|
+
def delete(self, platform: str, key: str = "token") -> None:
|
|
89
|
+
if _HAVE_KEYRING:
|
|
90
|
+
try:
|
|
91
|
+
keyring.delete_password(self.service, f"{platform}:{key}")
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
data = self._vault_read()
|
|
95
|
+
data.pop(f"{platform}:{key}", None)
|
|
96
|
+
self._vault_write(data)
|
|
97
|
+
|
|
98
|
+
# ----------------------------------------------------------------- vault
|
|
99
|
+
def _vault_key(self) -> bytes:
|
|
100
|
+
env = os.environ.get("PUBKIT_VAULT_KEY")
|
|
101
|
+
if env:
|
|
102
|
+
return env.encode()
|
|
103
|
+
if _HAVE_KEYRING:
|
|
104
|
+
k = keyring.get_password(self.service, "_vault_key")
|
|
105
|
+
if not k:
|
|
106
|
+
k = Fernet.generate_key().decode() if _HAVE_FERNET else base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
|
|
107
|
+
keyring.set_password(self.service, "_vault_key", k)
|
|
108
|
+
return k.encode()
|
|
109
|
+
raise CredentialError(
|
|
110
|
+
"no keychain and no PUBKIT_VAULT_KEY set; cannot encrypt the vault. "
|
|
111
|
+
"In CI, set PUBKIT_VAULT_KEY from your secret store."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _vault_read(self) -> dict[str, str]:
|
|
115
|
+
if not self.vault_path.exists():
|
|
116
|
+
return {}
|
|
117
|
+
blob = self.vault_path.read_bytes()
|
|
118
|
+
if not _HAVE_FERNET:
|
|
119
|
+
raise CredentialError("cryptography is required to read the vault")
|
|
120
|
+
return json.loads(Fernet(self._vault_key()).decrypt(blob))
|
|
121
|
+
|
|
122
|
+
def _vault_write(self, data: dict[str, str]) -> None:
|
|
123
|
+
if not _HAVE_FERNET:
|
|
124
|
+
raise CredentialError("cryptography is required to write the vault")
|
|
125
|
+
self.vault_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
blob = Fernet(self._vault_key()).encrypt(json.dumps(data).encode())
|
|
127
|
+
self.vault_path.write_bytes(blob)
|
|
128
|
+
os.chmod(self.vault_path, 0o600)
|
|
129
|
+
|
|
130
|
+
def _vault_get(self, platform: str, key: str) -> str | None:
|
|
131
|
+
try:
|
|
132
|
+
return self._vault_read().get(f"{platform}:{key}")
|
|
133
|
+
except Exception:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def _vault_set(self, platform: str, key: str, value: str) -> None:
|
|
137
|
+
data = self._vault_read()
|
|
138
|
+
data[f"{platform}:{key}"] = value
|
|
139
|
+
self._vault_write(data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class SessionStore:
|
|
144
|
+
"""Persisted Playwright `storage_state`, encrypted at rest.
|
|
145
|
+
|
|
146
|
+
The flow is deliberately human-in-the-loop:
|
|
147
|
+
|
|
148
|
+
pubkit auth login medium
|
|
149
|
+
→ opens a real browser window at the platform's login page
|
|
150
|
+
→ you sign in (password manager, MFA, whatever it takes)
|
|
151
|
+
→ pubkit waits for the post-login URL, saves the session, closes
|
|
152
|
+
|
|
153
|
+
pubkit sees cookies, never credentials.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
dir: Path = Path(".pubkit/sessions")
|
|
157
|
+
tokens: TokenStore = None # type: ignore[assignment]
|
|
158
|
+
|
|
159
|
+
def __post_init__(self) -> None:
|
|
160
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
if self.tokens is None:
|
|
162
|
+
self.tokens = TokenStore()
|
|
163
|
+
|
|
164
|
+
def _path(self, platform: str) -> Path:
|
|
165
|
+
return self.dir / f"{platform}.session"
|
|
166
|
+
|
|
167
|
+
def exists(self, platform: str) -> bool:
|
|
168
|
+
return self._path(platform).exists()
|
|
169
|
+
|
|
170
|
+
def save(self, platform: str, storage_state: dict) -> None:
|
|
171
|
+
raw = json.dumps(storage_state).encode()
|
|
172
|
+
if _HAVE_FERNET:
|
|
173
|
+
raw = Fernet(self.tokens._vault_key()).encrypt(raw)
|
|
174
|
+
p = self._path(platform)
|
|
175
|
+
p.write_bytes(raw)
|
|
176
|
+
os.chmod(p, 0o600)
|
|
177
|
+
|
|
178
|
+
def load(self, platform: str) -> dict | None:
|
|
179
|
+
p = self._path(platform)
|
|
180
|
+
if not p.exists():
|
|
181
|
+
return None
|
|
182
|
+
raw = p.read_bytes()
|
|
183
|
+
if _HAVE_FERNET:
|
|
184
|
+
try:
|
|
185
|
+
raw = Fernet(self.tokens._vault_key()).decrypt(raw)
|
|
186
|
+
except Exception as exc: # corrupted or key rotated
|
|
187
|
+
raise CredentialError(
|
|
188
|
+
f"cannot decrypt the saved {platform} session; "
|
|
189
|
+
f"run `pubkit auth login {platform}` again ({exc})"
|
|
190
|
+
) from exc
|
|
191
|
+
return json.loads(raw)
|
|
192
|
+
|
|
193
|
+
def forget(self, platform: str) -> None:
|
|
194
|
+
self._path(platform).unlink(missing_ok=True)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RedactingFilter:
|
|
198
|
+
"""Log filter that strips secrets by construction, not by discipline."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, secrets_: list[str]) -> None:
|
|
201
|
+
self._secrets = [s for s in secrets_ if s and len(s) >= 8]
|
|
202
|
+
|
|
203
|
+
def filter(self, record) -> bool: # pragma: no cover - trivial
|
|
204
|
+
msg = str(record.getMessage())
|
|
205
|
+
for s in self._secrets:
|
|
206
|
+
if s in msg:
|
|
207
|
+
record.msg = msg.replace(s, "***redacted***")
|
|
208
|
+
record.args = ()
|
|
209
|
+
return True
|