···11+import { Context, Next } from 'hono'
22+import { TakedownService } from '../services/takedown.js'
33+44+/**
55+ * Middleware that filters out taken-down content from responses
66+ * This is meant to be applied to routes that return content
77+ * that might have been taken down by admins
88+ */
99+export const takedownFilterMiddleware = async (c: Context, next: Next) => {
1010+ // Skip filtering for admin routes and non-content routes
1111+ const path = c.req.path
1212+ if (path.startsWith('/admin/') || path === '/' || path.includes('favicon') || path.includes('xrpc/com.atproto.admin.updateSubjectStatus')) {
1313+ await next()
1414+ return
1515+ }
1616+1717+ // Call the next middleware/route handler first
1818+ await next()
1919+2020+ // Skip filtering if not a JSON response
2121+ const contentType = c.res.headers.get('Content-Type')
2222+ if (!contentType || !contentType.includes('application/json')) {
2323+ return
2424+ }
2525+2626+ try {
2727+ // Get the takedown service from context
2828+ const takedownService = c.get('takedownService') as TakedownService
2929+3030+ // Get the response body
3131+ const body = await c.res.json()
3232+3333+ // Process different response formats
3434+ if (body.posts && Array.isArray(body.posts)) {
3535+ // For post feeds
3636+ const filteredPosts = await filterTakenDownItems(body.posts, takedownService, 'uri')
3737+ body.posts = filteredPosts
3838+ } else if (body.feed && Array.isArray(body.feed)) {
3939+ // For general feeds
4040+ const filteredFeed = await filterTakenDownItems(body.feed, takedownService, 'post.uri')
4141+ body.feed = filteredFeed
4242+ } else if (body.thread && body.thread.post) {
4343+ // For thread views
4444+ const isThreadTakenDown = await takedownService.isTakenDown(body.thread.post.uri)
4545+ if (isThreadTakenDown) {
4646+ // If the main post is taken down, return empty thread
4747+ body.thread = null
4848+ } else if (body.thread.replies) {
4949+ // Filter replies if they exist
5050+ body.thread.replies = await filterReplies(body.thread.replies, takedownService)
5151+ }
5252+ }
5353+5454+ // If there are user profiles in the response, filter out taken down repositories
5555+ if (body.profiles && Array.isArray(body.profiles)) {
5656+ const filteredProfiles = await filterTakenDownRepos(body.profiles, takedownService)
5757+ body.profiles = filteredProfiles
5858+ } else if (body.profile) {
5959+ if (body.profile.did) {
6060+ const isRepoTakenDown = await takedownService.isRepoTakenDown(body.profile.did)
6161+ if (isRepoTakenDown) {
6262+ body.profile = null
6363+ }
6464+ }
6565+ } else if (body.did && body.$type && body.$type.includes('profileView')) {
6666+ // For direct ProfileViewDetailed objects (so.sprk.actor.getProfile)
6767+ const isRepoTakenDown = await takedownService.isRepoTakenDown(body.did)
6868+ if (isRepoTakenDown) {
6969+ // Return a minimal placeholder object for taken-down profiles
7070+ const takenDownProfile = {
7171+ $type: body.$type,
7272+ did: body.did,
7373+ handle: body.handle || 'unavailable',
7474+ moderation: {
7575+ takenDown: true
7676+ }
7777+ }
7878+7979+ // Create a new response with the placeholder instead of trying to modify body
8080+ c.res = new Response(JSON.stringify(takenDownProfile), {
8181+ status: c.res.status,
8282+ headers: c.res.headers,
8383+ })
8484+8585+ // Skip the rest of the processing
8686+ return
8787+ }
8888+ } else if (body.subject) {
8989+ // For followers/follows response that has a subject profile
9090+ if (body.subject.did) {
9191+ const isRepoTakenDown = await takedownService.isRepoTakenDown(body.subject.did)
9292+ if (isRepoTakenDown) {
9393+ // Keep minimal info about the profile but mark it as taken down
9494+ body.subject = {
9595+ $type: body.subject.$type,
9696+ did: body.subject.did,
9797+ handle: body.subject.handle || 'unavailable',
9898+ moderation: {
9999+ takenDown: true
100100+ }
101101+ }
102102+ }
103103+ }
104104+105105+ // Also filter any followers/follows list
106106+ if (body.followers && Array.isArray(body.followers)) {
107107+ body.followers = await filterTakenDownRepos(body.followers, takedownService)
108108+ }
109109+110110+ if (body.follows && Array.isArray(body.follows)) {
111111+ body.follows = await filterTakenDownRepos(body.follows, takedownService)
112112+ }
113113+ }
114114+115115+ // Set the filtered response
116116+ c.res = new Response(JSON.stringify(body), {
117117+ status: c.res.status,
118118+ headers: c.res.headers,
119119+ })
120120+ } catch (error) {
121121+ // In case of error, just continue with the original response
122122+ console.error('Error in takedown filter middleware:', error)
123123+ }
124124+}
125125+126126+// Helper function to filter out taken down items
127127+async function filterTakenDownItems(
128128+ items: any[],
129129+ takedownService: TakedownService,
130130+ uriPath: string
131131+): Promise<any[]> {
132132+ if (!items || !Array.isArray(items)) return items
133133+134134+ const filteredItems: any[] = []
135135+136136+ for (const item of items) {
137137+ // Get the URI based on the specified path (handles nested objects)
138138+ const uri = uriPath.split('.').reduce((obj, key) => obj && obj[key], item)
139139+140140+ if (uri) {
141141+ const isTakenDown = await takedownService.isTakenDown(uri)
142142+143143+ // Check if author's repo is taken down
144144+ let isAuthorTakenDown = false
145145+ if (item.author?.did || (item.post?.author?.did)) {
146146+ const authorDid = item.author?.did || item.post?.author?.did
147147+ isAuthorTakenDown = await takedownService.isRepoTakenDown(authorDid)
148148+ }
149149+150150+ // Keep the item only if neither the content nor the author is taken down
151151+ if (!isTakenDown && !isAuthorTakenDown) {
152152+ // Filter out taken down images if the item has embeds
153153+ if (item.embed?.images && Array.isArray(item.embed.images)) {
154154+ item.embed.images = await filterTakenDownBlobs(item.embed.images, takedownService)
155155+ }
156156+157157+ filteredItems.push(item)
158158+ }
159159+ } else {
160160+ // If URI is not found, keep the item
161161+ filteredItems.push(item)
162162+ }
163163+ }
164164+165165+ return filteredItems
166166+}
167167+168168+// Helper function to filter out taken down repositories
169169+async function filterTakenDownRepos(
170170+ profiles: any[],
171171+ takedownService: TakedownService
172172+): Promise<any[]> {
173173+ if (!profiles || !Array.isArray(profiles)) return profiles
174174+175175+ const filteredProfiles: any[] = []
176176+177177+ for (const profile of profiles) {
178178+ if (profile.did) {
179179+ const isRepoTakenDown = await takedownService.isRepoTakenDown(profile.did)
180180+ if (!isRepoTakenDown) {
181181+ filteredProfiles.push(profile)
182182+ } else {
183183+ // For UI consistency, push a minimal placeholder for taken-down profiles
184184+ // if they need to be represented in lists (follows, followers, etc.)
185185+ filteredProfiles.push({
186186+ $type: profile.$type,
187187+ did: profile.did,
188188+ handle: profile.handle || 'unavailable',
189189+ moderation: {
190190+ takenDown: true
191191+ }
192192+ })
193193+ }
194194+ } else {
195195+ // If no DID, keep the profile
196196+ filteredProfiles.push(profile)
197197+ }
198198+ }
199199+200200+ return filteredProfiles
201201+}
202202+203203+// Helper function to filter out taken down blobs/images
204204+async function filterTakenDownBlobs(
205205+ images: any[],
206206+ takedownService: TakedownService
207207+): Promise<any[]> {
208208+ if (!images || !Array.isArray(images)) return images
209209+210210+ const filteredImages: any[] = []
211211+212212+ for (const image of images) {
213213+ // Check if the image is taken down based on blob CID
214214+ if (image.cid && image.did) {
215215+ const isBlobTakenDown = await takedownService.isBlobTakenDown(image.did, image.cid)
216216+ if (!isBlobTakenDown) {
217217+ filteredImages.push(image)
218218+ }
219219+ } else {
220220+ // If no CID or DID, keep the image
221221+ filteredImages.push(image)
222222+ }
223223+ }
224224+225225+ return filteredImages
226226+}
227227+228228+// Helper function to recursively filter replies in a thread
229229+async function filterReplies(
230230+ replies: any[],
231231+ takedownService: TakedownService
232232+): Promise<any[]> {
233233+ if (!replies || !Array.isArray(replies)) return replies
234234+235235+ const filteredReplies: any[] = []
236236+237237+ for (const reply of replies) {
238238+ if (reply.post && reply.post.uri) {
239239+ const isTakenDown = await takedownService.isTakenDown(reply.post.uri)
240240+241241+ // Check if author's repo is taken down
242242+ let isAuthorTakenDown = false
243243+ if (reply.post.author?.did) {
244244+ isAuthorTakenDown = await takedownService.isRepoTakenDown(reply.post.author.did)
245245+ }
246246+247247+ if (!isTakenDown && !isAuthorTakenDown) {
248248+ // If this reply has nested replies, filter those too
249249+ if (reply.replies && Array.isArray(reply.replies)) {
250250+ reply.replies = await filterReplies(reply.replies, takedownService)
251251+ }
252252+253253+ // Filter out taken down images in the post
254254+ if (reply.post.embed?.images && Array.isArray(reply.post.embed.images)) {
255255+ reply.post.embed.images = await filterTakenDownBlobs(reply.post.embed.images, takedownService)
256256+ }
257257+258258+ filteredReplies.push(reply)
259259+ }
260260+ } else {
261261+ // If no post or URI, keep the reply
262262+ filteredReplies.push(reply)
263263+ }
264264+ }
265265+266266+ return filteredReplies
267267+}