diff --git a/apps/backend/src/__tests__/objectStore.test.ts b/apps/backend/src/__tests__/objectStore.test.ts new file mode 100644 index 0000000..f4eed32 --- /dev/null +++ b/apps/backend/src/__tests__/objectStore.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { S3ObjectStore } from '../lib/objectStore.js'; +import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +// Mock `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` +vi.mock('@aws-sdk/client-s3', async (importOriginal) => { + const original = await importOriginal(); + + // Custom mock client to track configuration and calls + class MockS3Client { + public config: any; + public send: any; + + constructor(config: any) { + this.config = config; + // We will re-assign this fn inside the test or use a global spy + this.send = vi.fn().mockImplementation(async (command) => { + if (command instanceof HeadObjectCommand) { + return { + ContentLength: 1024, + ContentType: 'application/octet-stream', + Metadata: { encrypted: 'true' }, + }; + } + return {}; + }); + } + } + + return { + ...original, + S3Client: MockS3Client, + }; +}); + +vi.mock('@aws-sdk/s3-request-presigner', () => { + return { + getSignedUrl: vi.fn().mockImplementation(async (client, command, options) => { + const bucket = (command as any).input.Bucket; + const key = (command as any).input.Key; + const expires = options?.expiresIn || 300; + return `https://mock-s3-presigned-url/${bucket}/${key}?expires=${expires}`; + }), + }; +}); + +describe('S3ObjectStore Client Wrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Configuration & Initialization', () => { + it('initializes with standard AWS S3 configuration', () => { + const store = new S3ObjectStore({ + bucket: 'prod-bucket', + region: 'us-west-2', + }); + + const client = (store as any).client; + expect(client.config.region).toBe('us-west-2'); + expect(client.config.endpoint).toBeUndefined(); + expect(client.config.credentials).toBeUndefined(); + expect(client.config.forcePathStyle).toBeUndefined(); + }); + + it('initializes with MinIO specific configuration (forcePathStyle, custom endpoint)', () => { + const store = new S3ObjectStore({ + bucket: 'minio-bucket', + endpoint: 'http://127.0.0.1:9000', + region: 'us-east-1', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadminsecret', + forcePathStyle: true, + }); + + const client = (store as any).client; + expect(client.config.region).toBe('us-east-1'); + expect(client.config.endpoint).toBe('http://127.0.0.1:9000'); + expect(client.config.forcePathStyle).toBe(true); + expect(client.config.credentials).toEqual({ + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadminsecret', + }); + }); + + it('initializes with Cloudflare R2 specific configuration', () => { + const store = new S3ObjectStore({ + bucket: 'r2-bucket', + endpoint: 'https://xyz.r2.cloudflarestorage.com', + region: 'auto', + accessKeyId: 'r2-key', + secretAccessKey: 'r2-secret', + }); + + const client = (store as any).client; + expect(client.config.region).toBe('auto'); + expect(client.config.endpoint).toBe('https://xyz.r2.cloudflarestorage.com'); + expect(client.config.credentials).toEqual({ + accessKeyId: 'r2-key', + secretAccessKey: 'r2-secret', + }); + }); + + it('throws an error if bucket is not provided', () => { + expect(() => new S3ObjectStore({ bucket: '' })).toThrow('S3 bucket name is required.'); + }); + }); + + describe('Operations', () => { + let store: S3ObjectStore; + + beforeEach(() => { + store = new S3ObjectStore({ + bucket: 'test-bucket', + region: 'us-east-1', + }); + }); + + it('generates a presigned upload URL (PUT)', async () => { + const url = await store.getUploadUrl('encrypted-file-123.bin', 3600); + + expect(url).toContain('https://mock-s3-presigned-url/test-bucket/encrypted-file-123.bin?expires=3600'); + expect(getSignedUrl).toHaveBeenCalledWith( + (store as any).client, + expect.any(PutObjectCommand), + { expiresIn: 3600 } + ); + }); + + it('generates a presigned download URL (GET)', async () => { + const url = await store.getDownloadUrl('encrypted-file-123.bin', 600); + + expect(url).toContain('https://mock-s3-presigned-url/test-bucket/encrypted-file-123.bin?expires=600'); + expect(getSignedUrl).toHaveBeenCalledWith( + (store as any).client, + expect.any(GetObjectCommand), + { expiresIn: 600 } + ); + }); + + it('retrieves metadata via head operation successfully', async () => { + const metadata = await store.head('encrypted-file-123.bin'); + + expect(metadata).toEqual({ + contentLength: 1024, + contentType: 'application/octet-stream', + metadata: { encrypted: 'true' }, + }); + expect((store as any).client.send).toHaveBeenCalledWith(expect.any(HeadObjectCommand)); + }); + + it('returns null on head operation if object is not found (404 / NotFound)', async () => { + // Stub send to throw a NotFound error + const notFoundError = new Error('Not Found'); + notFoundError.name = 'NotFound'; + (store as any).client.send.mockRejectedValueOnce(notFoundError); + + const metadata = await store.head('missing-file.bin'); + + expect(metadata).toBeNull(); + expect((store as any).client.send).toHaveBeenCalledWith(expect.any(HeadObjectCommand)); + }); + + it('returns null on head operation if object is not found (NoSuchKey)', async () => { + const noSuchKeyError = new Error('NoSuchKey'); + (noSuchKeyError as any).code = 'NoSuchKey'; + (store as any).client.send.mockRejectedValueOnce(noSuchKeyError); + + const metadata = await store.head('missing-file.bin'); + + expect(metadata).toBeNull(); + }); + + it('throws other errors from head operation', async () => { + const forbiddenError = new Error('Access Denied'); + forbiddenError.name = 'Forbidden'; + (store as any).client.send.mockRejectedValueOnce(forbiddenError); + + await expect(store.head('encrypted-file-123.bin')).rejects.toThrow('Access Denied'); + }); + + it('deletes an object successfully', async () => { + await store.delete('encrypted-file-123.bin'); + + expect((store as any).client.send).toHaveBeenCalledWith(expect.any(DeleteObjectCommand)); + }); + }); +}); diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index ce53b6c..9347dc2 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -11,6 +11,18 @@ export const EnvSchema = z.object({ JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), PORT: z.coerce.number().int('PORT must be an integer').positive('PORT must be positive'), TOKEN_TRANSFER_CONTRACT_ID: z.string().min(1, 'TOKEN_TRANSFER_CONTRACT_ID is required'), + S3_ENDPOINT: z.string().optional(), + S3_REGION: z.string().optional(), + S3_ACCESS_KEY_ID: z.string().optional(), + S3_SECRET_ACCESS_KEY: z.string().optional(), + S3_BUCKET: z.string().optional(), + S3_FORCE_PATH_STYLE: z + .preprocess((val) => { + if (val === 'true' || val === true) return true; + if (val === 'false' || val === false) return false; + return undefined; + }, z.boolean()) + .optional(), }); export type Env = z.infer; diff --git a/apps/backend/src/lib/objectStore.ts b/apps/backend/src/lib/objectStore.ts new file mode 100644 index 0000000..33768c2 --- /dev/null +++ b/apps/backend/src/lib/objectStore.ts @@ -0,0 +1,153 @@ +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +/** + * Configuration options for the S3-compatible ObjectStore. + */ +export interface ObjectStoreConfig { + bucket: string; + endpoint?: string; + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + forcePathStyle?: boolean; +} + +/** + * Provider-agnostic interface for storage clients. + * This guarantees interchangeability between MinIO, R2, and S3. + * + * NOTE: All bytes that touch this layer MUST be already-encrypted ciphertext. + * This layer MUST NEVER accept, store, or process plaintext data. + */ +export interface ObjectStore { + /** + * Generates a presigned upload (PUT) URL. + * Allows clients to upload encrypted ciphertext directly. + */ + getUploadUrl(key: string, expiresInSeconds?: number): Promise; + + /** + * Generates a presigned download (GET) URL. + * Allows clients to download encrypted ciphertext directly. + */ + getDownloadUrl(key: string, expiresInSeconds?: number): Promise; + + /** + * Retrieves metadata and checks existence of an encrypted object. + * Returns null if the object is not found. + */ + head(key: string): Promise<{ contentLength?: number; contentType?: string; metadata?: Record } | null>; + + /** + * Deletes an encrypted object from the store. + */ + delete(key: string): Promise; +} + +/** + * S3-compatible implementation of ObjectStore. + */ +export class S3ObjectStore implements ObjectStore { + private client: S3Client; + private bucket: string; + + constructor(config: ObjectStoreConfig) { + if (!config.bucket) { + throw new Error('S3 bucket name is required.'); + } + this.bucket = config.bucket; + + const s3Config: any = { + region: config.region || 'us-east-1', + }; + + if (config.endpoint) { + s3Config.endpoint = config.endpoint; + } + + if (config.forcePathStyle !== undefined) { + s3Config.forcePathStyle = config.forcePathStyle; + } + + if (config.accessKeyId && config.secretAccessKey) { + s3Config.credentials = { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }; + } + + this.client = new S3Client(s3Config); + } + + async getUploadUrl(key: string, expiresInSeconds = 3600): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds }); + } + + async getDownloadUrl(key: string, expiresInSeconds = 300): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds }); + } + + async head(key: string): Promise<{ contentLength?: number; contentType?: string; metadata?: Record } | null> { + try { + const command = new HeadObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + const response = await this.client.send(command); + return { + contentLength: response.ContentLength, + contentType: response.ContentType, + metadata: response.Metadata, + }; + } catch (error: any) { + if ( + error.name === 'NotFound' || + error.$metadata?.httpStatusCode === 404 || + error.code === 'NoSuchKey' + ) { + return null; + } + throw error; + } + } + + async delete(key: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + await this.client.send(command); + } +} + +// Instantiate the singleton using environment variables with fallback support +const bucket = process.env['S3_BUCKET'] || process.env['AWS_BUCKET'] || 'clicked-files'; +const endpoint = process.env['S3_ENDPOINT']; +const region = process.env['S3_REGION'] || process.env['AWS_REGION'] || 'us-east-1'; +const accessKeyId = process.env['S3_ACCESS_KEY_ID'] || process.env['AWS_ACCESS_KEY_ID']; +const secretAccessKey = process.env['S3_SECRET_ACCESS_KEY'] || process.env['AWS_SECRET_ACCESS_KEY']; +const forcePathStyle = process.env['S3_FORCE_PATH_STYLE'] === 'true' || process.env['AWS_FORCE_PATH_STYLE'] === 'true'; + +export const objectStore = new S3ObjectStore({ + bucket, + endpoint, + region, + accessKeyId, + secretAccessKey, + forcePathStyle, +}); diff --git a/apps/backend/src/routes/files.ts b/apps/backend/src/routes/files.ts index ee50cd4..8533412 100644 --- a/apps/backend/src/routes/files.ts +++ b/apps/backend/src/routes/files.ts @@ -4,17 +4,11 @@ import { eq, and } from 'drizzle-orm'; import { db } from '../db/index.js'; import { messages, conversationMembers } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; -import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { objectStore } from '../lib/objectStore.js'; export const filesRouter: IRouter = Router(); filesRouter.use(requireAuth); -const s3 = new S3Client({ - region: process.env['AWS_REGION'] || 'us-east-1', -}); -const bucketName = process.env['AWS_BUCKET'] || 'clicked-files'; - filesRouter.get('/:fileId', async (req: AuthRequest, res) => { const userId = req.auth!.userId; const fileId = req.params['fileId'] as string; @@ -48,12 +42,8 @@ filesRouter.get('/:fileId', async (req: AuthRequest, res) => { } try { - const command = new GetObjectCommand({ - Bucket: bucketName, - Key: fileId, - }); // Short-lived URL: 5 minutes - const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 300 }); + const presignedUrl = await objectStore.getDownloadUrl(fileId, 300); res.json({ url: presignedUrl }); } catch { res.status(500).json({ error: 'Failed to generate download URL' });