diff --git a/.gitignore b/.gitignore index dc323e4..4fed061 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Finder (MacOS) folder config .DS_Store +# SQLite data +data/ + # Bun lockfile - keep but binary cache bun.lockb plans/ diff --git a/packages/server/.env.example b/packages/server/.env.example index a0fab1a..c009c03 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -1,7 +1,7 @@ CLIENT_URL=https://your-domain.com CLIENT_NAME=Sequoia PORT=3000 -REDIS_URL=redis://redis:6379 +DATABASE_PATH=./data/sequoia.db # Theme overrides (optional) # THEME_ACCENT_COLOR=#3A5A40 diff --git a/packages/server/docker-compose.yml b/packages/server/docker-compose.yml index 2a60d47..991d4b6 100644 --- a/packages/server/docker-compose.yml +++ b/packages/server/docker-compose.yml @@ -7,7 +7,7 @@ services: - CLIENT_URL=${CLIENT_URL} - CLIENT_NAME=${CLIENT_NAME:-Sequoia} - PORT=${PORT:-3000} - - REDIS_URL=redis://redis:6379 + - DATABASE_PATH=${DATABASE_PATH:-/app/data/sequoia.db} - THEME_ACCENT_COLOR=${THEME_ACCENT_COLOR:-} - THEME_BG_COLOR=${THEME_BG_COLOR:-} - THEME_FG_COLOR=${THEME_FG_COLOR:-} @@ -20,13 +20,8 @@ services: - THEME_DARK_BORDER_COLOR=${THEME_DARK_BORDER_COLOR:-} - THEME_DARK_ERROR_COLOR=${THEME_DARK_ERROR_COLOR:-} - THEME_CSS_PATH=${THEME_CSS_PATH:-} - depends_on: - - redis - - redis: - image: redis:7 volumes: - - redis-data:/data + - sequoia-data:/app/data volumes: - redis-data: + sequoia-data: diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index d4aedba..bb67c82 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -2,7 +2,7 @@ export interface Env { CLIENT_URL: string; CLIENT_NAME: string; PORT: number; - REDIS_URL: string; + DATABASE_PATH: string; } export function loadEnv(): Env { @@ -15,6 +15,6 @@ export function loadEnv(): Env { CLIENT_URL: CLIENT_URL.replace(/\/+$/, ""), CLIENT_NAME: process.env.CLIENT_NAME || "Sequoia", PORT: Number(process.env.PORT) || 3000, - REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379", + DATABASE_PATH: process.env.DATABASE_PATH || "./data/sequoia.db", }; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 723c8cb..5e8a907 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,23 +1,23 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; -import { RedisClient } from "bun"; import { loadEnv } from "./env"; import type { Env } from "./env"; +import { openDatabase } from "./lib/db"; import auth from "./routes/auth"; import subscribe from "./routes/subscribe"; const env = loadEnv(); -const redis = new RedisClient(env.REDIS_URL); +const db = openDatabase(env.DATABASE_PATH); -type Variables = { env: Env; redis: typeof redis }; +type Variables = { env: Env; db: typeof db }; const app = new Hono<{ Variables: Variables }>(); -// Inject env and redis into all routes +// Inject env and db into all routes app.use("*", async (c, next) => { c.set("env", env); - c.set("redis", redis); + c.set("db", db); await next(); }); diff --git a/packages/server/src/lib/db.ts b/packages/server/src/lib/db.ts new file mode 100644 index 0000000..ec7c58b --- /dev/null +++ b/packages/server/src/lib/db.ts @@ -0,0 +1,53 @@ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export function openDatabase(path: string): Database { + mkdirSync(dirname(path), { recursive: true }); + + const db = new Database(path); + db.run("PRAGMA journal_mode = WAL"); + db.run(` + CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at INTEGER + ) + `); + return db; +} + +export function kvGet(db: Database, key: string): string | undefined { + const row = db + .query<{ value: string; expires_at: number | null }, [string]>( + "SELECT value, expires_at FROM kv WHERE key = ?", + ) + .get(key); + + if (!row) return undefined; + + if (row.expires_at !== null && row.expires_at <= Date.now()) { + db.run("DELETE FROM kv WHERE key = ?", [key]); + return undefined; + } + + return row.value; +} + +export function kvSet( + db: Database, + key: string, + value: string, + ttlSeconds?: number, +): void { + const expiresAt = + ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null; + db.run( + "INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", + [key, value, expiresAt], + ); +} + +export function kvDel(db: Database, key: string): void { + db.run("DELETE FROM kv WHERE key = ?", [key]); +} diff --git a/packages/server/src/lib/oauth-client.ts b/packages/server/src/lib/oauth-client.ts index ed2a351..3ff41a0 100644 --- a/packages/server/src/lib/oauth-client.ts +++ b/packages/server/src/lib/oauth-client.ts @@ -1,14 +1,14 @@ import { JoseKey } from "@atproto/jwk-jose"; import { OAuthClient } from "@atproto/oauth-client"; import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; -import type { RedisClient } from "bun"; -import { createStateStore, createSessionStore } from "./redis-stores"; +import type { Database } from "bun:sqlite"; +import { createStateStore, createSessionStore } from "./stores"; export const OAUTH_SCOPE = "atproto repo:site.standard.graph.subscription?action=create&action=delete"; export function createOAuthClient( - redis: RedisClient, + db: Database, clientUrl: string, clientName = "Sequoia", ) { @@ -47,7 +47,7 @@ export function createOAuthClient( }, requestLock: (_name: string, fn: () => T | PromiseLike) => fn(), }, - stateStore: createStateStore(redis), - sessionStore: createSessionStore(redis), + stateStore: createStateStore(db), + sessionStore: createSessionStore(db), }); } diff --git a/packages/server/src/lib/redis-stores.ts b/packages/server/src/lib/stores.ts similarity index 72% rename from packages/server/src/lib/redis-stores.ts rename to packages/server/src/lib/stores.ts index e7f61c2..04b4d19 100644 --- a/packages/server/src/lib/redis-stores.ts +++ b/packages/server/src/lib/stores.ts @@ -5,7 +5,8 @@ import type { SessionStore, StateStore, } from "@atproto/oauth-client"; -import { RedisClient } from "bun"; +import type { Database } from "bun:sqlite"; +import { kvGet, kvSet, kvDel } from "./db"; type SerializedStateData = Omit & { dpopJwk: Record; @@ -25,32 +26,30 @@ async function deserializeKey(jwk: Record): Promise { return JoseKey.fromJWK(jwk) as unknown as Key; } -export function createStateStore(redis: RedisClient, ttl = 600): StateStore { +export function createStateStore(db: Database, ttl = 600): StateStore { return { async set(key, { dpopKey, ...rest }) { const data: SerializedStateData = { ...rest, dpopJwk: serializeKey(dpopKey), }; - const redisKey = `oauth_state:${key}`; - await redis.set(redisKey, JSON.stringify(data)); - await redis.expire(redisKey, ttl); + kvSet(db, `oauth_state:${key}`, JSON.stringify(data), ttl); }, async get(key) { - const raw = await redis.get(`oauth_state:${key}`); + const raw = kvGet(db, `oauth_state:${key}`); if (!raw) return undefined; const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw); const dpopKey = await deserializeKey(dpopJwk); return { ...rest, dpopKey }; }, async del(key) { - await redis.del(`oauth_state:${key}`); + kvDel(db, `oauth_state:${key}`); }, }; } export function createSessionStore( - redis: RedisClient, + db: Database, ttl = 60 * 60 * 24 * 14, ): SessionStore { return { @@ -59,19 +58,17 @@ export function createSessionStore( ...rest, dpopJwk: serializeKey(dpopKey), }; - const redisKey = `oauth_session:${sub}`; - await redis.set(redisKey, JSON.stringify(data)); - await redis.expire(redisKey, ttl); + kvSet(db, `oauth_session:${sub}`, JSON.stringify(data), ttl); }, async get(sub) { - const raw = await redis.get(`oauth_session:${sub}`); + const raw = kvGet(db, `oauth_session:${sub}`); if (!raw) return undefined; const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw); const dpopKey = await deserializeKey(dpopJwk); return { ...rest, dpopKey }; }, async del(sub) { - await redis.del(`oauth_session:${sub}`); + kvDel(db, `oauth_session:${sub}`); }, }; } diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index c77db30..04f4573 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; -import type { RedisClient } from "bun"; +import type { Database } from "bun:sqlite"; import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; +import { kvGet, kvSet, kvDel } from "../lib/db"; import { getSessionDid, setSessionCookie, @@ -10,7 +11,7 @@ import { } from "../lib/session"; import type { Env } from "../env"; -type Variables = { env: Env; redis: RedisClient }; +type Variables = { env: Env; db: Database }; const auth = new Hono<{ Variables: Variables }>(); @@ -37,7 +38,7 @@ auth.get("/client-metadata.json", (c) => { // Start OAuth login flow auth.get("/login", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); try { const handle = c.req.query("handle"); @@ -45,7 +46,7 @@ auth.get("/login", async (c) => { return c.redirect(`${env.CLIENT_URL}/?error=missing_handle`); } - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const authUrl = await client.authorize(handle, { scope: OAUTH_SCOPE, }); @@ -60,7 +61,7 @@ auth.get("/login", async (c) => { // OAuth callback handler auth.get("/callback", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); try { const params = new URLSearchParams(c.req.url.split("?")[1] || ""); @@ -73,7 +74,7 @@ auth.get("/callback", async (c) => { ); } - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const { session } = await client.callback(params); // Resolve handle from DID @@ -85,11 +86,9 @@ auth.get("/callback", async (c) => { // Handle resolution is best-effort } - // Store handle in Redis alongside the session for quick lookup + // Store handle alongside the session for quick lookup if (handle) { - const key = `oauth_handle:${session.did}`; - await redis.set(key, handle); - await redis.expire(key, 60 * 60 * 24 * 14); + kvSet(db, `oauth_handle:${session.did}`, handle, 60 * 60 * 24 * 14); } setSessionCookie(c, session.did, env.CLIENT_URL); @@ -108,17 +107,17 @@ auth.get("/callback", async (c) => { // Logout endpoint auth.post("/logout", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); const did = getSessionDid(c); if (did) { try { - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); await client.revoke(did); } catch (error) { console.error("Revoke error:", error); } - await redis.del(`oauth_handle:${did}`); + kvDel(db, `oauth_handle:${did}`); } clearSessionCookie(c, env.CLIENT_URL); @@ -128,7 +127,7 @@ auth.post("/logout", async (c) => { // Check auth status auth.get("/status", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); const did = getSessionDid(c); if (!did) { @@ -136,10 +135,10 @@ auth.get("/status", async (c) => { } try { - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const session = await client.restore(did); - const handle = await redis.get(`oauth_handle:${session.did}`); + const handle = kvGet(db, `oauth_handle:${session.did}`); return c.json({ authenticated: true, diff --git a/packages/server/src/routes/subscribe.ts b/packages/server/src/routes/subscribe.ts index 9b1605d..a6be007 100644 --- a/packages/server/src/routes/subscribe.ts +++ b/packages/server/src/routes/subscribe.ts @@ -1,12 +1,12 @@ import { Agent } from "@atproto/api"; import { Hono } from "hono"; -import type { RedisClient } from "bun"; +import type { Database } from "bun:sqlite"; import { createOAuthClient } from "../lib/oauth-client"; import { getSessionDid, setReturnToCookie } from "../lib/session"; import { page, escapeHtml } from "../lib/theme"; import type { Env } from "../env"; -type Variables = { env: Env; redis: RedisClient }; +type Variables = { env: Env; db: Database }; const subscribe = new Hono<{ Variables: Variables }>(); @@ -66,7 +66,7 @@ async function findExistingSubscription( subscribe.post("/", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); let publicationUri: string; try { @@ -87,7 +87,7 @@ subscribe.post("/", async (c) => { } try { - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const session = await client.restore(did); const agent = new Agent(session); @@ -131,7 +131,7 @@ subscribe.post("/", async (c) => { subscribe.get("/", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); const publicationUri = c.req.query("publicationUri"); const action = c.req.query("action"); @@ -157,7 +157,7 @@ subscribe.get("/", async (c) => { } try { - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const session = await client.restore(did); const agent = new Agent(session); @@ -256,7 +256,7 @@ subscribe.get("/", async (c) => { subscribe.get("/check", async (c) => { const env = c.get("env"); - const redis = c.get("redis"); + const db = c.get("db"); const publicationUri = c.req.query("publicationUri"); @@ -270,7 +270,7 @@ subscribe.get("/check", async (c) => { } try { - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); const session = await client.restore(did); const agent = new Agent(session); const recordUri = await findExistingSubscription(