[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.

getrecord function and improved admin auth (#4)

authored by

Roscoe Rubin-Rottenberg and committed by
GitHub
6a1d77db 59ed58bd

+227 -60
+31 -49
services/appview/src/auth/middleware.ts
··· 17 17 } 18 18 } 19 19 20 - // Authentication middleware 21 - export const authMiddleware = async (c: Context, next: Next) => { 20 + /** 21 + * Authentication middleware for ATP agents 22 + * 23 + * @param c - Hono context 24 + * @param next - Next middleware function 25 + * @param adminRequired - Whether admin privileges are required (checks agent DID against ADMIN_DIDS) 26 + */ 27 + export const authMiddleware = async (c: Context, next: Next, adminRequired = false) => { 22 28 const authHeader = c.req.header('Authorization') 23 29 24 30 if (!authHeader || !authHeader.startsWith('Bearer ')) { ··· 42 48 c.set('did', parsed.iss) 43 49 c.set('accessJwt', jwt) 44 50 51 + // Check for admin status if required 52 + if (adminRequired) { 53 + const isAdmin = env.ADMIN_DIDS.includes(parsed.iss) 54 + if (!isAdmin) { 55 + throw new HTTPException(403, { 56 + message: 'Forbidden: Admin privileges required', 57 + }) 58 + } 59 + c.set('isAdmin', true) 60 + } 61 + 45 62 await next() 46 63 } catch (err) { 64 + if (err instanceof HTTPException) { 65 + throw err 66 + } 47 67 throw new HTTPException(401, { 48 68 message: 'Unauthorized: Invalid JWT token', 49 69 }) 50 70 } 51 71 } 52 72 53 - // Optional authentication middleware - doesn't throw on missing/invalid auth 73 + /** 74 + * Optional authentication middleware - doesn't throw on missing/invalid auth 75 + * Still sets isAdmin flag if the user has admin privileges 76 + */ 54 77 export const optionalAuthMiddleware = async (c: Context, next: Next) => { 55 78 const authHeader = c.req.header('Authorization') 56 79 ··· 68 91 // Set auth information if JWT is valid 69 92 c.set('did', parsed.iss) 70 93 c.set('accessJwt', jwt) 94 + 95 + // Check if user has admin privileges (but don't require it) 96 + if (env.ADMIN_DIDS.includes(parsed.iss)) { 97 + c.set('isAdmin', true) 98 + } 71 99 } catch (err) { 72 100 // On auth failure, just continue without setting auth context 73 101 } 74 102 } 75 103 76 104 await next() 77 - } 78 - 79 - // Admin authentication middleware 80 - export const adminAuthMiddleware = async (c: Context, next: Next) => { 81 - const authHeader = c.req.header('Authorization') 82 - const adminPassword = c.req.header('X-Admin-Password') 83 - 84 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 85 - throw new HTTPException(401, { 86 - message: 'Unauthorized: Invalid or missing Authorization header', 87 - }) 88 - } 89 - 90 - const jwt = authHeader.replace('Bearer ', '').trim() 91 - 92 - try { 93 - // The service DID and resolver should be passed from app context 94 - const serviceDid = c.get('serviceDid') 95 - const didResolver = c.get('didResolver') as DidResolver 96 - 97 - const parsed = await verifyJwt(jwt, serviceDid, null, async (did: string) => { 98 - return didResolver.resolveAtprotoKey(did) 99 - }) 100 - 101 - // Set auth information in the context for route handlers to access 102 - c.set('did', parsed.iss) 103 - c.set('accessJwt', jwt) 104 - 105 - // Check if admin password is valid 106 - if (!adminPassword || !env.ADMIN_PASSWORDS.includes(adminPassword)) { 107 - throw new HTTPException(403, { 108 - message: 'Forbidden: Invalid admin credentials', 109 - }) 110 - } 111 - 112 - c.set('isAdmin', true) 113 - 114 - await next() 115 - } catch (err) { 116 - if (err instanceof HTTPException) { 117 - throw err 118 - } 119 - throw new HTTPException(401, { 120 - message: 'Unauthorized: Invalid JWT token', 121 - }) 122 - } 123 105 }
+1 -1
services/appview/src/env.ts
··· 11 11 APPVIEW_K256_PRIVATE_KEY_HEX: envStr('APPVIEW_K256_PRIVATE_KEY_HEX') ?? '', 12 12 SERVICE_DID: envStr('SERVICE_DID') ?? 'did:web:localhost', 13 13 MOD_SERVICE_DID: envStr('MOD_SERVICE_DID') ?? 'did:web:localhost', 14 - ADMIN_PASSWORDS: envList('ADMIN_PASSWORDS') ?? [], 14 + ADMIN_DIDS: envList('ADMIN_DIDS') ?? [], 15 15 16 16 DB_NAME: envStr('DB_NAME') ?? 'dev', 17 17 DB_HOST: envStr('DB_HOST') ?? 'localhost',
+3
services/appview/src/index.ts
··· 22 22 import { createGetFollowsRouter } from './routes/so/sprk/graph/getFollows.js' 23 23 import { createTakedownRouter } from './routes/admin/takedowns.js' 24 24 import { createUpdateSubjectStatusRouter } from './routes/com/atproto/admin/updateSubjectStatus.js' 25 + import { createGetRecordRouter } from './routes/com/atproto/repo/getRecord.js' 25 26 import wellKnownRouter from './well-known.js' 26 27 import { TakedownService } from './services/takedown.js' 27 28 ··· 90 91 const searchActorRouter = createSearchActorRouter(ctx) 91 92 const updateSubjectStatusRouter = createUpdateSubjectStatusRouter(ctx) 92 93 const takedownRouter = createTakedownRouter(ctx) 94 + const getRecordRouter = createGetRecordRouter(ctx) 93 95 94 96 app.route('/', getPostsRouter) 95 97 app.route('/', getPostThreadRouter) ··· 100 102 app.route('/', searchActorRouter) 101 103 app.route('/', updateSubjectStatusRouter) 102 104 app.route('/', takedownRouter) 105 + app.route('/', getRecordRouter) 103 106 104 107 app.route('/', wellKnownRouter()) 105 108
+3 -3
services/appview/src/middleware/takedown-filter.ts
··· 7 7 * that might have been taken down by admins 8 8 */ 9 9 export const takedownFilterMiddleware = async (c: Context, next: Next) => { 10 - // Skip filtering for admin routes and non-content routes 11 - const path = c.req.path 12 - if (path.startsWith('/admin/') || path === '/' || path.includes('favicon') || path.includes('xrpc/com.atproto.admin.updateSubjectStatus')) { 10 + // Skip filtering if user is an admin 11 + const isAdmin = c.get('isAdmin') as boolean | undefined 12 + if (isAdmin) { 13 13 await next() 14 14 return 15 15 }
+51 -5
services/appview/src/routes/admin/takedowns.ts
··· 3 3 import { z } from 'zod' 4 4 import { HTTPException } from 'hono/http-exception' 5 5 import { TakedownService } from '../../services/takedown.js' 6 - import { adminAuthMiddleware } from '../../auth/middleware.js' 6 + import { authMiddleware } from '../../auth/middleware.js' 7 + import { Database } from '../../db.js' 8 + import { AtUri } from '@atproto/syntax' 7 9 8 10 type TakedownContext = { 9 11 takedownService: TakedownService 12 + db: Database 10 13 } 11 14 12 15 export const createTakedownRouter = (ctx: TakedownContext) => { ··· 14 17 const takedownService = ctx.takedownService 15 18 16 19 // Apply admin auth middleware to all admin routes 17 - takedownRoutes.use('/admin/*', adminAuthMiddleware) 18 - 19 - // No auth needed for the Ozone integration endpoint as it will use its own auth 20 - // This will be protected by adminAuthMiddleware before processing 20 + takedownRoutes.use('/admin/*', (c, next) => authMiddleware(c, next, true)) 21 21 22 22 takedownRoutes.post('/admin/takedowns', zValidator('json', z.object({ 23 23 targetUri: z.string(), ··· 220 220 return c.json({ isTakenDown }) 221 221 } catch (error) { 222 222 throw new HTTPException(500, { message: 'Failed to check blob takedown status' }) 223 + } 224 + }) 225 + 226 + // Get a specific taken down record with its content 227 + takedownRoutes.get('/admin/takedowns/content/:uri', async (c) => { 228 + const uri = c.req.param('uri') 229 + 230 + try { 231 + // First check if this content is taken down 232 + const takedown = await takedownService.getTakedown(uri) 233 + if (!takedown) { 234 + return c.json({ error: 'Content is not taken down' }, 404) 235 + } 236 + 237 + // Parse the URI to extract components 238 + const atUri = new AtUri(uri) 239 + const collection = atUri.collection 240 + const did = atUri.hostname 241 + let record = null 242 + 243 + // Get record based on collection 244 + if (collection.includes('post')) { 245 + record = await ctx.db.models.Post.findOne({ uri }).lean() 246 + } else if (collection.includes('repost')) { 247 + record = await ctx.db.models.Repost.findOne({ uri }).lean() 248 + } else if (collection.includes('like')) { 249 + record = await ctx.db.models.Like.findOne({ uri }).lean() 250 + } else if (collection.includes('follow')) { 251 + record = await ctx.db.models.Follow.findOne({ uri }).lean() 252 + } else if (collection.includes('block')) { 253 + record = await ctx.db.models.Block.findOne({ uri }).lean() 254 + } else if (collection.includes('profile')) { 255 + // For profiles we need to extract the DID 256 + record = await ctx.db.models.Profile.findOne({ authorDid: did }).lean() 257 + } 258 + 259 + if (!record) { 260 + return c.json({ error: 'Record content not found in database' }, 404) 261 + } 262 + 263 + return c.json({ 264 + takedown, 265 + record 266 + }) 267 + } catch (error) { 268 + throw new HTTPException(500, { message: 'Failed to fetch taken down content' }) 223 269 } 224 270 }) 225 271
+2 -2
services/appview/src/routes/com/atproto/admin/updateSubjectStatus.ts
··· 3 3 import { z } from 'zod' 4 4 import { HTTPException } from 'hono/http-exception' 5 5 import { TakedownService } from '../../../../services/takedown.js' 6 - import { adminAuthMiddleware } from '../../../../auth/middleware.js' 6 + import { authMiddleware } from '../../../../auth/middleware.js' 7 7 import type * as ComAtprotoAdminUpdateSubjectStatus from '../../../../lexicon/types/com/atproto/admin/updateSubjectStatus.js' 8 8 import type * as ComAtprotoAdminDefs from '../../../../lexicon/types/com/atproto/admin/defs.js' 9 9 import type * as ComAtprotoRepoStrongRef from '../../../../lexicon/types/com/atproto/repo/strongRef.js' ··· 21 21 // XRPC endpoint for Ozone integration: com.atproto.admin.updateSubjectStatus 22 22 router.post( 23 23 '/xrpc/com.atproto.admin.updateSubjectStatus', 24 - adminAuthMiddleware, 24 + (c, next) => authMiddleware(c, next, true), 25 25 zValidator( 26 26 'json', 27 27 z.object({
+120
services/appview/src/routes/com/atproto/repo/getRecord.ts
··· 1 + import { AtUri } from '@atproto/syntax' 2 + import { InvalidRequestError } from '@atproto/xrpc-server' 3 + import { Hono } from 'hono' 4 + 5 + import { optionalAuthMiddleware } from '../../../../auth/middleware.js' 6 + import { AppContext } from '../../../../index.js' 7 + import { OutputSchema } from '../../../../lexicon/types/com/atproto/repo/getRecord.js' 8 + 9 + export const createGetRecordRouter = (ctx: AppContext) => { 10 + const router = new Hono() 11 + 12 + router.get( 13 + '/xrpc/com.atproto.repo.getRecord', 14 + optionalAuthMiddleware, 15 + async (c) => { 16 + const { repo, collection, rkey, cid } = c.req.query() 17 + const viewerDid = c.get('did') as string | undefined 18 + const isAdmin = c.get('isAdmin') as boolean | undefined 19 + 20 + if (!repo || !collection || !rkey) { 21 + return c.json({ error: 'Missing required parameters' }, 400) 22 + } 23 + 24 + // Resolve the handle to DID if needed 25 + let did 26 + try { 27 + if (repo.startsWith('did:')) { 28 + did = repo 29 + } else { 30 + // Assume it's a handle 31 + const didDoc = await ctx.resolver.resolveHandleToDidDoc(repo) 32 + did = didDoc.did 33 + } 34 + } catch (err) { 35 + throw new InvalidRequestError(`Could not find repo: ${repo}`) 36 + } 37 + 38 + if (!did) { 39 + throw new InvalidRequestError(`Could not find repo: ${repo}`) 40 + } 41 + 42 + // Create the URI 43 + const uri = AtUri.make(did, collection, rkey).toString() 44 + 45 + // Get the record based on the collection type 46 + try { 47 + let record = null 48 + 49 + // Check which collection to query based on the NSID 50 + if (collection.includes('post') || collection.endsWith('post')) { 51 + record = await ctx.db.models.Post.findOne({ uri }).lean() 52 + } else if (collection.includes('repost')) { 53 + record = await ctx.db.models.Repost.findOne({ uri }).lean() 54 + } else if (collection.includes('like')) { 55 + record = await ctx.db.models.Like.findOne({ uri }).lean() 56 + } else if (collection.includes('look')) { 57 + record = await ctx.db.models.Look.findOne({ uri }).lean() 58 + } else if (collection.includes('profile')) { 59 + record = await ctx.db.models.Profile.findOne({ authorDid: did }).lean() 60 + } else if (collection.includes('follow')) { 61 + record = await ctx.db.models.Follow.findOne({ uri }).lean() 62 + } else if (collection.includes('block')) { 63 + record = await ctx.db.models.Block.findOne({ uri }).lean() 64 + } 65 + 66 + if (!record || (cid && record.cid !== cid)) { 67 + // For admins, provide more detailed information about what we tried to query 68 + if (isAdmin) { 69 + ctx.logger.info({ 70 + uri, 71 + collection, 72 + did, 73 + rkey, 74 + cid, 75 + foundRecord: !!record, 76 + cidMatch: record ? (cid ? record.cid === cid : true) : false, 77 + }, 'Admin record lookup failed') 78 + } 79 + throw new InvalidRequestError(`Could not locate record: ${uri}`) 80 + } 81 + 82 + // Check if the record is subject to a takedown 83 + const takedown = await ctx.takedownService.getTakedown(uri) 84 + 85 + // If record is taken down and user is not an admin, deny access 86 + if (takedown && !isAdmin) { 87 + throw new InvalidRequestError(`Record is taken down: ${uri}`) 88 + } 89 + 90 + // Format the response according to the output schema 91 + const response: OutputSchema & { takedown?: any } = { 92 + uri: uri, 93 + cid: record.cid, 94 + value: record 95 + } 96 + 97 + // Include takedown info for admins 98 + if (isAdmin && takedown) { 99 + response.takedown = { 100 + reason: takedown.reason, 101 + takenDownBy: takedown.takenDownBy, 102 + takenDownAt: takedown.takenDownAt, 103 + warning: 'This content has been taken down and is only visible to admins' 104 + } 105 + } 106 + 107 + return c.json(response) 108 + } catch (err) { 109 + if (err instanceof InvalidRequestError) { 110 + throw err 111 + } 112 + throw new InvalidRequestError(`Error retrieving record: ${uri}`) 113 + } 114 + }, 115 + ) 116 + 117 + return router 118 + } 119 + 120 + export default (ctx: AppContext) => createGetRecordRouter(ctx)
+16
services/appview/src/services/takedown.ts
··· 66 66 return !!takedown 67 67 } 68 68 69 + /** 70 + * Get takedown information for a URI if it exists 71 + * @param uri The URI of the content to check 72 + * @returns Takedown information or null if not taken down 73 + */ 74 + async getTakedown(uri: string): Promise<{ 75 + targetUri: string 76 + targetCid: string 77 + reason: string 78 + takenDownBy: string 79 + takenDownAt: string 80 + } | null> { 81 + const takedown = await this.db.models.Takedown.findOne({ targetUri: uri }).lean() 82 + return takedown 83 + } 84 + 69 85 // Add a method to check if a repo is taken down 70 86 async isRepoTakenDown(did: string): Promise<boolean> { 71 87 const takedown = await this.db.models.RepoTakedown.findOne({ did })