From 488145340ded52c5516f4909c7d6fbab705b0fc6 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sun, 9 Aug 2026 22:18:10 +0100 Subject: [PATCH] fix: hide outbound media from gallery --- docs/api.md | 2 +- docs/data-flow.md | 2 +- src/web/api-catalog.ts | 2 +- src/web/routes/media.ts | 30 +++++++++++++++++++++++++++++- tests/web/media-route.test.ts | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6871e4b..cb46daf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -76,7 +76,7 @@ an administrator credential. | `POST` | `/api/conversations/{id}/suggest-media-ask` | Generate a media request suggestion | admin | yes | | `POST` | `/api/conversations/{id}/send-reply` | Send a conversation reply | admin | yes | | `POST` | `/api/conversations/start` | Start a new conversation | admin | yes | -| `GET` | `/api/media` | List indexed private media | read | no | +| `GET` | `/api/media` | List received private media | read | no | | `GET` | `/api/media/sync` | Get media sync state | read | no | | `POST` | `/api/media/sync` | Trigger media sync | admin | yes | | `POST` | `/api/media/sync/full` | Trigger full media sync | admin | yes | diff --git a/docs/data-flow.md b/docs/data-flow.md index 9d03fdf..bb60c89 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -96,7 +96,7 @@ Legacy conversations may make at most one optional autonomous media request, inc `ChatService` communicates with Reddit's Matrix homeserver, maintains an account-scoped SQLite-backed cache, resolves `t2_` identities to Reddit usernames, and emits sync progress/new-reply callbacks. Startup chooses full or delta sync from persisted cursor state. Operators can explicitly run tracked sync jobs through the versioned API. -`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs. +`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. The media listing exposes received items only: records attributed to the owning Reddit/Matrix account are excluded, including historical self-sender forms. Private media is served behind the same authorization boundary as the dashboard and APIs. The process-global exchange library is separate. Dashboard uploads are decoded and re-encoded with `sharp`, which validates the actual JPEG/PNG/WebP contents, applies orientation, and removes EXIF/location/camera metadata. OpenRouter then assigns overlapping anatomy tags plus SFW/NSFW classification. Failed or low-confidence classifications remain disabled until the operator corrects them. diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index 1c9914c..3ace9f2 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -84,7 +84,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('suggestMediaAsk', 'POST', '/api/conversations/{id}/suggest-media-ask', 'Generate a media request suggestion', ['Conversations']), operation('sendReply', 'POST', '/api/conversations/{id}/send-reply', 'Send a conversation reply', ['Conversations']), operation('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']), - operation('getMedia', 'GET', '/api/media', 'List indexed private media', ['Media']), + operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']), operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']), operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']), operation('triggerFullMediaSync', 'POST', '/api/media/sync/full', 'Trigger full media sync', ['Media']), diff --git a/src/web/routes/media.ts b/src/web/routes/media.ts index 756521a..04b9aff 100644 --- a/src/web/routes/media.ts +++ b/src/web/routes/media.ts @@ -30,7 +30,14 @@ export function createMediaRouter(ctx: WebContext): Router { : (ctx.services.chat ? await ctx.services.chat.getCachedConversations() : []).map((chat) => ({ ...chat, accountId: '' })); const byRoom = new Map(liveChats.map((c) => [`${c.accountId}:${c.roomId ?? ''}`, c] as const)); - res.json(items.map((item) => { + const receivedItems = items.filter((item) => { + const account = item.accountId + ? ctx.services.redditAccounts?.get(item.accountId) + : null; + return !isOwnedSender(item.sender, item.accountId, account?.redditUsername); + }); + + res.json(receivedItems.map((item) => { const { accountId, ...publicItem } = item; const live = item.chatRoomId ? byRoom.get(`${accountId ?? ''}:${item.chatRoomId}`) : undefined; const username = resolveRedditUsername( @@ -143,3 +150,24 @@ function newest(values: Array): number | null { const present = values.filter((value): value is number => value !== null); return present.length > 0 ? Math.max(...present) : null; } + +function isOwnedSender(sender: string, accountId?: string, redditUsername?: string): boolean { + const normalizedSender = sender.trim().toLowerCase(); + if (/^(me|you)$/i.test(normalizedSender)) return true; + + const ownedIdentities = new Set(); + if (accountId) { + const normalizedAccountId = accountId.trim().toLowerCase(); + ownedIdentities.add(normalizedAccountId); + if (!normalizedAccountId.startsWith('@')) { + ownedIdentities.add(`@${normalizedAccountId}:reddit.com`); + } + } + if (redditUsername) { + const normalizedUsername = redditUsername.trim().toLowerCase(); + ownedIdentities.add(normalizedUsername); + ownedIdentities.add(`u/${normalizedUsername}`); + } + + return ownedIdentities.has(normalizedSender); +} diff --git a/tests/web/media-route.test.ts b/tests/web/media-route.test.ts index ce3e8f9..571d631 100644 --- a/tests/web/media-route.test.ts +++ b/tests/web/media-route.test.ts @@ -20,6 +20,39 @@ afterEach(async () => { }); describe('media route username presentation', () => { + it('lists received media while excluding items sent by the owning account', async () => { + const mediaItem = (id: string, sender: string) => ({ + accountId: 't2_owner', id, chatUsername: 'alice', chatRoomId: 'room-one', sender, + timestamp: 1, originalUrl: `mxc://${id}`, fileName: `${id}.jpg`, relativePath: `${id}.jpg`, createdAt: 2, + }); + const context = { + logger: { error: vi.fn() }, + services: { + media: { getMediaItems: vi.fn().mockResolvedValue([ + mediaItem('received', 'alice'), + mediaItem('sent-marker', 'Me'), + mediaItem('sent-matrix-id', '@t2_owner:reddit.com'), + mediaItem('sent-username', 'owner'), + ]) }, + chat: { + getCachedConversations: vi.fn().mockResolvedValue([]), + resolveSenderDisplay: vi.fn((identity: string) => identity), + }, + redditAccounts: { + get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }), + }, + }, + } as unknown as WebContext; + const app = express().use('/api', createMediaRouter(context)); + + const response = await request(app, '/api/media'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + expect.objectContaining({ id: 'received', sender: 'alice' }), + ]); + }); + it('upgrades historical t2 fields through the chat username map', async () => { const context = { logger: { error: vi.fn() },