diff --git a/services/appview/src/auth/middleware.ts b/services/appview/src/auth/middleware.ts index d5a31a2..823c63d 100644 --- a/services/appview/src/auth/middleware.ts +++ b/services/appview/src/auth/middleware.ts @@ -17,8 +17,14 @@ declare module 'hono' { } } -// Authentication middleware -export const authMiddleware = async (c: Context, next: Next) => { +/** + * Authentication middleware for ATP agents + * + * @param c - Hono context + * @param next - Next middleware function + * @param adminRequired - Whether admin privileges are required (checks agent DID against ADMIN_DIDS) + */ +export const authMiddleware = async (c: Context, next: Next, adminRequired = false) => { const authHeader = c.req.header('Authorization') if (!authHeader || !authHeader.startsWith('Bearer ')) { @@ -42,15 +48,32 @@ export const authMiddleware = async (c: Context, next: Next) => { c.set('did', parsed.iss) c.set('accessJwt', jwt) + // Check for admin status if required + if (adminRequired) { + const isAdmin = env.ADMIN_DIDS.includes(parsed.iss) + if (!isAdmin) { + throw new HTTPException(403, { + message: 'Forbidden: Admin privileges required', + }) + } + c.set('isAdmin', true) + } + await next() } catch (err) { + if (err instanceof HTTPException) { + throw err + } throw new HTTPException(401, { message: 'Unauthorized: Invalid JWT token', }) } } -// Optional authentication middleware - doesn't throw on missing/invalid auth +/** + * Optional authentication middleware - doesn't throw on missing/invalid auth + * Still sets isAdmin flag if the user has admin privileges + */ export const optionalAuthMiddleware = async (c: Context, next: Next) => { const authHeader = c.req.header('Authorization') @@ -68,56 +91,15 @@ export const optionalAuthMiddleware = async (c: Context, next: Next) => { // Set auth information if JWT is valid c.set('did', parsed.iss) c.set('accessJwt', jwt) + + // Check if user has admin privileges (but don't require it) + if (env.ADMIN_DIDS.includes(parsed.iss)) { + c.set('isAdmin', true) + } } catch (err) { // On auth failure, just continue without setting auth context } } await next() -} - -// Admin authentication middleware -export const adminAuthMiddleware = async (c: Context, next: Next) => { - const authHeader = c.req.header('Authorization') - const adminPassword = c.req.header('X-Admin-Password') - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new HTTPException(401, { - message: 'Unauthorized: Invalid or missing Authorization header', - }) - } - - const jwt = authHeader.replace('Bearer ', '').trim() - - try { - // The service DID and resolver should be passed from app context - const serviceDid = c.get('serviceDid') - const didResolver = c.get('didResolver') as DidResolver - - const parsed = await verifyJwt(jwt, serviceDid, null, async (did: string) => { - return didResolver.resolveAtprotoKey(did) - }) - - // Set auth information in the context for route handlers to access - c.set('did', parsed.iss) - c.set('accessJwt', jwt) - - // Check if admin password is valid - if (!adminPassword || !env.ADMIN_PASSWORDS.includes(adminPassword)) { - throw new HTTPException(403, { - message: 'Forbidden: Invalid admin credentials', - }) - } - - c.set('isAdmin', true) - - await next() - } catch (err) { - if (err instanceof HTTPException) { - throw err - } - throw new HTTPException(401, { - message: 'Unauthorized: Invalid JWT token', - }) - } } \ No newline at end of file diff --git a/services/appview/src/env.ts b/services/appview/src/env.ts index 7f90c48..bf8fc3b 100644 --- a/services/appview/src/env.ts +++ b/services/appview/src/env.ts @@ -11,7 +11,7 @@ export const env = { APPVIEW_K256_PRIVATE_KEY_HEX: envStr('APPVIEW_K256_PRIVATE_KEY_HEX') ?? '', SERVICE_DID: envStr('SERVICE_DID') ?? 'did:web:localhost', MOD_SERVICE_DID: envStr('MOD_SERVICE_DID') ?? 'did:web:localhost', - ADMIN_PASSWORDS: envList('ADMIN_PASSWORDS') ?? [], + ADMIN_DIDS: envList('ADMIN_DIDS') ?? [], DB_NAME: envStr('DB_NAME') ?? 'dev', DB_HOST: envStr('DB_HOST') ?? 'localhost', diff --git a/services/appview/src/index.ts b/services/appview/src/index.ts index d9bb860..edfb441 100644 --- a/services/appview/src/index.ts +++ b/services/appview/src/index.ts @@ -22,6 +22,7 @@ import { createGetFollowersRouter } from './routes/so/sprk/graph/getFollowers.js import { createGetFollowsRouter } from './routes/so/sprk/graph/getFollows.js' import { createTakedownRouter } from './routes/admin/takedowns.js' import { createUpdateSubjectStatusRouter } from './routes/com/atproto/admin/updateSubjectStatus.js' +import { createGetRecordRouter } from './routes/com/atproto/repo/getRecord.js' import wellKnownRouter from './well-known.js' import { TakedownService } from './services/takedown.js' @@ -90,6 +91,7 @@ export class Server { const searchActorRouter = createSearchActorRouter(ctx) const updateSubjectStatusRouter = createUpdateSubjectStatusRouter(ctx) const takedownRouter = createTakedownRouter(ctx) + const getRecordRouter = createGetRecordRouter(ctx) app.route('/', getPostsRouter) app.route('/', getPostThreadRouter) @@ -100,6 +102,7 @@ export class Server { app.route('/', searchActorRouter) app.route('/', updateSubjectStatusRouter) app.route('/', takedownRouter) + app.route('/', getRecordRouter) app.route('/', wellKnownRouter()) diff --git a/services/appview/src/middleware/takedown-filter.ts b/services/appview/src/middleware/takedown-filter.ts index 92c2c57..c71fdb2 100644 --- a/services/appview/src/middleware/takedown-filter.ts +++ b/services/appview/src/middleware/takedown-filter.ts @@ -7,9 +7,9 @@ import { TakedownService } from '../services/takedown.js' * that might have been taken down by admins */ export const takedownFilterMiddleware = async (c: Context, next: Next) => { - // Skip filtering for admin routes and non-content routes - const path = c.req.path - if (path.startsWith('/admin/') || path === '/' || path.includes('favicon') || path.includes('xrpc/com.atproto.admin.updateSubjectStatus')) { + // Skip filtering if user is an admin + const isAdmin = c.get('isAdmin') as boolean | undefined + if (isAdmin) { await next() return } diff --git a/services/appview/src/routes/admin/takedowns.ts b/services/appview/src/routes/admin/takedowns.ts index 7c9a4e2..7b0940c 100644 --- a/services/appview/src/routes/admin/takedowns.ts +++ b/services/appview/src/routes/admin/takedowns.ts @@ -3,10 +3,13 @@ import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { HTTPException } from 'hono/http-exception' import { TakedownService } from '../../services/takedown.js' -import { adminAuthMiddleware } from '../../auth/middleware.js' +import { authMiddleware } from '../../auth/middleware.js' +import { Database } from '../../db.js' +import { AtUri } from '@atproto/syntax' type TakedownContext = { takedownService: TakedownService + db: Database } export const createTakedownRouter = (ctx: TakedownContext) => { @@ -14,10 +17,7 @@ export const createTakedownRouter = (ctx: TakedownContext) => { const takedownService = ctx.takedownService // Apply admin auth middleware to all admin routes - takedownRoutes.use('/admin/*', adminAuthMiddleware) - - // No auth needed for the Ozone integration endpoint as it will use its own auth - // This will be protected by adminAuthMiddleware before processing + takedownRoutes.use('/admin/*', (c, next) => authMiddleware(c, next, true)) takedownRoutes.post('/admin/takedowns', zValidator('json', z.object({ targetUri: z.string(), @@ -223,5 +223,51 @@ export const createTakedownRouter = (ctx: TakedownContext) => { } }) + // Get a specific taken down record with its content + takedownRoutes.get('/admin/takedowns/content/:uri', async (c) => { + const uri = c.req.param('uri') + + try { + // First check if this content is taken down + const takedown = await takedownService.getTakedown(uri) + if (!takedown) { + return c.json({ error: 'Content is not taken down' }, 404) + } + + // Parse the URI to extract components + const atUri = new AtUri(uri) + const collection = atUri.collection + const did = atUri.hostname + let record = null + + // Get record based on collection + if (collection.includes('post')) { + record = await ctx.db.models.Post.findOne({ uri }).lean() + } else if (collection.includes('repost')) { + record = await ctx.db.models.Repost.findOne({ uri }).lean() + } else if (collection.includes('like')) { + record = await ctx.db.models.Like.findOne({ uri }).lean() + } else if (collection.includes('follow')) { + record = await ctx.db.models.Follow.findOne({ uri }).lean() + } else if (collection.includes('block')) { + record = await ctx.db.models.Block.findOne({ uri }).lean() + } else if (collection.includes('profile')) { + // For profiles we need to extract the DID + record = await ctx.db.models.Profile.findOne({ authorDid: did }).lean() + } + + if (!record) { + return c.json({ error: 'Record content not found in database' }, 404) + } + + return c.json({ + takedown, + record + }) + } catch (error) { + throw new HTTPException(500, { message: 'Failed to fetch taken down content' }) + } + }) + return takedownRoutes } \ No newline at end of file diff --git a/services/appview/src/routes/com/atproto/admin/updateSubjectStatus.ts b/services/appview/src/routes/com/atproto/admin/updateSubjectStatus.ts index 9ffc279..c1caa22 100644 --- a/services/appview/src/routes/com/atproto/admin/updateSubjectStatus.ts +++ b/services/appview/src/routes/com/atproto/admin/updateSubjectStatus.ts @@ -3,7 +3,7 @@ import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { HTTPException } from 'hono/http-exception' import { TakedownService } from '../../../../services/takedown.js' -import { adminAuthMiddleware } from '../../../../auth/middleware.js' +import { authMiddleware } from '../../../../auth/middleware.js' import type * as ComAtprotoAdminUpdateSubjectStatus from '../../../../lexicon/types/com/atproto/admin/updateSubjectStatus.js' import type * as ComAtprotoAdminDefs from '../../../../lexicon/types/com/atproto/admin/defs.js' import type * as ComAtprotoRepoStrongRef from '../../../../lexicon/types/com/atproto/repo/strongRef.js' @@ -21,7 +21,7 @@ export const createUpdateSubjectStatusRouter = ( // XRPC endpoint for Ozone integration: com.atproto.admin.updateSubjectStatus router.post( '/xrpc/com.atproto.admin.updateSubjectStatus', - adminAuthMiddleware, + (c, next) => authMiddleware(c, next, true), zValidator( 'json', z.object({ diff --git a/services/appview/src/routes/com/atproto/repo/getRecord.ts b/services/appview/src/routes/com/atproto/repo/getRecord.ts new file mode 100644 index 0000000..ef8cfd4 --- /dev/null +++ b/services/appview/src/routes/com/atproto/repo/getRecord.ts @@ -0,0 +1,120 @@ +import { AtUri } from '@atproto/syntax' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Hono } from 'hono' + +import { optionalAuthMiddleware } from '../../../../auth/middleware.js' +import { AppContext } from '../../../../index.js' +import { OutputSchema } from '../../../../lexicon/types/com/atproto/repo/getRecord.js' + +export const createGetRecordRouter = (ctx: AppContext) => { + const router = new Hono() + + router.get( + '/xrpc/com.atproto.repo.getRecord', + optionalAuthMiddleware, + async (c) => { + const { repo, collection, rkey, cid } = c.req.query() + const viewerDid = c.get('did') as string | undefined + const isAdmin = c.get('isAdmin') as boolean | undefined + + if (!repo || !collection || !rkey) { + return c.json({ error: 'Missing required parameters' }, 400) + } + + // Resolve the handle to DID if needed + let did + try { + if (repo.startsWith('did:')) { + did = repo + } else { + // Assume it's a handle + const didDoc = await ctx.resolver.resolveHandleToDidDoc(repo) + did = didDoc.did + } + } catch (err) { + throw new InvalidRequestError(`Could not find repo: ${repo}`) + } + + if (!did) { + throw new InvalidRequestError(`Could not find repo: ${repo}`) + } + + // Create the URI + const uri = AtUri.make(did, collection, rkey).toString() + + // Get the record based on the collection type + try { + let record = null + + // Check which collection to query based on the NSID + if (collection.includes('post') || collection.endsWith('post')) { + record = await ctx.db.models.Post.findOne({ uri }).lean() + } else if (collection.includes('repost')) { + record = await ctx.db.models.Repost.findOne({ uri }).lean() + } else if (collection.includes('like')) { + record = await ctx.db.models.Like.findOne({ uri }).lean() + } else if (collection.includes('look')) { + record = await ctx.db.models.Look.findOne({ uri }).lean() + } else if (collection.includes('profile')) { + record = await ctx.db.models.Profile.findOne({ authorDid: did }).lean() + } else if (collection.includes('follow')) { + record = await ctx.db.models.Follow.findOne({ uri }).lean() + } else if (collection.includes('block')) { + record = await ctx.db.models.Block.findOne({ uri }).lean() + } + + if (!record || (cid && record.cid !== cid)) { + // For admins, provide more detailed information about what we tried to query + if (isAdmin) { + ctx.logger.info({ + uri, + collection, + did, + rkey, + cid, + foundRecord: !!record, + cidMatch: record ? (cid ? record.cid === cid : true) : false, + }, 'Admin record lookup failed') + } + throw new InvalidRequestError(`Could not locate record: ${uri}`) + } + + // Check if the record is subject to a takedown + const takedown = await ctx.takedownService.getTakedown(uri) + + // If record is taken down and user is not an admin, deny access + if (takedown && !isAdmin) { + throw new InvalidRequestError(`Record is taken down: ${uri}`) + } + + // Format the response according to the output schema + const response: OutputSchema & { takedown?: any } = { + uri: uri, + cid: record.cid, + value: record + } + + // Include takedown info for admins + if (isAdmin && takedown) { + response.takedown = { + reason: takedown.reason, + takenDownBy: takedown.takenDownBy, + takenDownAt: takedown.takenDownAt, + warning: 'This content has been taken down and is only visible to admins' + } + } + + return c.json(response) + } catch (err) { + if (err instanceof InvalidRequestError) { + throw err + } + throw new InvalidRequestError(`Error retrieving record: ${uri}`) + } + }, + ) + + return router +} + +export default (ctx: AppContext) => createGetRecordRouter(ctx) \ No newline at end of file diff --git a/services/appview/src/services/takedown.ts b/services/appview/src/services/takedown.ts index 5200d41..88c3ac9 100644 --- a/services/appview/src/services/takedown.ts +++ b/services/appview/src/services/takedown.ts @@ -66,6 +66,22 @@ export class TakedownService { return !!takedown } + /** + * Get takedown information for a URI if it exists + * @param uri The URI of the content to check + * @returns Takedown information or null if not taken down + */ + async getTakedown(uri: string): Promise<{ + targetUri: string + targetCid: string + reason: string + takenDownBy: string + takenDownAt: string + } | null> { + const takedown = await this.db.models.Takedown.findOne({ targetUri: uri }).lean() + return takedown + } + // Add a method to check if a repo is taken down async isRepoTakenDown(did: string): Promise { const takedown = await this.db.models.RepoTakedown.findOne({ did })