Personal save-for-later and Miniflux e-reader proxy for Xteink X4 (wip)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: device api tokens

+383 -35
+120
src/server/device-tokens.ts
··· 1 + import { 2 + readFileSync, 3 + writeFileSync, 4 + existsSync, 5 + renameSync, 6 + mkdirSync, 7 + } from "node:fs"; 8 + import { resolve, dirname } from "node:path"; 9 + import { randomBytes, createHash, timingSafeEqual } from "node:crypto"; 10 + 11 + export type DeviceTokenRecord = { 12 + id: string; 13 + hash: string; 14 + did: string; 15 + label: string; 16 + prefix: string; 17 + createdAt: string; 18 + lastUsedAt: string | null; 19 + revokedAt: string | null; 20 + }; 21 + 22 + export type DeviceTokenPublic = Omit<DeviceTokenRecord, "hash">; 23 + 24 + type File = { tokens: DeviceTokenRecord[] }; 25 + 26 + const TOKEN_PREFIX = "nsd_"; 27 + 28 + export class DeviceTokenStore { 29 + private path: string; 30 + private cache: File | null = null; 31 + 32 + constructor(dataDir: string) { 33 + this.path = resolve(dataDir, "device-tokens.json"); 34 + mkdirSync(dirname(this.path), { recursive: true }); 35 + } 36 + 37 + private read(): File { 38 + if (this.cache) return this.cache; 39 + if (!existsSync(this.path)) { 40 + this.cache = { tokens: [] }; 41 + return this.cache; 42 + } 43 + try { 44 + this.cache = JSON.parse(readFileSync(this.path, "utf8")) as File; 45 + } catch { 46 + this.cache = { tokens: [] }; 47 + } 48 + return this.cache; 49 + } 50 + 51 + private write(file: File): void { 52 + const tmp = this.path + ".tmp"; 53 + writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 }); 54 + renameSync(tmp, this.path); 55 + this.cache = file; 56 + } 57 + 58 + list(did: string): DeviceTokenPublic[] { 59 + return this.read() 60 + .tokens.filter((t) => t.did === did) 61 + .map(({ hash: _hash, ...rest }) => rest); 62 + } 63 + 64 + create(did: string, label: string): { record: DeviceTokenPublic; token: string } { 65 + const raw = randomBytes(32).toString("base64url"); 66 + const token = TOKEN_PREFIX + raw; 67 + const record: DeviceTokenRecord = { 68 + id: randomBytes(8).toString("hex"), 69 + hash: hashToken(token), 70 + did, 71 + label: label.trim() || "device", 72 + prefix: token.slice(0, 8), 73 + createdAt: new Date().toISOString(), 74 + lastUsedAt: null, 75 + revokedAt: null, 76 + }; 77 + const file = this.read(); 78 + file.tokens.push(record); 79 + this.write(file); 80 + const { hash: _hash, ...publicRec } = record; 81 + return { record: publicRec, token }; 82 + } 83 + 84 + revoke(did: string, id: string): boolean { 85 + const file = this.read(); 86 + const t = file.tokens.find((r) => r.id === id && r.did === did); 87 + if (!t || t.revokedAt) return false; 88 + t.revokedAt = new Date().toISOString(); 89 + this.write(file); 90 + return true; 91 + } 92 + 93 + verify(token: string): DeviceTokenRecord | null { 94 + if (!token.startsWith(TOKEN_PREFIX)) return null; 95 + const expected = hashToken(token); 96 + const file = this.read(); 97 + for (const t of file.tokens) { 98 + if (t.revokedAt) continue; 99 + if (constantTimeEqualHex(t.hash, expected)) return t; 100 + } 101 + return null; 102 + } 103 + 104 + touch(id: string): void { 105 + const file = this.read(); 106 + const t = file.tokens.find((r) => r.id === id); 107 + if (!t) return; 108 + t.lastUsedAt = new Date().toISOString(); 109 + this.write(file); 110 + } 111 + } 112 + 113 + function hashToken(token: string): string { 114 + return createHash("sha256").update(token).digest("hex"); 115 + } 116 + 117 + function constantTimeEqualHex(a: string, b: string): boolean { 118 + if (a.length !== b.length) return false; 119 + return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex")); 120 + }
+4 -2
src/server/index.ts
··· 8 8 import { config, isLoopback, minifluxAllowed, publicBase } from "./config.js"; 9 9 import { MinifluxClient } from "./miniflux.js"; 10 10 import { BodyCache } from "./body-cache.js"; 11 + import { DeviceTokenStore } from "./device-tokens.js"; 11 12 import { buildOAuth } from "./oauth.js"; 12 13 import { AtprotoRepo } from "./atproto.js"; 13 14 import { Syncer } from "./sync.js"; ··· 17 18 18 19 const mf = new MinifluxClient(config.miniflux.url, config.miniflux.token); 19 20 const bodies = new BodyCache(); 21 + const deviceTokens = new DeviceTokenStore(config.dataDir); 20 22 const oauth = await buildOAuth(); 21 23 22 24 const app = new Hono(); ··· 32 34 }); 33 35 } 34 36 35 - app.route("/auth", authRoutes(oauth)); 37 + app.route("/auth", authRoutes(oauth, deviceTokens)); 36 38 app.route("/api", apiRoutes(oauth, mf)); 37 - app.route("/device", deviceRoutes(oauth, mf, bodies)); 39 + app.route("/device", deviceRoutes(oauth, mf, bodies, deviceTokens)); 38 40 39 41 const packageRoot = resolve(import.meta.dirname, "../.."); 40 42 const publicDir = resolve(packageRoot, "dist/public");
+38 -7
src/server/routes-auth.ts
··· 1 - import { Hono } from "hono"; 1 + import { Hono, type Context } from "hono"; 2 2 import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 3 3 import type { NightshadeOAuth } from "./oauth.js"; 4 4 import { ··· 8 8 deleteBrowserSession, 9 9 getBrowserSession, 10 10 } from "./browser-sessions.js"; 11 + import type { DeviceTokenStore } from "./device-tokens.js"; 11 12 import { isLoopback } from "./config.js"; 12 13 13 - export function authRoutes(oauth: NightshadeOAuth) { 14 + export function authRoutes(oauth: NightshadeOAuth, tokens: DeviceTokenStore) { 14 15 const app = new Hono(); 15 16 16 - app.get("/status", async (c) => { 17 + async function requireDid(c: Context): Promise<string | null> { 17 18 const id = getCookie(c, BROWSER_SESSION_COOKIE); 18 - if (!id) return c.json({ authenticated: false }); 19 + if (!id) return null; 19 20 const bs = getBrowserSession(id); 20 21 if (!bs) { 21 22 deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 22 - return c.json({ authenticated: false }); 23 + return null; 23 24 } 24 25 const session = await oauth.getSessionForDid(bs.did); 25 26 if (!session) { 26 27 deleteBrowserSession(id); 27 28 deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 28 - return c.json({ authenticated: false }); 29 + return null; 29 30 } 30 - return c.json({ authenticated: true, did: session.did }); 31 + return session.did; 32 + } 33 + 34 + app.get("/status", async (c) => { 35 + const did = await requireDid(c); 36 + if (!did) return c.json({ authenticated: false }); 37 + return c.json({ authenticated: true, did }); 31 38 }); 32 39 33 40 app.post("/login", async (c) => { ··· 63 70 const id = getCookie(c, BROWSER_SESSION_COOKIE); 64 71 if (id) deleteBrowserSession(id); 65 72 deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 73 + return c.body(null, 204); 74 + }); 75 + 76 + app.get("/device-tokens", async (c) => { 77 + const did = await requireDid(c); 78 + if (!did) return c.json({ error: "not authenticated" }, 401); 79 + return c.json(tokens.list(did)); 80 + }); 81 + 82 + app.post("/device-tokens", async (c) => { 83 + const did = await requireDid(c); 84 + if (!did) return c.json({ error: "not authenticated" }, 401); 85 + const { label } = await c.req 86 + .json<{ label?: string }>() 87 + .catch(() => ({ label: "" })); 88 + const { record, token } = tokens.create(did, label ?? ""); 89 + return c.json({ ...record, token }); 90 + }); 91 + 92 + app.delete("/device-tokens/:id", async (c) => { 93 + const did = await requireDid(c); 94 + if (!did) return c.json({ error: "not authenticated" }, 401); 95 + const ok = tokens.revoke(did, c.req.param("id")); 96 + if (!ok) return c.json({ error: "not found" }, 404); 66 97 return c.body(null, 204); 67 98 }); 68 99
+34 -23
src/server/routes-device.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { parseHTML } from "linkedom"; 3 - import type { AtprotoRepo } from "./atproto.js"; 3 + import { AtprotoRepo } from "./atproto.js"; 4 4 import type { BodyCache } from "./body-cache.js"; 5 5 import type { MinifluxClient } from "./miniflux.js"; 6 6 import type { NightshadeOAuth } from "./oauth.js"; 7 + import type { DeviceTokenStore } from "./device-tokens.js"; 7 8 import { htmlToText } from "./readability.js"; 8 9 import { renderItem, renderList } from "./reader-format.js"; 9 10 import { minifluxAllowed } from "./config.js"; 10 11 import type { UnifiedItem } from "../shared/types.js"; 11 12 13 + type Env = { Variables: { repo: AtprotoRepo } }; 14 + 12 15 export function deviceRoutes( 13 16 oauth: NightshadeOAuth, 14 17 mf: MinifluxClient, 15 18 bodies: BodyCache, 19 + tokens: DeviceTokenStore, 16 20 ) { 17 - const app = new Hono(); 21 + const app = new Hono<Env>(); 18 22 19 - async function getRepo(): Promise<AtprotoRepo | null> { 20 - // Device/e-reader endpoints have no cookie. For now, only auto-resolve 21 - // a session when exactly one DID is stored (single-user deployment). 22 - // Multi-user device access should be added via explicit device tokens. 23 - const dids = oauth.listDids(); 24 - if (dids.length !== 1) return null; 25 - const session = await oauth.getSessionForDid(dids[0]!); 26 - if (!session) return null; 27 - const { AtprotoRepo } = await import("./atproto.js"); 28 - return new AtprotoRepo(session); 29 - } 23 + app.use("/*", async (c, next) => { 24 + const token = extractToken(c.req.raw); 25 + if (!token) return c.text("missing token\n", 401); 26 + const record = tokens.verify(token); 27 + if (!record) return c.text("invalid token\n", 401); 28 + const session = await oauth.getSessionForDid(record.did); 29 + if (!session) return c.text("session expired; re-auth on server\n", 401); 30 + tokens.touch(record.id); 31 + c.set("repo", new AtprotoRepo(session)); 32 + return next(); 33 + }); 30 34 31 35 app.get("/list", async (c) => { 32 - const repo = await getRepo(); 33 - if (!repo) return c.text("not authenticated\n", 401); 36 + const repo = c.get("repo"); 34 37 const all = c.req.query("all") !== undefined; 35 38 const limit = Number(c.req.query("limit") ?? 50); 36 39 ··· 76 79 }); 77 80 78 81 app.get("/item/:id", async (c) => { 82 + const repo = c.get("repo"); 79 83 const rawId = c.req.param("id"); 80 84 const page = Number(c.req.query("page") ?? 1); 81 85 try { 82 86 if (rawId.startsWith("s")) { 83 - const repo = await getRepo(); 84 - if (!repo) return c.text("not authenticated\n", 401); 85 87 const rkey = rawId.slice(1); 86 88 const save = await repo.getSave(rkey); 87 89 if (!save) return c.text("not found\n", 404); ··· 100 102 { "Content-Type": "text/plain; charset=utf-8" }, 101 103 ); 102 104 } 103 - const repo = await getRepo(); 104 - if (!repo || !minifluxAllowed(repo.did)) 105 + if (!minifluxAllowed(repo.did)) 105 106 return c.text("not allowed\n", 403); 106 107 const entry = await mf.getEntry(Number(rawId)); 107 108 const body = entry.content ··· 119 120 }); 120 121 121 122 app.post("/item/:id/read", async (c) => { 123 + const repo = c.get("repo"); 122 124 const rawId = c.req.param("id"); 123 125 if (rawId.startsWith("s")) { 124 - const repo = await getRepo(); 125 - if (!repo) return c.text("not authenticated\n", 401); 126 126 await repo.markSaveRead(rawId.slice(1), true); 127 127 } else { 128 - const repo = await getRepo(); 129 - if (!repo || !minifluxAllowed(repo.did)) 128 + if (!minifluxAllowed(repo.did)) 130 129 return c.text("not allowed\n", 403); 131 130 await mf.markEntries([Number(rawId)], "read"); 132 131 } ··· 134 133 }); 135 134 136 135 return app; 136 + } 137 + 138 + function extractToken(req: Request): string | null { 139 + const auth = req.headers.get("authorization"); 140 + if (auth) { 141 + const m = auth.match(/^Bearer\s+(.+)$/i); 142 + if (m) return m[1]!.trim(); 143 + } 144 + const url = new URL(req.url); 145 + const q = url.searchParams.get("token"); 146 + if (q) return q.trim(); 147 + return null; 137 148 } 138 149 139 150 function extractFromStoredHtml(html: string): string {
+147 -3
src/web/App.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 - import { api, type AuthStatus } from "./api.js"; 2 + import { 3 + api, 4 + type AuthStatus, 5 + type CreatedDeviceToken, 6 + type DeviceToken, 7 + } from "./api.js"; 3 8 import type { FeedView, SaveView } from "../shared/lexicons.js"; 4 9 5 - type Tab = "saves" | "feeds"; 10 + type Tab = "saves" | "feeds" | "devices"; 6 11 7 12 export function App() { 8 13 const [tab, setTab] = useState<Tab>("saves"); ··· 41 46 Feeds 42 47 </button> 43 48 <button 49 + class={tab === "devices" ? "active" : ""} 50 + onClick={() => setTab("devices")} 51 + > 52 + Devices 53 + </button> 54 + <button 44 55 class="ghost" 45 56 title={`Signed in: ${auth.did}`} 46 57 onClick={async () => { ··· 52 63 </button> 53 64 </nav> 54 65 </header> 55 - {tab === "saves" ? <SavesView /> : <FeedsView />} 66 + {tab === "saves" && <SavesView />} 67 + {tab === "feeds" && <FeedsView />} 68 + {tab === "devices" && <DevicesView />} 56 69 </> 57 70 ); 58 71 } ··· 387 400 </div> 388 401 <div class="actions"> 389 402 <button class="danger" onClick={remove} title="Unsubscribe"> 403 + 404 + </button> 405 + </div> 406 + </li> 407 + ); 408 + } 409 + 410 + function DevicesView() { 411 + const { data, err, loading, reload } = useAsync<DeviceToken[]>( 412 + () => api.listDeviceTokens(), 413 + [], 414 + ); 415 + const [justCreated, setJustCreated] = useState<CreatedDeviceToken | null>( 416 + null, 417 + ); 418 + 419 + const active = (data ?? []).filter((t) => !t.revokedAt); 420 + 421 + return ( 422 + <> 423 + <CreateDeviceToken 424 + onCreated={(t) => { 425 + setJustCreated(t); 426 + reload(); 427 + }} 428 + /> 429 + {justCreated && ( 430 + <div class="new-token"> 431 + <p> 432 + New token for <strong>{justCreated.label}</strong>. Save this now — 433 + it won't be shown again. 434 + </p> 435 + <pre>{justCreated.token}</pre> 436 + <button class="ghost" onClick={() => setJustCreated(null)}> 437 + dismiss 438 + </button> 439 + </div> 440 + )} 441 + <div class="toolbar"> 442 + <div class="filters"> 443 + {data ? `${active.length} active` : ""} 444 + </div> 445 + <button class="ghost" onClick={reload} disabled={loading}> 446 + {loading ? "…" : "reload"} 447 + </button> 448 + </div> 449 + {err && <div class="error">{err}</div>} 450 + {active.length === 0 && data && ( 451 + <div class="empty">No device tokens yet.</div> 452 + )} 453 + {active.length > 0 && ( 454 + <ul class="list"> 455 + {active.map((t) => ( 456 + <DeviceTokenRow key={t.id} view={t} onChange={reload} /> 457 + ))} 458 + </ul> 459 + )} 460 + </> 461 + ); 462 + } 463 + 464 + function CreateDeviceToken({ 465 + onCreated, 466 + }: { 467 + onCreated: (t: CreatedDeviceToken) => void; 468 + }) { 469 + const [label, setLabel] = useState(""); 470 + const [busy, setBusy] = useState(false); 471 + const [err, setErr] = useState<string | null>(null); 472 + 473 + const submit = async (e: Event) => { 474 + e.preventDefault(); 475 + if (!label.trim()) return; 476 + setBusy(true); 477 + setErr(null); 478 + try { 479 + const created = await api.createDeviceToken(label.trim()); 480 + setLabel(""); 481 + onCreated(created); 482 + } catch (e) { 483 + setErr((e as Error).message); 484 + } finally { 485 + setBusy(false); 486 + } 487 + }; 488 + 489 + return ( 490 + <> 491 + <form class="add" onSubmit={submit}> 492 + <input 493 + type="text" 494 + placeholder="kobo-clara" 495 + value={label} 496 + onInput={(e) => setLabel((e.target as HTMLInputElement).value)} 497 + required 498 + disabled={busy} 499 + /> 500 + <button class="primary" type="submit" disabled={busy}> 501 + {busy ? "…" : "create token"} 502 + </button> 503 + </form> 504 + {err && <div class="error">{err}</div>} 505 + </> 506 + ); 507 + } 508 + 509 + function DeviceTokenRow({ 510 + view, 511 + onChange, 512 + }: { 513 + view: DeviceToken; 514 + onChange: () => void; 515 + }) { 516 + const revoke = async () => { 517 + if (!confirm(`Revoke token "${view.label}"?`)) return; 518 + await api.revokeDeviceToken(view.id); 519 + onChange(); 520 + }; 521 + const lastUsed = view.lastUsedAt 522 + ? `last used ${formatDate(view.lastUsedAt)}` 523 + : "never used"; 524 + return ( 525 + <li> 526 + <div> 527 + <div class="item-title">{view.label}</div> 528 + <div class="item-meta"> 529 + {view.prefix}… · created {formatDate(view.createdAt)} · {lastUsed} 530 + </div> 531 + </div> 532 + <div class="actions"> 533 + <button class="danger" onClick={revoke} title="Revoke"> 390 534 391 535 </button> 392 536 </div>
+23
src/web/api.ts
··· 30 30 | { authenticated: false } 31 31 | { authenticated: true; did: string }; 32 32 33 + export type DeviceToken = { 34 + id: string; 35 + did: string; 36 + label: string; 37 + prefix: string; 38 + createdAt: string; 39 + lastUsedAt: string | null; 40 + revokedAt: string | null; 41 + }; 42 + 43 + export type CreatedDeviceToken = DeviceToken & { token: string }; 44 + 33 45 export const api = { 34 46 authStatus: (): Promise<AuthStatus> => req("/auth/status"), 35 47 ··· 70 82 71 83 syncNow: (): Promise<{ added: number; removed: number }> => 72 84 req("/api/sync", { method: "POST" }), 85 + 86 + listDeviceTokens: (): Promise<DeviceToken[]> => req("/auth/device-tokens"), 87 + 88 + createDeviceToken: (label: string): Promise<CreatedDeviceToken> => 89 + req("/auth/device-tokens", { 90 + method: "POST", 91 + body: JSON.stringify({ label }), 92 + }), 93 + 94 + revokeDeviceToken: (id: string): Promise<void> => 95 + req(`/auth/device-tokens/${id}`, { method: "DELETE" }), 73 96 };
+17
src/web/styles.css
··· 206 206 color: var(--muted); 207 207 padding: 1rem 0; 208 208 } 209 + 210 + .new-token { 211 + background: rgba(180, 140, 40, 0.1); 212 + border-left: 3px solid #b48c28; 213 + padding: 0.75rem 1rem; 214 + border-radius: 3px; 215 + margin-bottom: 1rem; 216 + } 217 + 218 + .new-token pre { 219 + background: rgba(0, 0, 0, 0.25); 220 + padding: 0.5rem 0.75rem; 221 + border-radius: 3px; 222 + overflow-x: auto; 223 + user-select: all; 224 + font-size: 0.9rem; 225 + }