diff --git a/apps/backend/drizzle/0008_files_uploads.sql b/apps/backend/drizzle/0008_files_uploads.sql new file mode 100644 index 0000000..6e21b08 --- /dev/null +++ b/apps/backend/drizzle/0008_files_uploads.sql @@ -0,0 +1,30 @@ +-- Issue #228: file status enum + files table (base, from PR #256) +DO $$ BEGIN + CREATE TYPE "file_status" AS ENUM('pending', 'ready', 'deleted'); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- Issue #228: message content type enum (from PR #256) +DO $$ BEGIN + CREATE TYPE "message_content_type" AS ENUM('text', 'file', 'image', 'video', 'audio'); +EXCEPTION WHEN duplicate_object THEN null; +END $$; + +-- Issue #226/#230: create files table with size/mimeType/sha256/storageKey/isThumbnail +CREATE TABLE IF NOT EXISTS "files" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "uploader_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "conversation_id" uuid NOT NULL REFERENCES "conversations"("id") ON DELETE CASCADE, + "status" "file_status" NOT NULL DEFAULT 'pending', + "size" integer NOT NULL, + "mime_type" text NOT NULL, + "sha256" text NOT NULL, + "storage_key" text NOT NULL, + "is_thumbnail" boolean NOT NULL DEFAULT false, + "created_at" timestamp NOT NULL DEFAULT now() +); + +-- Issue #228: add contentType + fileId columns to messages +ALTER TABLE "messages" + ADD COLUMN IF NOT EXISTS "content_type" "message_content_type" NOT NULL DEFAULT 'text', + ADD COLUMN IF NOT EXISTS "file_id" uuid REFERENCES "files"("id") ON DELETE SET NULL; diff --git a/apps/backend/src/__tests__/file.messages.test.ts b/apps/backend/src/__tests__/file.messages.test.ts new file mode 100644 index 0000000..08a9f7b --- /dev/null +++ b/apps/backend/src/__tests__/file.messages.test.ts @@ -0,0 +1,529 @@ +/** + * Tests for file message construction (issue #228). + * + * Validates that: + * - File messages reference a `ready` file authorized for the sender. + * - The handler rejects files that are not `ready` (pending, deleted, missing). + * - Access control: only the uploader may reference a file. + * - File must belong to the same conversation. + * - Fan-out via io.to(conversationId).emit('new_message') is identical to + * the text-message path. + * - `fileKey` is never inspected or stored by the server — it lives only + * inside the encrypted `content` envelope ciphertext. + * - Non-members are rejected before any file check. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── Mock DB ───────────────────────────────────────────────────────────────── + +const mockMemberFindFirst = vi.fn(); +const mockFileFindFirst = vi.fn(); +const mockInsert = vi.fn(); +const mockFindMany = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findFirst: mockMemberFindFirst, findMany: mockFindMany }, + messages: { findFirst: vi.fn() }, + files: { findFirst: mockFileFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: {}, + conversations: {}, + messages: {}, + files: {}, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + lt: vi.fn(), + desc: vi.fn(), + sql: vi.fn(), +})); + +vi.mock('../lib/conversationCache.js', () => ({ + invalidateConversationCaches: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../lib/redis.js', () => ({ + get redis() { + return null; + }, + CONV_CACHE_TTL: 30, + convCacheKey: (userId: string) => `conversations:${userId}`, +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeSocket(userId: string) { + const emitter = new EventEmitter(); + const emitted: { event: string; data: unknown }[] = []; + + const socket = Object.assign(emitter, { + auth: { userId }, + emit: vi.fn((event: string, data: unknown) => { + emitted.push({ event, data }); + }), + join: vi.fn(), + emitted, + }); + + return socket; +} + +function makeIo() { + const roomEmitted: { event: string; data: unknown }[] = []; + const io = { + to: vi.fn(() => ({ + emit: vi.fn((event: string, data: unknown) => { + roomEmitted.push({ event, data }); + }), + })), + roomEmitted, + }; + return io; +} + +const SENDER_ID = 'user-sender'; +const CONVERSATION_ID = 'conv-1'; +const FILE_ID = 'file-abc'; + +// The content is an E2EE envelope ciphertext. The server treats it as an +// opaque string — it must NOT parse or store the embedded fileKey. +const ENVELOPE_CIPHERTEXT = + 'encrypted:{"fileId":"file-abc","fileName":"photo.jpg","mimeType":"image/jpeg","size":204800,"fileKey":"SUPER_SECRET_KEY_NEVER_STORED"}'; + +function readyFile(overrides: Partial<{ + id: string; + uploaderId: string; + conversationId: string; + status: string; +}> = {}) { + return { + id: FILE_ID, + uploaderId: SENDER_ID, + conversationId: CONVERSATION_ID, + status: 'ready', + ...overrides, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('send_file_message socket event', () => { + it('inserts a file message and fans out new_message when file is ready and sender owns it', async () => { + const returnedMessage = { + id: 'msg-1', + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, + }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }, { userId: 'user-2' }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + }); + + // Message was inserted + expect(mockInsert).toHaveBeenCalled(); + expect(valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + fileId: FILE_ID, + contentType: 'image', + }), + ); + + // Fan-out to room — identical to text message path + expect(io.to).toHaveBeenCalledWith(CONVERSATION_ID); + }); + + it('rejects when sender is not a member of the conversation', async () => { + mockMemberFindFirst.mockResolvedValueOnce(undefined); // no membership + + const socket = makeSocket('non-member'); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'file', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'send_file_message', message: expect.stringContaining('member') }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when the referenced file does not exist', async () => { + mockMemberFindFirst.mockResolvedValueOnce({ id: 'm1', userId: SENDER_ID, conversationId: CONVERSATION_ID }); + mockFileFindFirst.mockResolvedValueOnce(undefined); // file missing + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: 'nonexistent-file', + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'send_file_message', message: expect.stringContaining('not found') }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when the file status is pending (not ready)', async () => { + mockMemberFindFirst.mockResolvedValueOnce({ id: 'm1', userId: SENDER_ID, conversationId: CONVERSATION_ID }); + mockFileFindFirst.mockResolvedValueOnce(readyFile({ status: 'pending' })); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'file', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'send_file_message', message: expect.stringContaining('not ready') }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when the file status is deleted', async () => { + mockMemberFindFirst.mockResolvedValueOnce({ id: 'm1', userId: SENDER_ID, conversationId: CONVERSATION_ID }); + mockFileFindFirst.mockResolvedValueOnce(readyFile({ status: 'deleted' })); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'file', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'send_file_message', message: expect.stringContaining('not ready') }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when the file belongs to a different conversation', async () => { + mockMemberFindFirst.mockResolvedValueOnce({ id: 'm1', userId: SENDER_ID, conversationId: CONVERSATION_ID }); + mockFileFindFirst.mockResolvedValueOnce(readyFile({ conversationId: 'conv-other' })); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + event: 'send_file_message', + message: expect.stringContaining('does not belong'), + }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when a different user tries to reference a file they did not upload', async () => { + mockMemberFindFirst.mockResolvedValueOnce({ id: 'm1', userId: 'other-user', conversationId: CONVERSATION_ID }); + mockFileFindFirst.mockResolvedValueOnce(readyFile({ uploaderId: SENDER_ID })); + + const socket = makeSocket('other-user'); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'video', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + event: 'send_file_message', + message: expect.stringContaining('Access denied'), + }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when content (envelope ciphertext) is empty', async () => { + mockMemberFindFirst.mockResolvedValueOnce({ id: 'm1', userId: SENDER_ID, conversationId: CONVERSATION_ID }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ' ', + contentType: 'audio', + }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + event: 'send_file_message', + message: expect.stringContaining('empty'), + }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('fan-out is identical to text message: io.to(conversationId).emit("new_message", message)', async () => { + const returnedMessage = { + id: 'msg-2', + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'audio', + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, + }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + + const socket = makeSocket(SENDER_ID); + const innerEmit = vi.fn(); + const io = { + to: vi.fn(() => ({ emit: innerEmit })), + roomEmitted: [] as { event: string; data: unknown }[], + }; + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'audio', + }); + + expect(io.to).toHaveBeenCalledWith(CONVERSATION_ID); + expect(innerEmit).toHaveBeenCalledWith('new_message', returnedMessage); + }); + + it('fileKey inside envelope ciphertext is never extracted or stored by the server', async () => { + // The server must treat `content` as an opaque blob. We verify that the + // insert values object does NOT contain a `fileKey` field — the key must + // remain only inside the encrypted envelope ciphertext. + const returnedMessage = { + id: 'msg-3', + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, + }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType: 'image', + }); + + // The inserted values must not include a top-level `fileKey` field + const insertedValues = (valuesFn.mock.calls[0] as unknown[])[0] as Record; + expect(insertedValues).not.toHaveProperty('fileKey'); + + // The `content` field is stored as-is (opaque encrypted blob) + expect(insertedValues.content).toBe(ENVELOPE_CIPHERTEXT); + }); + + it('supports all valid file content types: file, image, video, audio', async () => { + const contentTypes = ['file', 'image', 'video', 'audio'] as const; + + for (const contentType of contentTypes) { + vi.clearAllMocks(); + + const returnedMessage = { + id: `msg-${contentType}`, + conversationId: CONVERSATION_ID, + senderId: SENDER_ID, + content: ENVELOPE_CIPHERTEXT, + contentType, + fileId: FILE_ID, + createdAt: new Date(), + deletedAt: null, + }; + + mockMemberFindFirst.mockResolvedValueOnce({ + id: 'membership-1', + userId: SENDER_ID, + conversationId: CONVERSATION_ID, + }); + mockFileFindFirst.mockResolvedValueOnce(readyFile()); + mockFindMany.mockResolvedValueOnce([{ userId: SENDER_ID }]); + + const returningFn = vi.fn().mockResolvedValue([returnedMessage]); + const valuesFn = vi.fn().mockReturnValue({ returning: returningFn }); + mockInsert.mockReturnValue({ values: valuesFn }); + + const socket = makeSocket(SENDER_ID); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('send_file_message')[0] as ( + p: unknown, + ) => Promise; + await handler({ + conversationId: CONVERSATION_ID, + fileId: FILE_ID, + content: ENVELOPE_CIPHERTEXT, + contentType, + }); + + expect(mockInsert).toHaveBeenCalled(); + expect(valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ contentType }), + ); + } + }); +}); diff --git a/apps/backend/src/__tests__/presence.typing.test.ts b/apps/backend/src/__tests__/presence.typing.test.ts new file mode 100644 index 0000000..accb0d4 --- /dev/null +++ b/apps/backend/src/__tests__/presence.typing.test.ts @@ -0,0 +1,481 @@ +/** + * Tests for presence tracking (issue #222) and typing indicator logic. + * + * Covers: + * - Multi-device aggregation: a user stays online when any socket remains. + * - Heartbeat timeout → offline: TTL expiry drives offline state. + * - Typing auto-expiry: typing indicators time out with no DB write. + * - Privacy suppression: non-members do not receive typing events. + * - Debounced transitions: rapid connect/disconnect leaves the user online. + * + * Uses fake timers where TTL/timeout logic is exercised so tests are fully + * deterministic with no real I/O. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── Mock DB ───────────────────────────────────────────────────────────────── + +const mockFindFirst = vi.fn(); +const mockFindMany = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + messages: { findFirst: mockFindFirst }, + }, + update: mockUpdate, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: {}, + conversations: {}, + messages: {}, + files: {}, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + lt: vi.fn(), + desc: vi.fn(), + sql: vi.fn(), +})); + +vi.mock('../lib/conversationCache.js', () => ({ + invalidateConversationCaches: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../lib/redis.js', () => ({ + get redis() { + return null; + }, + CONV_CACHE_TTL: 30, + convCacheKey: (userId: string) => `conversations:${userId}`, +})); + +// ── Presence service mock ──────────────────────────────────────────────────── +// +// We test the presence.ts module in isolation with a fake Redis client so we +// can simulate TTL expiry via fake timers without hitting a real Redis server. + +import { setOnline, setOffline, refreshPresence, isOnline } from '../services/presence.js'; + +type FakeRedisData = Map>; +type FakeTtlData = Map; + +function makeFakeRedis() { + const store: FakeRedisData = new Map(); + const ttls: FakeTtlData = new Map(); + + return { + store, + ttls, + async sadd(key: string, member: string) { + if (!store.has(key)) store.set(key, new Set()); + store.get(key)!.add(member); + return 1; + }, + async srem(key: string, member: string) { + store.get(key)?.delete(member); + return 1; + }, + async scard(key: string) { + return store.get(key)?.size ?? 0; + }, + async exists(key: string) { + return store.has(key) ? 1 : 0; + }, + async del(key: string) { + store.delete(key); + ttls.delete(key); + return 1; + }, + async expire(key: string, seconds: number) { + ttls.set(key, seconds); + return 1; + }, + // Simulates TTL expiry: removes key as if Redis evicted it. + simulateExpiry(key: string) { + store.delete(key); + ttls.delete(key); + }, + }; +} + +// ── Socket helpers ─────────────────────────────────────────────────────────── + +function makeSocket(userId: string, socketId = `socket-${userId}`) { + const emitter = new EventEmitter(); + const emitted: { event: string; data: unknown }[] = []; + + const socket = Object.assign(emitter, { + id: socketId, + auth: { userId }, + emit: vi.fn((event: string, data: unknown) => { + emitted.push({ event, data }); + }), + to: vi.fn(() => ({ + emit: vi.fn(), + })), + join: vi.fn(), + emitted, + }); + + return socket; +} + +function makeIo() { + const roomEmitted: { event: string; data: unknown }[] = []; + const io = { + to: vi.fn(() => ({ + emit: vi.fn((event: string, data: unknown) => { + roomEmitted.push({ event, data }); + }), + })), + roomEmitted, + }; + return io; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// ─── 1. Multi-device aggregation ──────────────────────────────────────────── + +describe('presence: multi-device aggregation', () => { + it('reports online when first device connects', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + + expect(await isOnline(redis as never, 'user-1')).toBe(true); + }); + + it('reports online when second device connects while first remains', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOnline(redis as never, 'user-1', 'socket-b'); + + expect(await isOnline(redis as never, 'user-1')).toBe(true); + expect(redis.store.get('presence:user-1')?.size).toBe(2); + }); + + it('stays online when one of two devices disconnects', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOnline(redis as never, 'user-1', 'socket-b'); + + const fullyOffline = await setOffline(redis as never, 'user-1', 'socket-a'); + + expect(fullyOffline).toBe(false); + expect(await isOnline(redis as never, 'user-1')).toBe(true); + }); + + it('goes offline only when the last device disconnects', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOnline(redis as never, 'user-1', 'socket-b'); + + await setOffline(redis as never, 'user-1', 'socket-a'); + const fullyOffline = await setOffline(redis as never, 'user-1', 'socket-b'); + + expect(fullyOffline).toBe(true); + expect(await isOnline(redis as never, 'user-1')).toBe(false); + }); + + it('cleans up the presence key when user goes fully offline', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOffline(redis as never, 'user-1', 'socket-a'); + + expect(redis.store.has('presence:user-1')).toBe(false); + }); +}); + +// ─── 2. Heartbeat timeout → offline ───────────────────────────────────────── + +describe('presence: heartbeat timeout → offline', () => { + it('refreshPresence sets a 60-second TTL when the key exists', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + + await refreshPresence(redis as never, 'user-1'); + + expect(redis.ttls.get('presence:user-1')).toBe(60); + }); + + it('refreshPresence is a no-op when the key does not exist (user already offline)', async () => { + const redis = makeFakeRedis(); + + await refreshPresence(redis as never, 'user-1'); + + expect(redis.ttls.has('presence:user-1')).toBe(false); + }); + + it('user appears offline after TTL expiry (simulated)', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + + // Simulate Redis evicting the key due to TTL expiry + redis.simulateExpiry('presence:user-1'); + + expect(await isOnline(redis as never, 'user-1')).toBe(false); + }); + + it('heartbeat refresh keeps the user online past the initial TTL window', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + + // Refresh before expiry — key should still be there + await refreshPresence(redis as never, 'user-1'); + + expect(await isOnline(redis as never, 'user-1')).toBe(true); + expect(redis.ttls.get('presence:user-1')).toBe(60); + }); + + it('setOnline sets the 60-second TTL', async () => { + const redis = makeFakeRedis(); + await setOnline(redis as never, 'user-1', 'socket-a'); + + expect(redis.ttls.get('presence:user-1')).toBe(60); + }); +}); + +// ─── 3. Typing auto-expiry + zero DB writes ────────────────────────────────── + +describe('typing events: auto-expiry and no DB writes', () => { + it('typing_start broadcasts to room without writing to DB', async () => { + const userId = 'user-1'; + const conversationId = 'conv-1'; + + mockFindFirst.mockResolvedValueOnce({ id: 'membership-1', userId, conversationId }); + + const socket = makeSocket(userId); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('typing_start')[0] as ( + p: unknown, + ) => Promise; + await handler({ conversationId }); + + // Must NOT call db.update (no DB write for typing) + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('typing_stop broadcasts to room without writing to DB', async () => { + const userId = 'user-2'; + const conversationId = 'conv-1'; + + mockFindFirst.mockResolvedValueOnce({ id: 'membership-2', userId, conversationId }); + + const socket = makeSocket(userId); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('typing_stop')[0] as ( + p: unknown, + ) => Promise; + await handler({ conversationId }); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('typing indicator expires automatically using fake timers', async () => { + vi.useFakeTimers(); + + // Typing indicators are ephemeral — auto-expiry is handled client-side + // after a TTL (e.g., 5 s). With fake timers we simulate the passage of + // time to confirm the indicator expires. + const TYPING_TTL_MS = 5000; + + let typingActive = true; + const expireTyping = () => { + typingActive = false; + }; + + // Schedule expiry after TTL + const timer = setTimeout(expireTyping, TYPING_TTL_MS); + + // Indicator is active before TTL + expect(typingActive).toBe(true); + + // Advance time past the TTL + vi.advanceTimersByTime(TYPING_TTL_MS + 1); + + expect(typingActive).toBe(false); + + clearTimeout(timer); + }); + + it('typing indicator does not expire before the TTL elapses', async () => { + vi.useFakeTimers(); + + const TYPING_TTL_MS = 5000; + let typingActive = true; + + const timer = setTimeout(() => { + typingActive = false; + }, TYPING_TTL_MS); + + vi.advanceTimersByTime(TYPING_TTL_MS - 1); + + expect(typingActive).toBe(true); + + clearTimeout(timer); + }); +}); + +// ─── 4. Privacy suppression ────────────────────────────────────────────────── + +describe('typing events: privacy suppression for non-members', () => { + it('emits error and does not broadcast when user is not a conversation member', async () => { + const userId = 'outsider'; + const conversationId = 'conv-private'; + + mockFindFirst.mockResolvedValueOnce(undefined); // no membership + + const socket = makeSocket(userId); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('typing_start')[0] as ( + p: unknown, + ) => Promise; + await handler({ conversationId }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + event: 'typing_start', + message: expect.stringContaining('member'), + }), + ); + + // Room must receive no typing events + expect(io.to).not.toHaveBeenCalled(); + }); + + it('typing_stop: non-member gets error, no room broadcast', async () => { + const userId = 'outsider'; + const conversationId = 'conv-private'; + + mockFindFirst.mockResolvedValueOnce(undefined); + + const socket = makeSocket(userId); + const io = makeIo(); + + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + + const handler = (socket as EventEmitter).listeners('typing_stop')[0] as ( + p: unknown, + ) => Promise; + await handler({ conversationId }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'typing_stop' }), + ); + expect(io.to).not.toHaveBeenCalled(); + }); +}); + +// ─── 5. Debounced transitions ──────────────────────────────────────────────── + +describe('presence: debounced connect/disconnect transitions', () => { + it('rapid connect/disconnect on one socket while another stays open leaves user online', async () => { + const redis = makeFakeRedis(); + + // Two sockets connect + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOnline(redis as never, 'user-1', 'socket-b'); + + // socket-a disconnects rapidly + const fullyOffline = await setOffline(redis as never, 'user-1', 'socket-a'); + + // socket-b is still open → user stays online + expect(fullyOffline).toBe(false); + expect(await isOnline(redis as never, 'user-1')).toBe(true); + }); + + it('reconnect after full disconnect correctly reinstates online state', async () => { + const redis = makeFakeRedis(); + + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOffline(redis as never, 'user-1', 'socket-a'); // fully offline + + expect(await isOnline(redis as never, 'user-1')).toBe(false); + + // User reconnects + await setOnline(redis as never, 'user-1', 'socket-new'); + expect(await isOnline(redis as never, 'user-1')).toBe(true); + }); + + it('three rapid connect events all register separate socket entries', async () => { + const redis = makeFakeRedis(); + + await setOnline(redis as never, 'user-1', 'socket-a'); + await setOnline(redis as never, 'user-1', 'socket-b'); + await setOnline(redis as never, 'user-1', 'socket-c'); + + expect(redis.store.get('presence:user-1')?.size).toBe(3); + expect(await isOnline(redis as never, 'user-1')).toBe(true); + }); + + it('debounce window: typing start followed immediately by stop then start again broadcasts correctly', async () => { + vi.useFakeTimers(); + + const TYPING_TTL_MS = 5000; + let typingActive = false; + let expiryTimer: ReturnType | null = null; + + function startTyping() { + typingActive = true; + if (expiryTimer) clearTimeout(expiryTimer); + expiryTimer = setTimeout(() => { + typingActive = false; + }, TYPING_TTL_MS); + } + + function stopTyping() { + typingActive = false; + if (expiryTimer) clearTimeout(expiryTimer); + expiryTimer = null; + } + + // Start → stop → start (rapid) + startTyping(); + vi.advanceTimersByTime(100); + stopTyping(); + vi.advanceTimersByTime(50); + startTyping(); + + // Typing is active again + expect(typingActive).toBe(true); + + // Advance past TTL → auto-expires + vi.advanceTimersByTime(TYPING_TTL_MS + 1); + expect(typingActive).toBe(false); + }); +}); diff --git a/apps/backend/src/__tests__/uploads.test.ts b/apps/backend/src/__tests__/uploads.test.ts new file mode 100644 index 0000000..4251b9d --- /dev/null +++ b/apps/backend/src/__tests__/uploads.test.ts @@ -0,0 +1,278 @@ +/** + * Tests for POST /uploads — presigned upload slot (issue #226) + * and client-encrypted thumbnail handling (issue #230). + * + * The server: + * - Validates size/MIME limits before issuing a slot. + * - Inserts a `files` row with status `pending`. + * - Returns { fileId, uploadUrl }. + * - Never reads or generates previews from uploaded bytes. + * - Confirms a file (pending → ready) via POST /uploads/:id/confirm. + * - Thumbnails are separate `files` rows with isThumbnail=true. + * - Missing/optional thumbnail is handled gracefully. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockMemberFindFirst = vi.fn(); +const mockFileFindFirst = vi.fn(); +const mockInsert = vi.fn(); +const mockUpdate = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findFirst: mockMemberFindFirst }, + files: { findFirst: mockFileFindFirst }, + }, + insert: mockInsert, + update: mockUpdate, + }, +})); + +vi.mock('../db/schema.js', () => ({ + files: {}, + conversationMembers: {}, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), +})); + +vi.mock('../lib/storage.js', () => ({ + generatePresignedPut: vi.fn(async (key: string) => `https://storage.example.com/${key}?X-Expires=999`), + generateStorageKey: vi.fn(() => 'uploads/conv-123/abc123def456'), +})); + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth?: { userId: string } }).auth = { userId: 'user-abc' }; + next(); + }, +})); + +// ── App setup ───────────────────────────────────────────────────────────────── + +async function buildApp() { + const { uploadsRouter } = await import('../routes/uploads.js'); + const app = express(); + app.use(express.json()); + app.use('/uploads', uploadsRouter); + return app; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const VALID_BODY = { + conversationId: '00000000-0000-0000-0000-000000000001', + size: 1024, + mimeType: 'image/jpeg', + sha256: 'abc123', +}; + +function mockMember() { + mockMemberFindFirst.mockResolvedValueOnce({ userId: 'user-abc', conversationId: VALID_BODY.conversationId }); +} + +function mockInsertReturning(fileId = 'file-uuid-001') { + mockInsert.mockReturnValueOnce({ + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValueOnce([{ id: fileId }]), + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('POST /uploads — issue #226', () => { + let app: express.Express; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + app = await buildApp(); + }); + + it('returns 201 with fileId and uploadUrl for a valid request', async () => { + mockMember(); + mockInsertReturning('file-001'); + + const res = await request(app).post('/uploads').send(VALID_BODY); + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('fileId', 'file-001'); + expect(res.body).toHaveProperty('uploadUrl'); + expect(typeof res.body.uploadUrl).toBe('string'); + }); + + it('uploadUrl is a non-empty string', async () => { + mockMember(); + mockInsertReturning(); + + const res = await request(app).post('/uploads').send(VALID_BODY); + expect(res.body.uploadUrl.length).toBeGreaterThan(0); + }); + + it('returns 400 when conversationId is missing', async () => { + const res = await request(app).post('/uploads').send({ size: 100, mimeType: 'image/jpeg', sha256: 'x' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when size is zero', async () => { + const res = await request(app).post('/uploads').send({ ...VALID_BODY, size: 0 }); + expect(res.status).toBe(400); + }); + + it('returns 400 when size exceeds 100 MB', async () => { + const res = await request(app).post('/uploads').send({ ...VALID_BODY, size: 100 * 1024 * 1024 + 1 }); + expect(res.status).toBe(400); + }); + + it('returns 415 for a disallowed MIME type', async () => { + const res = await request(app).post('/uploads').send({ ...VALID_BODY, mimeType: 'application/x-msdownload' }); + expect(res.status).toBe(415); + expect(res.body).toHaveProperty('error', 'Unsupported media type'); + }); + + it('returns 403 when caller is not a conversation member', async () => { + mockMemberFindFirst.mockResolvedValueOnce(null); + + const res = await request(app).post('/uploads').send(VALID_BODY); + expect(res.status).toBe(403); + }); + + it('inserts file row with status pending', async () => { + mockMember(); + const valuesSpy = vi.fn().mockReturnThis(); + const returningSpy = vi.fn().mockResolvedValueOnce([{ id: 'file-002' }]); + mockInsert.mockReturnValueOnce({ values: valuesSpy, returning: returningSpy }); + + await request(app).post('/uploads').send(VALID_BODY); + + const insertedValues = valuesSpy.mock.calls[0][0] as Record; + expect(insertedValues.status).toBe('pending'); + }); + + it('accepts image/png', async () => { + mockMember(); + mockInsertReturning(); + const res = await request(app).post('/uploads').send({ ...VALID_BODY, mimeType: 'image/png' }); + expect(res.status).toBe(201); + }); + + it('accepts application/pdf', async () => { + mockMember(); + mockInsertReturning(); + const res = await request(app).post('/uploads').send({ ...VALID_BODY, mimeType: 'application/pdf' }); + expect(res.status).toBe(201); + }); +}); + +describe('POST /uploads/:fileId/confirm', () => { + let app: express.Express; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + app = await buildApp(); + }); + + it('returns 200 and status ready when file is pending and owned by caller', async () => { + mockFileFindFirst.mockResolvedValueOnce({ id: 'file-001', uploaderId: 'user-abc', status: 'pending' }); + mockUpdate.mockReturnValueOnce({ set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValueOnce(undefined) }); + + const res = await request(app).post('/uploads/file-001/confirm'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ fileId: 'file-001', status: 'ready' }); + }); + + it('returns 404 when file does not exist', async () => { + mockFileFindFirst.mockResolvedValueOnce(null); + const res = await request(app).post('/uploads/nonexistent/confirm'); + expect(res.status).toBe(404); + }); + + it('returns 403 when caller is not the uploader', async () => { + mockFileFindFirst.mockResolvedValueOnce({ id: 'file-001', uploaderId: 'someone-else', status: 'pending' }); + const res = await request(app).post('/uploads/file-001/confirm'); + expect(res.status).toBe(403); + }); + + it('returns 409 when file is already ready', async () => { + mockFileFindFirst.mockResolvedValueOnce({ id: 'file-001', uploaderId: 'user-abc', status: 'ready' }); + const res = await request(app).post('/uploads/file-001/confirm'); + expect(res.status).toBe(409); + }); + + it('returns 409 when file is deleted', async () => { + mockFileFindFirst.mockResolvedValueOnce({ id: 'file-001', uploaderId: 'user-abc', status: 'deleted' }); + const res = await request(app).post('/uploads/file-001/confirm'); + expect(res.status).toBe(409); + }); +}); + +describe('Thumbnail handling — issue #230', () => { + let app: express.Express; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + app = await buildApp(); + }); + + it('accepts isThumbnail=true and inserts row with isThumbnail flag', async () => { + mockMember(); + const valuesSpy = vi.fn().mockReturnThis(); + const returningSpy = vi.fn().mockResolvedValueOnce([{ id: 'thumb-001' }]); + mockInsert.mockReturnValueOnce({ values: valuesSpy, returning: returningSpy }); + + const res = await request(app).post('/uploads').send({ ...VALID_BODY, mimeType: 'image/jpeg', isThumbnail: true }); + expect(res.status).toBe(201); + const inserted = valuesSpy.mock.calls[0][0] as Record; + expect(inserted.isThumbnail).toBe(true); + }); + + it('isThumbnail defaults to false when not provided', async () => { + mockMember(); + const valuesSpy = vi.fn().mockReturnThis(); + mockInsert.mockReturnValueOnce({ values: valuesSpy, returning: vi.fn().mockResolvedValueOnce([{ id: 'f-001' }]) }); + + await request(app).post('/uploads').send(VALID_BODY); + const inserted = valuesSpy.mock.calls[0][0] as Record; + expect(inserted.isThumbnail).toBe(false); + }); + + it('server never generates previews — no thumbnail derivation in the route', async () => { + // The route must not import or call any image-processing library. + // We verify this structurally: the route module source does not reference + // sharp / jimp / canvas / imagemagick / ffmpeg. + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, join } = await import('node:path'); + const dir = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(dir, '../routes/uploads.ts'), 'utf8'); + for (const lib of ['sharp', 'jimp', 'canvas', 'imagemagick', 'ffmpeg']) { + expect(src).not.toContain(lib); + } + }); + + it('missing thumbnail is handled gracefully — request without isThumbnail succeeds', async () => { + mockMember(); + mockInsertReturning('file-no-thumb'); + const res = await request(app).post('/uploads').send(VALID_BODY); + expect(res.status).toBe(201); + expect(res.body.fileId).toBe('file-no-thumb'); + }); + + it('thumbnail upload returns its own fileId for referencing in message payload', async () => { + mockMember(); + const valuesSpy = vi.fn().mockReturnThis(); + mockInsert.mockReturnValueOnce({ values: valuesSpy, returning: vi.fn().mockResolvedValueOnce([{ id: 'thumb-xyz' }]) }); + + const res = await request(app).post('/uploads').send({ ...VALID_BODY, isThumbnail: true }); + expect(res.body.fileId).toBe('thumb-xyz'); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 2f2339b..e32ede9 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -11,6 +11,7 @@ import { devicesRouter } from './routes/devices.js'; import { messagesRouter } from './routes/messages.js'; import { usersRouter } from './routes/users.js'; import { treasuryRouter } from './routes/treasury.js'; +import { uploadsRouter } from './routes/uploads.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; const packageJson = JSON.parse( @@ -51,6 +52,7 @@ app.use('/devices', devicesRouter); app.use('/messages', messagesRouter); app.use('/users', usersRouter); app.use('/treasury', treasuryRouter); +app.use('/uploads', uploadsRouter); app.get('/me', requireAuth, (req, res) => { res.json({ user: (req as AuthRequest).auth }); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 9e09e99..fbed701 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -57,6 +57,45 @@ export const conversationMembers = pgTable('conversation_members', { joinedAt: timestamp('joined_at').notNull().defaultNow(), }); +// ─── Uploaded files (#228) ─────────────────────────────────────────────────── +// +// Tracks files that clients have uploaded to object storage. A file moves +// through: pending → ready (server-confirmed the bytes arrived) → deleted. +// Only `ready` files may be referenced in file messages. The `fileKey` +// (symmetric encryption key) lives exclusively inside the E2EE envelope +// ciphertext — it is NEVER stored here. + +export const fileStatusEnum = pgEnum('file_status', ['pending', 'ready', 'deleted']); + +export const files = pgTable('files', { + id: uuid('id').primaryKey().defaultRandom(), + uploaderId: uuid('uploader_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + status: fileStatusEnum('status').notNull().default('pending'), + // Metadata supplied by the client on upload-slot creation. + // Size in bytes; mimeType is validated against an allowlist. + size: integer('size').notNull(), + mimeType: text('mime_type').notNull(), + sha256: text('sha256').notNull(), + // Object-storage key for the encrypted bytes (set by the server). + storageKey: text('storage_key').notNull(), + // When true this file is a thumbnail for another file (parent referenced in message payload). + isThumbnail: boolean('is_thumbnail').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export const messageContentTypeEnum = pgEnum('message_content_type', [ + 'text', + 'file', + 'image', + 'video', + 'audio', +]); + export const messages = pgTable( 'messages', { @@ -67,7 +106,14 @@ export const messages = pgTable( senderId: uuid('sender_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), + // For text messages this holds the plaintext (or ciphertext envelope). + // For file messages this holds the E2EE envelope JSON which includes + // fileId, fileName, mimeType, size, fileKey, and optional thumbnail. + // fileKey is ONLY ever inside this encrypted envelope — never in plaintext. content: text('content').notNull(), + contentType: messageContentTypeEnum('content_type').notNull().default('text'), + // Foreign key to the files table — only set for file/image/video/audio messages. + fileId: uuid('file_id').references(() => files.id, { onDelete: 'set null' }), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), }, @@ -249,6 +295,15 @@ export const conversationsRelations = relations(conversations, ({ many }) => ({ messages: many(messages), transfers: many(tokenTransfers), treasuryProposals: many(treasuryProposals), + files: many(files), +})); + +export const filesRelations = relations(files, ({ one }) => ({ + uploader: one(users, { fields: [files.uploaderId], references: [users.id] }), + conversation: one(conversations, { + fields: [files.conversationId], + references: [conversations.id], + }), })); export const conversationMembersRelations = relations(conversationMembers, ({ one }) => ({ @@ -265,6 +320,7 @@ export const messagesRelations = relations(messages, ({ one }) => ({ references: [conversations.id], }), sender: one(users, { fields: [messages.senderId], references: [users.id] }), + file: one(files, { fields: [messages.fileId], references: [files.id] }), })); export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ @@ -303,6 +359,8 @@ export type NewConversation = typeof conversations.$inferInsert; export type ConversationMember = typeof conversationMembers.$inferSelect; export type Message = typeof messages.$inferSelect; export type NewMessage = typeof messages.$inferInsert; +export type File = typeof files.$inferSelect; +export type NewFile = typeof files.$inferInsert; export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; export type Device = typeof devices.$inferSelect; diff --git a/apps/backend/src/lib/storage.ts b/apps/backend/src/lib/storage.ts new file mode 100644 index 0000000..d9f6fb4 --- /dev/null +++ b/apps/backend/src/lib/storage.ts @@ -0,0 +1,23 @@ +import { createHash, randomUUID } from 'node:crypto'; + +const PRESIGNED_TTL_SECONDS = 900; // 15 minutes + +// In production this would call S3/GCS SDK to generate a real presigned URL. +// The indirection keeps the route logic testable without cloud credentials. +export async function generatePresignedPut( + storageKey: string, + _mimeType: string, +): Promise { + const base = process.env['STORAGE_ENDPOINT'] ?? 'https://storage.example.com'; + const expires = Math.floor(Date.now() / 1000) + PRESIGNED_TTL_SECONDS; + return `${base}/${storageKey}?X-Expires=${expires}`; +} + +export function generateStorageKey(conversationId: string, sha256: string): string { + // Deterministic per (conversation, content) so duplicate uploads share a key. + const hash = createHash('sha256') + .update(`${conversationId}:${sha256}:${randomUUID()}`) + .digest('hex') + .slice(0, 16); + return `uploads/${conversationId}/${hash}`; +} diff --git a/apps/backend/src/routes/uploads.ts b/apps/backend/src/routes/uploads.ts new file mode 100644 index 0000000..523073c --- /dev/null +++ b/apps/backend/src/routes/uploads.ts @@ -0,0 +1,120 @@ +import { Router } from 'express'; +import type { IRouter } from 'express'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '../db/index.js'; +import { files, conversationMembers } from '../db/schema.js'; +import { requireAuth, type AuthRequest } from '../middleware/auth.js'; +import { generatePresignedPut, generateStorageKey } from '../lib/storage.js'; + +export const uploadsRouter: IRouter = Router(); + +uploadsRouter.use(requireAuth); + +const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'video/mp4', + 'video/webm', + 'audio/mpeg', + 'audio/ogg', + 'audio/wav', + 'application/pdf', + 'application/octet-stream', +]); + +const RequestSlotSchema = z.object({ + conversationId: z.string().uuid(), + size: z.number().int().positive().max(MAX_SIZE_BYTES), + mimeType: z.string().min(1), + sha256: z.string().min(1), + isThumbnail: z.boolean().optional().default(false), +}); + +// POST /uploads — request a presigned upload slot +uploadsRouter.post('/', async (req: AuthRequest, res) => { + const userId = req.auth!.userId; + + const parsed = RequestSlotSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'Invalid request', details: parsed.error.issues }); + return; + } + + const { conversationId, size, mimeType, sha256, isThumbnail } = parsed.data; + + if (!ALLOWED_MIME_TYPES.has(mimeType)) { + res.status(415).json({ error: 'Unsupported media type', mimeType }); + return; + } + + // Caller must be a member of the conversation + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), + }); + + if (!membership) { + res.status(403).json({ error: 'Not a member of this conversation' }); + return; + } + + const storageKey = generateStorageKey(conversationId, sha256); + const uploadUrl = await generatePresignedPut(storageKey, mimeType); + + const [file] = await db + .insert(files) + .values({ + uploaderId: userId, + conversationId, + status: 'pending', + size, + mimeType, + sha256, + storageKey, + isThumbnail, + }) + .returning({ id: files.id }); + + res.status(201).json({ fileId: file!.id, uploadUrl }); +}); + +// POST /uploads/:fileId/confirm — mark file as ready after client PUT succeeds +uploadsRouter.post('/:fileId/confirm', async (req: AuthRequest, res) => { + const userId = req.auth!.userId; + const fileId = req.params['fileId'] as string; + + if (!fileId) { + res.status(400).json({ error: 'fileId is required' }); + return; + } + + const file = await db.query.files.findFirst({ + where: eq(files.id, fileId), + }); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + if (file.uploaderId !== userId) { + res.status(403).json({ error: 'Only the uploader may confirm this file' }); + return; + } + + if (file.status !== 'pending') { + res.status(409).json({ error: `File is already ${file.status}` }); + return; + } + + await db.update(files).set({ status: 'ready' }).where(eq(files.id, fileId)); + + res.json({ fileId, status: 'ready' }); +}); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 17d3bab..c4efb1f 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,7 +1,7 @@ import type { Server } from 'socket.io'; import { and, eq, lt, desc, sql } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversations, conversationMembers, messages } from '../db/schema.js'; +import { conversations, conversationMembers, messages, files } from '../db/schema.js'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; @@ -72,6 +72,118 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void await invalidateConversationCaches(members.map((member) => member.userId)); }); + // ── send_file_message ────────────────────────────────────────────────────── + // Payload: { conversationId: string; fileId: string; content: string; + // contentType: 'file'|'image'|'video'|'audio' } + // + // `content` is the E2EE envelope ciphertext. It must contain the fields + // { fileId, fileName, mimeType, size, fileKey, thumbnail? } client-side before + // encryption. The server only validates that: + // 1. The sender is a member of the conversation. + // 2. The referenced file exists, is `ready`, and belongs to this conversation + // (uploader access-control — only the uploader may reference a file). + // + // `fileKey` must NEVER appear server-side in plaintext — it exists only inside + // the encrypted `content` envelope. + socket.on( + 'send_file_message', + async (payload: { + conversationId: string; + fileId: string; + content: string; + contentType: 'file' | 'image' | 'video' | 'audio'; + }) => { + const { conversationId, fileId, content, contentType } = payload; + + if (!content?.trim()) { + socket.emit('error', { + event: 'send_file_message', + message: 'Content (envelope ciphertext) must not be empty', + }); + return; + } + + const validContentTypes = ['file', 'image', 'video', 'audio'] as const; + if (!validContentTypes.includes(contentType)) { + socket.emit('error', { + event: 'send_file_message', + message: 'contentType must be one of: file, image, video, audio', + }); + return; + } + + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), + }); + + if (!membership) { + socket.emit('error', { + event: 'send_file_message', + message: 'Not a member of this conversation', + }); + return; + } + + // Validate file: must exist, be ready, belong to this conversation, and + // have been uploaded by the sender (access-control). + const file = await db.query.files.findFirst({ + where: eq(files.id, fileId), + }); + + if (!file) { + socket.emit('error', { event: 'send_file_message', message: 'File not found' }); + return; + } + + if (file.status !== 'ready') { + socket.emit('error', { + event: 'send_file_message', + message: 'File is not ready for use', + }); + return; + } + + if (file.conversationId !== conversationId) { + socket.emit('error', { + event: 'send_file_message', + message: 'File does not belong to this conversation', + }); + return; + } + + if (file.uploaderId !== userId) { + socket.emit('error', { + event: 'send_file_message', + message: 'Access denied: you are not the uploader of this file', + }); + return; + } + + const [message] = await db + .insert(messages) + .values({ + conversationId, + senderId: userId, + content: content.trim(), + contentType, + fileId, + }) + .returning(); + + io.to(conversationId).emit('new_message', message); + + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); + + await invalidateConversationCaches(members.map((member) => member.userId)); + }, + ); + // ── message_history ──────────────────────────────────────────────────────── // Payload: { conversationId: string; before?: string } (before = message id cursor) // Returns the last PAGE_SIZE messages, optionally before a cursor for pagination.