diff --git a/src/auth/client.ts b/src/auth/client.ts index f00c4d3..372513a 100644 --- a/src/auth/client.ts +++ b/src/auth/client.ts @@ -1,6 +1,6 @@ import { NodeOAuthClient } from '@atproto/oauth-client-node' import type { Database } from '#/db' -import { env } from '#/env' +import { env } from '#/lib/env' import { SessionStore, StateStore } from './storage' export const createClient = async (db: Database) => { diff --git a/src/auth/session.ts b/src/auth/session.ts index 4203796..632479e 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,7 +1,7 @@ import assert from 'node:assert' import type { IncomingMessage, ServerResponse } from 'node:http' import { getIronSession } from 'iron-session' -import { env } from '#/env' +import { env } from '#/lib/env' import { AppContext } from '#/index' export type Session = { did: string } diff --git a/src/index.ts b/src/index.ts index e2c707a..ada96ff 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { pino } from 'pino' import type { OAuthClient } from '@atproto/oauth-client-node' import { createDb, migrateToLatest } from '#/db' -import { env } from '#/env' +import { env } from '#/lib/env' import { Ingester } from '#/firehose/ingester' import { createRouter } from '#/routes' import { createClient } from '#/auth/client' diff --git a/src/env.ts b/src/lib/env.ts similarity index 100% rename from src/env.ts rename to src/lib/env.ts diff --git a/src/view.ts b/src/lib/view.ts similarity index 100% rename from src/view.ts rename to src/lib/view.ts diff --git a/src/pages/home.ts b/src/pages/home.ts index 8dcbcce..cb6144f 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -1,6 +1,5 @@ -import { AtUri } from '@atproto/syntax' import type { Status } from '#/db/schema' -import { html } from '../view' +import { html } from '../lib/view' import { shell } from './shell' const TODAY = new Date().toDateString() diff --git a/src/pages/login.ts b/src/pages/login.ts index ee3dd06..55cb166 100644 --- a/src/pages/login.ts +++ b/src/pages/login.ts @@ -1,4 +1,4 @@ -import { html } from '../view' +import { html } from '../lib/view' import { shell } from './shell' type Props = { error?: string } diff --git a/src/pages/shell.ts b/src/pages/shell.ts index d38df6b..ddb94aa 100644 --- a/src/pages/shell.ts +++ b/src/pages/shell.ts @@ -1,4 +1,4 @@ -import { type Hole, html } from '../view' +import { type Hole, html } from '../lib/view' export function shell({ title, content }: { title: string; content: Hole }) { return html` diff --git a/src/routes.ts b/src/routes.ts index ed63fb0..4f88bcc 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -6,7 +6,7 @@ import { createSession, destroySession, getSessionAgent } from '#/auth/session' import type { AppContext } from '#/index' import { home } from '#/pages/home' import { login } from '#/pages/login' -import { page } from '#/view' +import { page } from '#/lib/view' import * as Status from '#/lexicon/types/com/example/status' const handler = -- 2.54.0 From 087d9ca0b4627930054b497cc95acb7ae934bdb2 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 13 Aug 2024 16:54:47 -0700 Subject: [PATCH 2/3] Fix: handle firehose update events --- src/firehose/firehose.ts | 7 ++++++- src/firehose/ingester.ts | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/firehose/firehose.ts b/src/firehose/firehose.ts index ca1f172..7fa0446 100644 --- a/src/firehose/firehose.ts +++ b/src/firehose/firehose.ts @@ -57,7 +57,10 @@ export class Firehose { if (isCommit(evt) && !this.opts.excludeCommit) { const parsed = await parseCommit(evt) for (const write of parsed) { - if (!this.opts.filterCollections || this.opts.filterCollections.includes(write.uri.collection)) { + if ( + !this.opts.filterCollections || + this.opts.filterCollections.includes(write.uri.collection) + ) { yield write } } @@ -167,6 +170,8 @@ type Create = CommitMeta & { type Update = CommitMeta & { event: 'update' + record: RepoRecord + cid: CID } type Delete = CommitMeta & { diff --git a/src/firehose/ingester.ts b/src/firehose/ingester.ts index 433f809..104841b 100644 --- a/src/firehose/ingester.ts +++ b/src/firehose/ingester.ts @@ -10,13 +10,17 @@ export class Ingester { const firehose = new Firehose({}) for await (const evt of firehose.run()) { - if (evt.event === 'create') { + // Watch for write events + if (evt.event === 'create' || evt.event === 'update') { const record = evt.record + + // If the write is a valid status update if ( evt.collection === 'com.example.status' && Status.isRecord(record) && Status.validateRecord(record).success ) { + // Store the status in our SQLite await this.db .insertInto('status') .values({ @@ -25,7 +29,13 @@ export class Ingester { updatedAt: record.updatedAt, indexedAt: new Date().toISOString(), }) - .onConflict((oc) => oc.doNothing()) + .onConflict((oc) => + oc.column('authorDid').doUpdateSet({ + status: record.status, + updatedAt: record.updatedAt, + indexedAt: new Date().toISOString(), + }) + ) .execute() } } -- 2.54.0 From 928fb954fe6d4cdc6eec3ab372ce478b122d5830 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 13 Aug 2024 16:54:53 -0700 Subject: [PATCH 3/3] Add comments --- src/index.ts | 24 +++++++++++++----------- src/routes.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index ada96ff..4829cbc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { createClient } from '#/auth/client' import { createResolver, Resolver } from '#/firehose/resolver' import type { Database } from '#/db' +// Application state passed to the router and elsewhere export type AppContext = { db: Database ingester: Ingester @@ -29,14 +30,16 @@ export class Server { static async create() { const { NODE_ENV, HOST, PORT, DB_PATH } = env - const logger = pino({ name: 'server start' }) + + // Set up the SQLite database const db = createDb(DB_PATH) await migrateToLatest(db) - const ingester = new Ingester(db) + + // Create the atproto utilities const oauthClient = await createClient(db) + const ingester = new Ingester(db) const resolver = createResolver() - ingester.start() const ctx = { db, ingester, @@ -45,22 +48,21 @@ export class Server { resolver, } - const app: Express = express() + // Subscribe to events on the firehose + ingester.start() - // Set the application to trust the reverse proxy + // Create our server + const app: Express = express() app.set('trust proxy', true) - // Middlewares + // Routes & middlewares + const router = createRouter(ctx) app.use(express.json()) app.use(express.urlencoded({ extended: true })) - - // Routes - const router = createRouter(ctx) app.use(router) - - // Error handlers app.use((_req, res) => res.sendStatus(404)) + // Bind our server to the port const server = app.listen(env.PORT) await events.once(server, 'listening') logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`) diff --git a/src/routes.ts b/src/routes.ts index 4f88bcc..6d70daa 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -9,6 +9,7 @@ import { login } from '#/pages/login' import { page } from '#/lib/view' import * as Status from '#/lexicon/types/com/example/status' +// Helper function for defining routes const handler = (fn: express.Handler) => async ( @@ -26,8 +27,10 @@ const handler = export const createRouter = (ctx: AppContext) => { const router = express.Router() + // Static assets router.use('/public', express.static(path.join(__dirname, 'pages', 'public'))) + // OAuth metadata router.get( '/client-metadata.json', handler((_req, res) => { @@ -35,6 +38,7 @@ export const createRouter = (ctx: AppContext) => { }) ) + // OAuth callback to complete session creation router.get( '/oauth/callback', handler(async (req, res) => { @@ -50,6 +54,7 @@ export const createRouter = (ctx: AppContext) => { }) ) + // Login page router.get( '/login', handler(async (_req, res) => { @@ -57,13 +62,17 @@ export const createRouter = (ctx: AppContext) => { }) ) + // Login handler router.post( '/login', handler(async (req, res) => { + // Validate const handle = req.body?.handle if (typeof handle !== 'string' || !isValidHandle(handle)) { return res.type('html').send(page(login({ error: 'invalid handle' }))) } + + // Initiate the OAuth flow try { const url = await ctx.oauthClient.authorize(handle) return res.redirect(url.toString()) @@ -83,6 +92,7 @@ export const createRouter = (ctx: AppContext) => { }) ) + // Logout handler router.post( '/logout', handler(async (req, res) => { @@ -91,10 +101,14 @@ export const createRouter = (ctx: AppContext) => { }) ) + // Homepage router.get( '/', handler(async (req, res) => { + // If the user is signed in, get an agent which communicates with their server const agent = await getSessionAgent(req, res, ctx) + + // Fetch data stored in our SQLite const statuses = await ctx.db .selectFrom('status') .selectAll() @@ -108,29 +122,41 @@ export const createRouter = (ctx: AppContext) => { .where('authorDid', '=', agent.accountDid) .executeTakeFirst() : undefined + + // Map user DIDs to their domain-name handles const didHandleMap = await ctx.resolver.resolveDidsToHandles( statuses.map((s) => s.authorDid) ) + if (!agent) { + // Serve the logged-out view return res.type('html').send(page(home({ statuses, didHandleMap }))) } + + // Fetch additional information about the logged-in user const { data: profile } = await agent.getProfile({ actor: agent.accountDid, }) + didHandleMap[profile.handle] = agent.accountDid + + // Serve the logged-in view return res .type('html') .send(page(home({ statuses, didHandleMap, profile, myStatus }))) }) ) + // "Set status" handler router.post( '/status', handler(async (req, res) => { + // If the user is signed in, get an agent which communicates with their server const agent = await getSessionAgent(req, res, ctx) if (!agent) { return res.status(401).json({ error: 'Session required' }) } + // Construct & validate their status record const record = { $type: 'com.example.status', status: req.body?.status, @@ -141,6 +167,7 @@ export const createRouter = (ctx: AppContext) => { } try { + // Write the status record to the user's repository await agent.com.atproto.repo.putRecord({ repo: agent.accountDid, collection: 'com.example.status', @@ -154,6 +181,10 @@ export const createRouter = (ctx: AppContext) => { } try { + // Optimistically update our SQLite + // This isn't strictly necessary because the write event will be + // handled in #/firehose/ingestor.ts, but it ensures that future reads + // will be up-to-date after this method finishes. await ctx.db .insertInto('status') .values({