fix: hide outbound media from gallery
This commit is contained in:
1 parent
c0cc661a2f
commit
488145340d
5 files changed
+65
-4
No files matched your search
+1
-1
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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() },
|
||||
|
||||
Reference in new issue
Block a user