Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/backend/src/__tests__/objectStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

Check failure on line 1 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

'afterEach' is defined but never used
import { S3ObjectStore } from '../lib/objectStore.js';
import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';

Check failure on line 3 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

'S3Client' is defined but never used
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<typeof import('@aws-sdk/client-s3')>();

// Custom mock client to track configuration and calls
class MockS3Client {
public config: any;

Check warning on line 12 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
public send: any;

Check warning on line 13 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

constructor(config: any) {

Check warning on line 15 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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;

Check warning on line 40 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
const key = (command as any).input.Key;

Check warning on line 41 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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;

Check warning on line 60 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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;

Check warning on line 77 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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;

Check warning on line 96 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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,

Check warning on line 125 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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,

Check warning on line 136 in apps/backend/src/__tests__/objectStore.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
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));
});
});
});
12 changes: 12 additions & 0 deletions apps/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof EnvSchema>;
Expand Down
153 changes: 153 additions & 0 deletions apps/backend/src/lib/objectStore.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

/**
* Generates a presigned download (GET) URL.
* Allows clients to download encrypted ciphertext directly.
*/
getDownloadUrl(key: string, expiresInSeconds?: number): Promise<string>;

/**
* 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<string, string> } | null>;

/**
* Deletes an encrypted object from the store.
*/
delete(key: string): Promise<void>;
}

/**
* 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<string> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.client, command, { expiresIn: expiresInSeconds });
}

async getDownloadUrl(key: string, expiresInSeconds = 300): Promise<string> {
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<string, string> } | 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<void> {
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,
});
14 changes: 2 additions & 12 deletions apps/backend/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' });
Expand Down
Loading