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: track oauth sessions in sqlite

+276 -71
+53
src/server/browser-sessions.ts
··· 1 + import { randomBytes } from "node:crypto"; 2 + import { db } from "./db.js"; 3 + 4 + export type BrowserSession = { 5 + id: string; 6 + did: string; 7 + createdAt: number; 8 + lastSeenAt: number; 9 + }; 10 + 11 + export const BROWSER_SESSION_COOKIE = "ns_session"; 12 + export const BROWSER_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days 13 + 14 + export function createBrowserSession(did: string): string { 15 + const id = randomBytes(32).toString("base64url"); 16 + const now = Date.now(); 17 + db() 18 + .prepare( 19 + `INSERT INTO browser_session (id, did, created_at, last_seen_at) VALUES (?, ?, ?, ?)`, 20 + ) 21 + .run(id, did, now, now); 22 + return id; 23 + } 24 + 25 + export function getBrowserSession(id: string): BrowserSession | null { 26 + const row = db() 27 + .prepare( 28 + `SELECT id, did, created_at AS createdAt, last_seen_at AS lastSeenAt 29 + FROM browser_session WHERE id = ?`, 30 + ) 31 + .get(id) as BrowserSession | undefined; 32 + if (!row) return null; 33 + const maxAgeMs = BROWSER_SESSION_MAX_AGE_SECONDS * 1000; 34 + if (Date.now() - row.lastSeenAt > maxAgeMs) { 35 + deleteBrowserSession(id); 36 + return null; 37 + } 38 + return row; 39 + } 40 + 41 + export function touchBrowserSession(id: string): void { 42 + db() 43 + .prepare(`UPDATE browser_session SET last_seen_at = ? WHERE id = ?`) 44 + .run(Date.now(), id); 45 + } 46 + 47 + export function deleteBrowserSession(id: string): void { 48 + db().prepare(`DELETE FROM browser_session WHERE id = ?`).run(id); 49 + } 50 + 51 + export function deleteBrowserSessionsForDid(did: string): void { 52 + db().prepare(`DELETE FROM browser_session WHERE did = ?`).run(did); 53 + }
+86
src/server/db.ts
··· 1 + import { DatabaseSync } from "node:sqlite"; 2 + import { 3 + existsSync, 4 + readFileSync, 5 + readdirSync, 6 + renameSync, 7 + mkdirSync, 8 + } from "node:fs"; 9 + import { resolve } from "node:path"; 10 + import { config } from "./config.js"; 11 + 12 + let instance: DatabaseSync | null = null; 13 + 14 + export function db(): DatabaseSync { 15 + if (instance) return instance; 16 + const path = resolve(config.dataDir, "nightshade.sqlite3"); 17 + mkdirSync(config.dataDir, { recursive: true }); 18 + const d = new DatabaseSync(path); 19 + d.exec("PRAGMA journal_mode = WAL"); 20 + d.exec("PRAGMA foreign_keys = ON"); 21 + d.exec(` 22 + CREATE TABLE IF NOT EXISTS oauth_state ( 23 + key TEXT PRIMARY KEY, 24 + value TEXT NOT NULL, 25 + updated_at INTEGER NOT NULL 26 + ); 27 + CREATE TABLE IF NOT EXISTS oauth_session ( 28 + did TEXT PRIMARY KEY, 29 + value TEXT NOT NULL, 30 + updated_at INTEGER NOT NULL 31 + ); 32 + CREATE TABLE IF NOT EXISTS browser_session ( 33 + id TEXT PRIMARY KEY, 34 + did TEXT NOT NULL, 35 + created_at INTEGER NOT NULL, 36 + last_seen_at INTEGER NOT NULL 37 + ); 38 + CREATE INDEX IF NOT EXISTS idx_browser_session_did ON browser_session(did); 39 + `); 40 + instance = d; 41 + migrateFileStores(d); 42 + return d; 43 + } 44 + 45 + function migrateFileStores(d: DatabaseSync): void { 46 + const legacyState = resolve(config.dataDir, "oauth-state"); 47 + const legacySessions = resolve(config.dataDir, "oauth-sessions"); 48 + migrateDir(d, legacyState, "oauth_state", "key"); 49 + migrateDir(d, legacySessions, "oauth_session", "did"); 50 + } 51 + 52 + function migrateDir( 53 + d: DatabaseSync, 54 + dir: string, 55 + table: "oauth_state" | "oauth_session", 56 + keyCol: "key" | "did", 57 + ): void { 58 + if (!existsSync(dir)) return; 59 + const files = readdirSync(dir).filter((f) => f.endsWith(".json")); 60 + if (files.length === 0) return; 61 + const insert = d.prepare( 62 + `INSERT OR REPLACE INTO ${table} (${keyCol}, value, updated_at) VALUES (?, ?, ?)`, 63 + ); 64 + const now = Date.now(); 65 + let migrated = 0; 66 + for (const f of files) { 67 + const p = resolve(dir, f); 68 + try { 69 + const value = readFileSync(p, "utf8"); 70 + JSON.parse(value); 71 + const key = decodeURIComponent(f.slice(0, -".json".length)); 72 + insert.run(key, value, now); 73 + migrated++; 74 + } catch { 75 + // skip malformed 76 + } 77 + } 78 + if (migrated > 0) { 79 + try { 80 + renameSync(dir, dir + ".migrated"); 81 + console.log(`migrated ${migrated} records from ${dir} to ${table}`); 82 + } catch { 83 + // non-fatal 84 + } 85 + } 86 + }
+13 -10
src/server/index.ts
··· 48 48 return c.html(indexHtml); 49 49 }); 50 50 51 - // Background sync every 5 minutes when a session exists. 51 + // Background sync every 5 minutes for every stored DID. 52 52 setInterval( 53 53 async () => { 54 - const session = await oauth.getActiveSession(); 55 - if (!session || !minifluxAllowed(session.did)) return; 56 - const syncer = new Syncer(config.dataDir, new AtprotoRepo(session), mf); 57 - try { 58 - const res = await syncer.run(); 59 - if (res.added || res.removed) { 60 - console.log(`sync: +${res.added} -${res.removed}`); 54 + for (const did of oauth.listDids()) { 55 + if (!minifluxAllowed(did)) continue; 56 + const session = await oauth.getSessionForDid(did); 57 + if (!session) continue; 58 + const syncer = new Syncer(config.dataDir, new AtprotoRepo(session), mf); 59 + try { 60 + const res = await syncer.run(); 61 + if (res.added || res.removed) { 62 + console.log(`sync[${did}]: +${res.added} -${res.removed}`); 63 + } 64 + } catch (e) { 65 + console.error(`sync[${did}] failed:`, e); 61 66 } 62 - } catch (e) { 63 - console.error("sync failed:", e); 64 67 } 65 68 }, 66 69 5 * 60 * 1000,
+36 -35
src/server/oauth-stores.ts
··· 1 - import { 2 - readFileSync, 3 - writeFileSync, 4 - existsSync, 5 - mkdirSync, 6 - renameSync, 7 - unlinkSync, 8 - readdirSync, 9 - } from "node:fs"; 10 - import { resolve, dirname } from "node:path"; 11 1 import type { 12 2 NodeSavedSession, 13 3 NodeSavedSessionStore, 14 4 NodeSavedState, 15 5 NodeSavedStateStore, 16 6 } from "@atproto/oauth-client-node"; 7 + import { db } from "./db.js"; 17 8 18 - class FileStore<T> { 19 - constructor(protected baseDir: string) { 20 - mkdirSync(baseDir, { recursive: true }); 21 - } 22 - 23 - protected pathFor(key: string): string { 24 - return resolve(this.baseDir, encodeURIComponent(key) + ".json"); 25 - } 9 + class SqliteStore<T> { 10 + constructor( 11 + private table: "oauth_state" | "oauth_session", 12 + private keyCol: "key" | "did", 13 + ) {} 26 14 27 15 async get(key: string): Promise<T | undefined> { 28 - const p = this.pathFor(key); 29 - if (!existsSync(p)) return undefined; 16 + const row = db() 17 + .prepare(`SELECT value FROM ${this.table} WHERE ${this.keyCol} = ?`) 18 + .get(key) as { value: string } | undefined; 19 + if (!row) return undefined; 30 20 try { 31 - return JSON.parse(readFileSync(p, "utf8")) as T; 21 + return JSON.parse(row.value) as T; 32 22 } catch { 33 23 return undefined; 34 24 } 35 25 } 36 26 37 27 async set(key: string, value: T): Promise<void> { 38 - const p = this.pathFor(key); 39 - mkdirSync(dirname(p), { recursive: true }); 40 - const tmp = p + ".tmp"; 41 - writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); 42 - renameSync(tmp, p); 28 + db() 29 + .prepare( 30 + `INSERT INTO ${this.table} (${this.keyCol}, value, updated_at) VALUES (?, ?, ?) 31 + ON CONFLICT(${this.keyCol}) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, 32 + ) 33 + .run(key, JSON.stringify(value), Date.now()); 43 34 } 44 35 45 36 async del(key: string): Promise<void> { 46 - const p = this.pathFor(key); 47 - if (existsSync(p)) unlinkSync(p); 37 + db() 38 + .prepare(`DELETE FROM ${this.table} WHERE ${this.keyCol} = ?`) 39 + .run(key); 48 40 } 49 41 } 50 42 51 43 export class StateStore 52 - extends FileStore<NodeSavedState> 53 - implements NodeSavedStateStore {} 44 + extends SqliteStore<NodeSavedState> 45 + implements NodeSavedStateStore 46 + { 47 + constructor() { 48 + super("oauth_state", "key"); 49 + } 50 + } 54 51 55 52 export class SessionStore 56 - extends FileStore<NodeSavedSession> 53 + extends SqliteStore<NodeSavedSession> 57 54 implements NodeSavedSessionStore 58 55 { 56 + constructor() { 57 + super("oauth_session", "did"); 58 + } 59 + 59 60 listKeys(): string[] { 60 - if (!existsSync(this.baseDir)) return []; 61 - return readdirSync(this.baseDir) 62 - .filter((f) => f.endsWith(".json")) 63 - .map((f) => decodeURIComponent(f.slice(0, -".json".length))); 61 + const rows = db() 62 + .prepare("SELECT did FROM oauth_session") 63 + .all() as Array<{ did: string }>; 64 + return rows.map((r) => r.did); 64 65 } 65 66 }
+25 -18
src/server/oauth.ts
··· 1 - import { resolve } from "node:path"; 2 1 import { NodeOAuthClient, type OAuthSession } from "@atproto/oauth-client-node"; 3 2 import { config, isLoopback, publicBase } from "./config.js"; 4 3 import { SessionStore, StateStore } from "./oauth-stores.js"; 4 + import { deleteBrowserSessionsForDid } from "./browser-sessions.js"; 5 5 import { FEED_NSID, SAVE_NSID } from "../shared/lexicons.js"; 6 6 7 7 // Granular scopes: one per collection we read/write, plus the mandatory ··· 12 12 export type NightshadeOAuth = { 13 13 client: NodeOAuthClient; 14 14 sessionStore: SessionStore; 15 - getActiveSession: () => Promise<OAuthSession | null>; 15 + getSessionForDid: (did: string) => Promise<OAuthSession | null>; 16 + listDids: () => string[]; 16 17 authorize: (handle: string) => Promise<URL>; 17 18 callback: (params: URLSearchParams) => Promise<OAuthSession>; 18 - revoke: () => Promise<void>; 19 + revokeDid: (did: string) => Promise<void>; 19 20 }; 20 21 21 22 export async function buildOAuth(): Promise<NightshadeOAuth> { 22 - const stateStore = new StateStore(resolve(config.dataDir, "oauth-state")); 23 - const sessionStore = new SessionStore( 24 - resolve(config.dataDir, "oauth-sessions"), 25 - ); 23 + const stateStore = new StateStore(); 24 + const sessionStore = new SessionStore(); 26 25 27 26 const base = publicBase(); 28 27 const redirectUri = `${base}/auth/callback`; ··· 67 66 sessionStore, 68 67 }); 69 68 70 - async function getActiveSession(): Promise<OAuthSession | null> { 71 - const dids = sessionStore.listKeys(); 72 - if (dids.length === 0) return null; 73 - // Single-user tool: pick the most-recently-written session. 74 - // `list()` returns in directory order; for now just use the first. 75 - const did = dids[0]!; 69 + void config; // keep import for future use 70 + 71 + async function getSessionForDid(did: string): Promise<OAuthSession | null> { 76 72 try { 77 73 return await client.restore(did); 78 74 } catch { ··· 80 76 } 81 77 } 82 78 79 + function listDids(): string[] { 80 + return sessionStore.listKeys(); 81 + } 82 + 83 83 async function authorize(handle: string): Promise<URL> { 84 84 return client.authorize(handle, { scope: SCOPE }); 85 85 } ··· 89 89 return session; 90 90 } 91 91 92 - async function revoke(): Promise<void> { 93 - for (const did of sessionStore.listKeys()) { 94 - await sessionStore.del(did); 95 - } 92 + async function revokeDid(did: string): Promise<void> { 93 + await sessionStore.del(did); 94 + deleteBrowserSessionsForDid(did); 96 95 } 97 96 98 - return { client, sessionStore, getActiveSession, authorize, callback, revoke }; 97 + return { 98 + client, 99 + sessionStore, 100 + getSessionForDid, 101 + listDids, 102 + authorize, 103 + callback, 104 + revokeDid, 105 + }; 99 106 }
+21 -2
src/server/routes-api.ts
··· 1 1 import { Hono } from "hono"; 2 + import { getCookie, deleteCookie } from "hono/cookie"; 2 3 import { AtprotoRepo } from "./atproto.js"; 3 4 import type { MinifluxClient } from "./miniflux.js"; 4 5 import { Syncer } from "./sync.js"; 5 6 import { fetchAndExtract } from "./readability.js"; 6 7 import type { NightshadeOAuth } from "./oauth.js"; 7 8 import { config, minifluxAllowed } from "./config.js"; 9 + import { 10 + BROWSER_SESSION_COOKIE, 11 + deleteBrowserSession, 12 + getBrowserSession, 13 + touchBrowserSession, 14 + } from "./browser-sessions.js"; 8 15 9 16 type Env = { Variables: { repo: AtprotoRepo } }; 10 17 ··· 15 22 new Syncer(config.dataDir, repo, mf); 16 23 17 24 app.use("/*", async (c, next) => { 18 - const session = await oauth.getActiveSession(); 19 - if (!session) return c.json({ error: "not authenticated" }, 401); 25 + const id = getCookie(c, BROWSER_SESSION_COOKIE); 26 + if (!id) return c.json({ error: "not authenticated" }, 401); 27 + const bs = getBrowserSession(id); 28 + if (!bs) { 29 + deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 30 + return c.json({ error: "not authenticated" }, 401); 31 + } 32 + const session = await oauth.getSessionForDid(bs.did); 33 + if (!session) { 34 + deleteBrowserSession(id); 35 + deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 36 + return c.json({ error: "not authenticated" }, 401); 37 + } 38 + touchBrowserSession(id); 20 39 c.set("repo", new AtprotoRepo(session)); 21 40 return next(); 22 41 });
+34 -4
src/server/routes-auth.ts
··· 1 1 import { Hono } from "hono"; 2 + import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 2 3 import type { NightshadeOAuth } from "./oauth.js"; 4 + import { 5 + BROWSER_SESSION_COOKIE, 6 + BROWSER_SESSION_MAX_AGE_SECONDS, 7 + createBrowserSession, 8 + deleteBrowserSession, 9 + getBrowserSession, 10 + } from "./browser-sessions.js"; 11 + import { isLoopback } from "./config.js"; 3 12 4 13 export function authRoutes(oauth: NightshadeOAuth) { 5 14 const app = new Hono(); 6 15 7 16 app.get("/status", async (c) => { 8 - const session = await oauth.getActiveSession(); 9 - if (!session) return c.json({ authenticated: false }); 17 + const id = getCookie(c, BROWSER_SESSION_COOKIE); 18 + if (!id) return c.json({ authenticated: false }); 19 + const bs = getBrowserSession(id); 20 + if (!bs) { 21 + deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 22 + return c.json({ authenticated: false }); 23 + } 24 + const session = await oauth.getSessionForDid(bs.did); 25 + if (!session) { 26 + deleteBrowserSession(id); 27 + deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 28 + return c.json({ authenticated: false }); 29 + } 10 30 return c.json({ authenticated: true, did: session.did }); 11 31 }); 12 32 ··· 24 44 app.get("/callback", async (c) => { 25 45 const params = new URLSearchParams(c.req.url.split("?")[1] ?? ""); 26 46 try { 27 - await oauth.callback(params); 47 + const session = await oauth.callback(params); 48 + const id = createBrowserSession(session.did); 49 + setCookie(c, BROWSER_SESSION_COOKIE, id, { 50 + path: "/", 51 + httpOnly: true, 52 + sameSite: "Lax", 53 + secure: !isLoopback(), 54 + maxAge: BROWSER_SESSION_MAX_AGE_SECONDS, 55 + }); 28 56 return c.redirect("/"); 29 57 } catch (e) { 30 58 return c.text(`OAuth callback failed: ${(e as Error).message}`, 400); ··· 32 60 }); 33 61 34 62 app.post("/logout", async (c) => { 35 - await oauth.revoke(); 63 + const id = getCookie(c, BROWSER_SESSION_COOKIE); 64 + if (id) deleteBrowserSession(id); 65 + deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 36 66 return c.body(null, 204); 37 67 }); 38 68
+6 -1
src/server/routes-device.ts
··· 17 17 const app = new Hono(); 18 18 19 19 async function getRepo(): Promise<AtprotoRepo | null> { 20 - const session = await oauth.getActiveSession(); 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]!); 21 26 if (!session) return null; 22 27 const { AtprotoRepo } = await import("./atproto.js"); 23 28 return new AtprotoRepo(session);
+2 -1
src/server/sync.ts
··· 19 19 private repo: AtprotoRepo, 20 20 private mf: MinifluxClient, 21 21 ) { 22 - this.statePath = resolve(dataDir, "sync-state.json"); 22 + const safeDid = repo.did.replace(/[^a-zA-Z0-9.:-]/g, "_"); 23 + this.statePath = resolve(dataDir, `sync-state.${safeDid}.json`); 23 24 } 24 25 25 26 async run(): Promise<{ added: number; removed: number }> {