fix: hide outbound media from gallery

This commit is contained in:
jjbrunton committed 2026-08-09 22:18:10 +01:00
1 parent c0cc661a2f
commit 488145340d
5 files changed
+65 -4

No files matched your search

+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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']),
+29 -1
View File
@@ -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>): 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<string>();
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);
}
+33
View File
@@ -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() },