[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

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

update auth things on appview

+115 -291
-28
services/appview/src/auth/client.ts
··· 1 - import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 - import type { Database } from '../db.js' 3 - import { SessionStore, StateStore } from './storage.js' 4 - import { env } from '../env.js' 5 - 6 - export const createClient = async (db: Database) => { 7 - const publicUrl = env.PUBLIC_URL 8 - const url = publicUrl || `http://127.0.0.1:${env.PORT}` 9 - const enc = encodeURIComponent 10 - return new NodeOAuthClient({ 11 - clientMetadata: { 12 - client_name: 'AT Protocol Hono App', 13 - client_id: publicUrl 14 - ? `${url}/client-metadata.json` 15 - : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`, 16 - client_uri: url, 17 - redirect_uris: [`${url}/oauth/callback`], 18 - scope: 'atproto transition:generic', 19 - grant_types: ['authorization_code', 'refresh_token'], 20 - response_types: ['code'], 21 - application_type: 'web', 22 - token_endpoint_auth_method: 'none', 23 - dpop_bound_access_tokens: true, 24 - }, 25 - stateStore: new StateStore(db), 26 - sessionStore: new SessionStore(db), 27 - }) 28 - }
-182
services/appview/src/auth/login.ts
··· 1 - import { Hono } from 'hono' 2 - import { AppContext } from '../index' 3 - import { isValidHandle } from '@atproto/syntax' 4 - import { HTTPException } from 'hono/http-exception' 5 - import { AtpAgent } from '@atproto/api' 6 - 7 - export type Session = { 8 - did: string 9 - accessJwt: string 10 - refreshJwt?: string 11 - } 12 - 13 - // Helper function to extract session from Authorization header 14 - export const getSessionFromHeader = async (authHeader?: string): Promise<Session | null> => { 15 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 16 - return null 17 - } 18 - 19 - const accessJwt = authHeader.substring(7) // Remove 'Bearer ' prefix 20 - 21 - try { 22 - // Extract DID from the JWT without verifying signature 23 - // AT Protocol JWTs have the DID encoded in them 24 - const [, payload] = accessJwt.split('.') 25 - if (!payload) return null 26 - 27 - const decoded = JSON.parse(Buffer.from(payload, 'base64').toString()) 28 - if (!decoded.sub) return null 29 - 30 - return { 31 - did: decoded.sub, 32 - accessJwt 33 - } 34 - } catch (err) { 35 - return null 36 - } 37 - } 38 - 39 - export const createAuthRouter = (ctx: AppContext) => { 40 - const router = new Hono() 41 - 42 - router.post('/register', async (c) => { 43 - const body = await c.req.json().catch(() => ({})) 44 - const { handle, password, email, invite } = body as { 45 - handle: string 46 - password: string 47 - email: string 48 - invite?: string 49 - } 50 - 51 - if (!handle || !password || !email) { 52 - return c.json({ error: 'Handle and password are required' }, 400) 53 - } 54 - 55 - if (!isValidHandle(handle)) { 56 - return c.json({ error: 'Invalid handle' }, 400) 57 - } 58 - 59 - const agent = new AtpAgent({ 60 - service: 'https://pds.sprk.so', 61 - }) 62 - 63 - const { success, data } = await agent.com.atproto.server.createAccount({ 64 - handle, 65 - password, 66 - email, 67 - inviteCode: invite, 68 - }) 69 - 70 - if (!success || !data) { 71 - throw new Error('Registration failed') 72 - } 73 - 74 - return c.json({ success: true, did: data.did, token: data.accessJwt }) 75 - }) 76 - 77 - router.post('/login', async (c) => { 78 - const body = await c.req.json().catch(() => ({})) 79 - const { handle, password } = body as { 80 - handle: string 81 - password: string 82 - } 83 - 84 - if (!handle || !password) { 85 - return c.json({ error: 'Handle and password are required' }, 400) 86 - } 87 - 88 - if (!isValidHandle(handle)) { 89 - return c.json({ error: 'Invalid handle' }, 400) 90 - } 91 - try { 92 - const handleData = await ctx.resolver.resolveHandleToDidDoc(handle) 93 - 94 - const agent = new AtpAgent({ 95 - service: handleData.pds, 96 - }) 97 - const { data } = await agent.com.atproto.server.createSession({ 98 - identifier: handle, 99 - password, 100 - }) 101 - 102 - if (!data) { 103 - throw new Error('Login failed') 104 - } 105 - 106 - return c.json({ 107 - success: true, 108 - did: data.did, 109 - accessJwt: data.accessJwt, 110 - refreshJwt: data.refreshJwt 111 - }) 112 - } catch (err: any) { 113 - ctx.logger.error({ err }, 'Login failed') 114 - return c.json( 115 - { 116 - error: 'Login failed', 117 - message: err?.message || 'Invalid credentials or service unavailable', 118 - }, 119 - 401, 120 - ) 121 - } 122 - }) 123 - 124 - router.get('/oauth/login', async (c) => { 125 - const handle = c.req.query('handle') || '' 126 - 127 - if (!isValidHandle(handle)) { 128 - return c.json({ error: 'Invalid handle' }, 400) 129 - } 130 - 131 - try { 132 - const url = await ctx.oauthClient.authorize(handle, { 133 - scope: 'atproto transition:generic', 134 - }) 135 - 136 - return c.redirect(url.toString()) 137 - } catch (err: any) { 138 - if (err?.cause?.message?.includes('does not resolve to a DID')) { 139 - throw new HTTPException(400, { 140 - message: 'Handle does not resolve to a valid DID', 141 - }) 142 - } 143 - ctx.logger.error({ err }, 'Failed to generate authorization URL') 144 - throw new HTTPException(500, { 145 - message: 'Failed to generate authorization URL', 146 - }) 147 - } 148 - }) 149 - 150 - router.get('/oauth/callback', async (c) => { 151 - const url = new URL(c.req.url) 152 - const params = url.searchParams 153 - 154 - try { 155 - const { session } = await ctx.oauthClient.callback(params) 156 - 157 - const redirectUrl = new URL('/', c.req.url) 158 - 159 - redirectUrl.searchParams.set('did', session.did) 160 - 161 - return c.redirect(redirectUrl.toString()) 162 - } catch (err) { 163 - ctx.logger.error({ err }, 'oauth callback failed') 164 - return c.redirect('/?error') 165 - } 166 - }) 167 - 168 - router.get('/session', async (c) => { 169 - // Extract token from authorization header 170 - const authHeader = c.req.header('Authorization') 171 - const session = await getSessionFromHeader(authHeader) 172 - 173 - if (!session || !session.did) { 174 - return c.json(null) 175 - } 176 - 177 - // Return the full session info including did and token 178 - return c.json(session) 179 - }) 180 - 181 - return router 182 - }
+54 -12
services/appview/src/auth/middleware.ts
··· 1 1 import { Context, Next } from 'hono' 2 2 import { HTTPException } from 'hono/http-exception' 3 - import { getSessionFromHeader, Session } from './login.js' 3 + import { verifyJwt } from '@atproto/xrpc-server' 4 + import { DidResolver } from '@atproto/identity' 4 5 5 - // Extend the Context type to include the session 6 + // Extend the Context type to include auth information 6 7 declare module 'hono' { 7 8 interface ContextVariableMap { 8 - session: Session 9 - did: string // Add did for more convenient access 10 - accessJwt: string // Add token for more convenient access 9 + did: string 10 + accessJwt: string 11 + serviceDid: string 12 + didResolver: DidResolver 11 13 } 12 14 } 13 15 14 16 // Authentication middleware 15 17 export const authMiddleware = async (c: Context, next: Next) => { 16 18 const authHeader = c.req.header('Authorization') 17 - const session = await getSessionFromHeader(authHeader) 18 19 19 - if (!session || !session.did) { 20 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 20 21 throw new HTTPException(401, { 21 - message: 'Unauthorized: Invalid or missing AT Protocol JWT token', 22 + message: 'Unauthorized: Invalid or missing Authorization header', 22 23 }) 23 24 } 24 25 25 - // Set both session and did in the context for route handlers to access 26 - c.set('session', session) 27 - c.set('did', session.did) 28 - c.set('accessJwt', session.accessJwt) 26 + const jwt = authHeader.replace('Bearer ', '').trim() 27 + 28 + try { 29 + // The service DID and resolver should be passed from app context 30 + const serviceDid = c.get('serviceDid') 31 + const didResolver = c.get('didResolver') as DidResolver 32 + 33 + const parsed = await verifyJwt(jwt, serviceDid, null, async (did: string) => { 34 + return didResolver.resolveAtprotoKey(did) 35 + }) 36 + 37 + // Set auth information in the context for route handlers to access 38 + c.set('did', parsed.iss) 39 + c.set('accessJwt', jwt) 40 + 41 + await next() 42 + } catch (err) { 43 + throw new HTTPException(401, { 44 + message: 'Unauthorized: Invalid JWT token', 45 + }) 46 + } 47 + } 48 + 49 + // Optional authentication middleware - doesn't throw on missing/invalid auth 50 + export const optionalAuthMiddleware = async (c: Context, next: Next) => { 51 + const authHeader = c.req.header('Authorization') 52 + 53 + if (authHeader && authHeader.startsWith('Bearer ')) { 54 + const jwt = authHeader.replace('Bearer ', '').trim() 55 + 56 + try { 57 + const serviceDid = c.get('serviceDid') 58 + const didResolver = c.get('didResolver') as DidResolver 59 + 60 + const parsed = await verifyJwt(jwt, serviceDid, null, async (did: string) => { 61 + return didResolver.resolveAtprotoKey(did) 62 + }) 63 + 64 + // Set auth information if JWT is valid 65 + c.set('did', parsed.iss) 66 + c.set('accessJwt', jwt) 67 + } catch (err) { 68 + // On auth failure, just continue without setting auth context 69 + } 70 + } 29 71 30 72 await next() 31 73 }
-56
services/appview/src/auth/storage.ts
··· 1 - // @ts-nocheck 2 - import type { 3 - NodeSavedSession, 4 - NodeSavedSessionStore, 5 - NodeSavedState, 6 - NodeSavedStateStore, 7 - } from '@atproto/oauth-client-node' 8 - import type { Database } from '../db.js' 9 - 10 - export class StateStore implements NodeSavedStateStore { 11 - constructor(private db: Database) {} 12 - async get(key: string): Promise<NodeSavedState | undefined> { 13 - const result = await this.db 14 - .selectFrom('auth_state') 15 - .selectAll() 16 - .where('key', '=', key) 17 - .executeTakeFirst() 18 - if (!result) return 19 - return JSON.parse(result.state) as NodeSavedState 20 - } 21 - async set(key: string, val: NodeSavedState) { 22 - const state = JSON.stringify(val) 23 - await this.db 24 - .insertInto('auth_state') 25 - .values({ key, state }) 26 - .onConflict((oc) => oc.column('key').doUpdateSet({ state })) 27 - .execute() 28 - } 29 - async del(key: string) { 30 - await this.db.deleteFrom('auth_state').where('key', '=', key).execute() 31 - } 32 - } 33 - 34 - export class SessionStore implements NodeSavedSessionStore { 35 - constructor(private db: Database) {} 36 - async get(key: string): Promise<NodeSavedSession | undefined> { 37 - const result = await this.db 38 - .selectFrom('auth_session') 39 - .selectAll() 40 - .where('key', '=', key) 41 - .executeTakeFirst() 42 - if (!result) return 43 - return JSON.parse(result.session) as NodeSavedSession 44 - } 45 - async set(key: string, val: NodeSavedSession) { 46 - const session = JSON.stringify(val) 47 - await this.db 48 - .insertInto('auth_session') 49 - .values({ key, session }) 50 - .onConflict((oc) => oc.column('key').doUpdateSet({ session })) 51 - .execute() 52 - } 53 - async del(key: string) { 54 - await this.db.deleteFrom('auth_session').where('key', '=', key).execute() 55 - } 56 - }
+1
services/appview/src/env.ts
··· 12 12 PORT: port({ devDefault: 3000 }), 13 13 PUBLIC_URL: str({ devDefault: '' }), 14 14 APPVIEW_K256_PRIVATE_KEY_HEX: str({ devDefault: '' }), 15 + SERVICE_DID: str({ devDefault: 'did:web:localhost' }), 15 16 16 17 DB_NAME: str({ devDefault: 'dev' }), 17 18 DB_HOST: str({ devDefault: 'localhost' }),
+19 -11
services/appview/src/index.ts
··· 1 1 import { Database } from './db.js' 2 - import { createClient } from './auth/client.js' 3 2 import { pino } from 'pino' 4 3 import { 5 4 BidirectionalResolver, 6 5 createBidirectionalResolver, 7 6 createIdResolver, 8 7 } from './id-resolver.js' 9 - import type { OAuthClient } from '@atproto/oauth-client-node' 10 8 import { Hono } from 'hono' 11 9 import { logger } from 'hono/logger' 12 - import { createAuthRouter } from './auth/login.js' 13 10 import { env } from './env.js' 14 11 import { serve } from '@hono/node-server' 15 12 import { HTTPException } from 'hono/http-exception' 16 - import { authMiddleware } from './auth/middleware.js' 13 + import { authMiddleware, optionalAuthMiddleware } from './auth/middleware.js' 17 14 import { createFeedRouter } from './feed/feed.js' 18 15 import { createGetPostsRouter } from './routes/getPosts.js' 19 16 import wellKnownRouter from './well-known.js' 17 + import { DidResolver } from '@atproto/identity' 20 18 21 19 export type AppContext = { 22 20 db: Database 23 21 logger: pino.Logger 24 - oauthClient: OAuthClient 25 22 resolver: BidirectionalResolver 23 + serviceDid: string 24 + didResolver: DidResolver 26 25 } 27 26 28 27 export class Server { ··· 37 36 const db = new Database() 38 37 await db.connect() 39 38 40 - const oauthClient = await createClient(db) 41 39 const baseIdResolver = createIdResolver() 42 40 const resolver = createBidirectionalResolver(baseIdResolver) 43 41 42 + // Get service DID from environment 43 + const serviceDid = env.SERVICE_DID 44 + 44 45 const ctx = { 45 46 db, 46 47 logger: appLogger, 47 - oauthClient, 48 48 resolver, 49 + serviceDid, 50 + didResolver: baseIdResolver.did, 49 51 } 50 52 51 53 const app = new Hono() ··· 53 55 // Middleware 54 56 app.use('*', logger()) 55 57 56 - app.use('/session', authMiddleware) 58 + // Set context variables for auth middleware 59 + app.use('*', async (c, next) => { 60 + // Type-safe way to set context variables 61 + c.set('serviceDid', serviceDid) 62 + c.set('didResolver', baseIdResolver.did) 63 + await next() 64 + }) 65 + 66 + // Apply optional auth to getPosts - enables auth but doesn't require it 67 + app.use('/xrpc/so.sprk.feed.getPosts', optionalAuthMiddleware) 57 68 58 69 // Auth routes 59 - const authRouter = createAuthRouter(ctx) 60 - app.route('/', authRouter) 61 - 62 70 const feedRouter = createFeedRouter(ctx) 63 71 app.route('/', feedRouter) 64 72
+41 -2
services/appview/src/routes/getPosts.ts
··· 15 15 post: PostDocument, 16 16 db: Database, 17 17 resolver: BidirectionalResolver, 18 + userDid?: string, 18 19 ): Promise<SoSprkFeedDefs.PostView> { 19 20 // Get like count 20 21 const likeCount = await db.models.Like.countDocuments({ subject: post.uri }) ··· 81 82 : undefined 82 83 : undefined 83 84 85 + // Build viewer state with information about the current user's interactions with the post 86 + const viewer: SoSprkFeedDefs.ViewerState = { 87 + // $type: 'so.sprk.feed.defs#viewerState', 88 + } 89 + 90 + // Only check user interactions if a userDid is provided 91 + if (userDid) { 92 + // Check if the user has liked this post 93 + const like = await db.models.Like.findOne({ 94 + subject: post.uri, 95 + authorDid: userDid, 96 + }) 97 + if (like) { 98 + viewer.like = like.uri 99 + } 100 + 101 + // Check if the user has reposted this post 102 + const repost = await db.models.Repost.findOne({ 103 + 'subject.uri': post.uri, 104 + authorDid: userDid, 105 + }) 106 + if (repost) { 107 + viewer.repost = repost.uri 108 + } 109 + 110 + // Check if the user has looked at this post 111 + const look = await db.models.Look.findOne({ 112 + 'subject.uri': post.uri, 113 + authorDid: userDid, 114 + }) 115 + if (look) { 116 + viewer.look = look.uri 117 + } 118 + } 119 + 84 120 return { 85 121 uri: post.uri, 86 122 cid: post.cid, ··· 92 128 tags: post.tags, 93 129 }, 94 130 embed: embed, 131 + viewer, 95 132 replyCount, 96 133 repostCount, 97 134 likeCount, ··· 106 143 uris: string | string[], 107 144 db: Database, 108 145 resolver: BidirectionalResolver, 146 + userDid?: string, 109 147 ): Promise<SoSprkFeedDefs.PostView[]> { 110 148 if (!uris) { 111 149 return [] ··· 121 159 122 160 // Transform each post to PostView format 123 161 const postViews = await Promise.all( 124 - dbPosts.map((post) => transformPostToPostView(post, db, resolver)), 162 + dbPosts.map((post) => transformPostToPostView(post, db, resolver, userDid)), 125 163 ) 126 164 127 165 return postViews ··· 132 170 133 171 router.get('/xrpc/so.sprk.feed.getPosts', async (c) => { 134 172 const uris = c.req.queries('uris') 173 + const userDid = c.get('did') as string | undefined 135 174 136 175 if (!uris || uris.length === 0) { 137 176 return c.json({ posts: [] } as GetPostsView) 138 177 } 139 178 140 - const posts = await getPosts(uris, ctx.db, ctx.resolver) 179 + const posts = await getPosts(uris, ctx.db, ctx.resolver, userDid) 141 180 142 181 return c.json({ posts } as GetPostsView) 143 182 })