Skip to content
Merged
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
1,269 changes: 1,269 additions & 0 deletions meridian-api/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions meridian-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "3.787.0",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0",
Expand Down
23 changes: 23 additions & 0 deletions meridian-api/src/upload/config/upload.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { registerAs } from '@nestjs/config';

/**
* Upload configuration namespace.
*
* Env vars consumed:
* STORAGE_PROVIDER – 'local' (default) | 's3'
* UPLOAD_MAX_SIZE_MB – maximum file size in MB (default: 5)
* UPLOAD_S3_BUCKET – S3 bucket name (required when STORAGE_PROVIDER=s3)
* UPLOAD_S3_REGION – AWS region (required when STORAGE_PROVIDER=s3)
* UPLOAD_S3_ACCESS_KEY_ID – AWS access key (optional; falls back to SDK default chain)
* UPLOAD_S3_SECRET_ACCESS_KEY – AWS secret key (optional; falls back to SDK default chain)
*/
export default registerAs('upload', () => ({
storageProvider: process.env.STORAGE_PROVIDER || 'local',
maxFileSizeMb: Number(process.env.UPLOAD_MAX_SIZE_MB) || 5,
s3: {
bucket: process.env.UPLOAD_S3_BUCKET || '',
region: process.env.UPLOAD_S3_REGION || '',
accessKeyId: process.env.UPLOAD_S3_ACCESS_KEY_ID || '',
secretAccessKey: process.env.UPLOAD_S3_SECRET_ACCESS_KEY || '',
},
}));
95 changes: 95 additions & 0 deletions meridian-api/src/upload/providers/local-storage.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { LocalStorageProvider } from './local-storage.provider';
import * as fs from 'fs';

describe('LocalStorageProvider', () => {
let provider: LocalStorageProvider;

beforeEach(() => {
provider = new LocalStorageProvider();
});

afterEach(() => {
jest.restoreAllMocks();
});

function makeFile(
originalname: string,
overrides?: Partial<Express.Multer.File>,
): Express.Multer.File {
return {
originalname,
mimetype: 'image/png',
buffer: Buffer.from('fake image content'),
size: 18,
...overrides,
} as Express.Multer.File;
}

it('should be defined', () => {
expect(provider).toBeDefined();
});

it('returns a /uploads/ path after a successful write', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);

const result = await provider.uploadFile(makeFile('photo.png'));
expect(result).toMatch(/^\/uploads\/\d+-photo\.png$/);
});

it('creates the uploads directory when it does not exist', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
const mkdirSpy = jest
.spyOn(fs, 'mkdirSync')
.mockImplementation(() => undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);

await provider.uploadFile(makeFile('photo.png'));
expect(mkdirSpy).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
expect.objectContaining({ recursive: true, mode: 0o755 }),
);
});

it('sanitizes path traversal sequences in the filename', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);

const result = await provider.uploadFile(makeFile('../../unsafe.png'));
expect(result).not.toContain('..');
expect(result).not.toContain('/uploads/../');
});

it('sanitizes special characters from the filename', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);

const result = await provider.uploadFile(
makeFile('../../unsafe-name#$.png'),
);
expect(result).not.toContain('#');
expect(result).not.toContain('$');
expect(result).not.toContain('..');
expect(result).toMatch(/\.png$/);
});

it('strips double extensions from the filename', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);

const result = await provider.uploadFile(makeFile('malware.php.png'));
expect(result).toMatch(/\.png$/);
expect(result).not.toContain('.php.');
});

it('wraps unexpected write errors in InternalServerErrorException', async () => {
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest
.spyOn(fs.promises, 'writeFile')
.mockRejectedValue(new Error('disk full'));

await expect(provider.uploadFile(makeFile('test.png'))).rejects.toThrow(
'Local file storage failed: disk full',
);
});
});
52 changes: 52 additions & 0 deletions meridian-api/src/upload/providers/local-storage.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { StorageProvider } from '../storage-provider.interface';
import { sanitizeFilename } from '../utils/sanitize-filename';
import * as fs from 'fs';
import * as path from 'path';

/**
* Stores uploaded files on the local filesystem under <cwd>/uploads/.
*
* Security measures:
* - Filenames are sanitized via sanitizeFilename() (path traversal, double
* extensions, special characters).
* - The resolved destination path is verified to remain inside the uploads
* directory before writing (belt-and-suspenders path traversal check).
* - The uploads directory is created with mode 0o755 if it does not yet exist.
*/
@Injectable()
export class LocalStorageProvider implements StorageProvider {
async uploadFile(file: Express.Multer.File): Promise<string> {
try {
const uploadDir = path.join(process.cwd(), 'uploads');

// Ensure directory exists with safe permissions (not world-writable).
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true, mode: 0o755 });
}

const uniqueName = sanitizeFilename(file.originalname);
const filePath = path.join(uploadDir, uniqueName);

// Final path-traversal guard: resolved path must stay inside uploadDir.
const resolvedPath = path.resolve(filePath);
const resolvedUploadDir = path.resolve(uploadDir);

if (!resolvedPath.startsWith(resolvedUploadDir + path.sep) &&
resolvedPath !== resolvedUploadDir) {
throw new InternalServerErrorException(
'Invalid file path detected (potential path traversal)',
);
}

await fs.promises.writeFile(filePath, file.buffer);

return `/uploads/${uniqueName}`;
} catch (error) {
if (error instanceof InternalServerErrorException) throw error;
throw new InternalServerErrorException(
`Local file storage failed: ${(error as Error).message}`,
);
}
}
}
111 changes: 111 additions & 0 deletions meridian-api/src/upload/providers/s3-storage.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { S3StorageProvider } from './s3-storage.provider';
import { ConfigService } from '@nestjs/config';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

// Mock the entire AWS SDK S3 client so tests run without real credentials.
jest.mock('@aws-sdk/client-s3', () => {
const sendMock = jest.fn().mockResolvedValue({});
return {
S3Client: jest.fn().mockImplementation(() => ({ send: sendMock })),
PutObjectCommand: jest.fn().mockImplementation((input) => input),
__sendMock: sendMock,
};
});

function makeConfigService(overrides: Record<string, string> = {}): ConfigService {
return {
get: jest.fn((key: string) => {
const defaults: Record<string, string> = {
UPLOAD_S3_BUCKET: 'my-test-bucket',
UPLOAD_S3_REGION: 'us-west-2',
UPLOAD_S3_ACCESS_KEY_ID: '',
UPLOAD_S3_SECRET_ACCESS_KEY: '',
};
return overrides[key] ?? defaults[key] ?? null;
}),
} as unknown as ConfigService;
}

function makeFile(originalname: string): Express.Multer.File {
return {
originalname,
mimetype: 'image/png',
buffer: Buffer.from('fake png data'),
size: 13,
} as Express.Multer.File;
}

describe('S3StorageProvider', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should be defined', () => {
const provider = new S3StorageProvider(makeConfigService());
expect(provider).toBeDefined();
});

it('returns a correctly formatted S3 URL with a sanitized filename', async () => {
const provider = new S3StorageProvider(makeConfigService());
const result = await provider.uploadFile(makeFile('hello world!.png'));

expect(result).toMatch(
/^https:\/\/my-test-bucket\.s3\.us-west-2\.amazonaws\.com\/.+\.png$/,
);
expect(result).not.toContain(' ');
expect(result).not.toContain('!');
});

it('calls PutObjectCommand with correct Bucket, Key, Body, and ContentType', async () => {
const provider = new S3StorageProvider(makeConfigService());
await provider.uploadFile(makeFile('document.pdf'));

expect(PutObjectCommand).toHaveBeenCalledWith(
expect.objectContaining({
Bucket: 'my-test-bucket',
ContentType: 'image/png',
Body: expect.any(Buffer),
Key: expect.stringMatching(/\.pdf$/),
}),
);
});

it('throws InternalServerErrorException when bucket is missing', async () => {
const provider = new S3StorageProvider(
makeConfigService({ UPLOAD_S3_BUCKET: '', UPLOAD_S3_REGION: 'us-east-1' }),
);
await expect(provider.uploadFile(makeFile('test.png'))).rejects.toThrow(
'S3 storage is not properly configured (missing bucket or region)',
);
});

it('throws InternalServerErrorException when region is missing', async () => {
const provider = new S3StorageProvider(
makeConfigService({ UPLOAD_S3_BUCKET: 'bucket', UPLOAD_S3_REGION: '' }),
);
await expect(provider.uploadFile(makeFile('test.png'))).rejects.toThrow(
'S3 storage is not properly configured (missing bucket or region)',
);
});

it('wraps SDK errors in InternalServerErrorException', async () => {
// Make the mocked send() throw on the next call.
const { __sendMock } = jest.requireMock('@aws-sdk/client-s3') as {
__sendMock: jest.Mock;
};
__sendMock.mockRejectedValueOnce(new Error('Network timeout'));

const provider = new S3StorageProvider(makeConfigService());
await expect(provider.uploadFile(makeFile('test.png'))).rejects.toThrow(
'S3 file storage failed: Network timeout',
);
});

it('strips double extensions from the S3 key', async () => {
const provider = new S3StorageProvider(makeConfigService());
const result = await provider.uploadFile(makeFile('malware.php.png'));

expect(result).toMatch(/\.png$/);
expect(result).not.toContain('.php.');
});
});
82 changes: 82 additions & 0 deletions meridian-api/src/upload/providers/s3-storage.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
PutObjectCommandInput,
} from '@aws-sdk/client-s3';
import { StorageProvider } from '../storage-provider.interface';
import { sanitizeFilename } from '../utils/sanitize-filename';

/**
* Uploads files to an S3-compatible bucket.
*
* Required env vars:
* UPLOAD_S3_BUCKET – target bucket name
* UPLOAD_S3_REGION – AWS region
*
* Optional env vars (fall back to the AWS SDK default credential chain):
* UPLOAD_S3_ACCESS_KEY_ID
* UPLOAD_S3_SECRET_ACCESS_KEY
*
* The provider is compatible with any S3-compatible service (MinIO, Cloudflare
* R2, etc.) as long as the standard AWS SDK credential/config env vars are set.
*/
@Injectable()
export class S3StorageProvider implements StorageProvider {
private readonly s3Client: S3Client;
private readonly bucket: string;
private readonly region: string;

constructor(private readonly configService: ConfigService) {
this.bucket = this.configService.get<string>('UPLOAD_S3_BUCKET') ?? '';
this.region = this.configService.get<string>('UPLOAD_S3_REGION') ?? '';

const accessKeyId =
this.configService.get<string>('UPLOAD_S3_ACCESS_KEY_ID') ?? '';
const secretAccessKey =
this.configService.get<string>('UPLOAD_S3_SECRET_ACCESS_KEY') ?? '';

const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
region: this.region || 'us-east-1',
};

// Only supply explicit credentials when both values are present; otherwise
// fall back to the SDK's default credential provider chain (env vars, ~/.aws
// config, IAM role, etc.).
if (accessKeyId && secretAccessKey) {
clientConfig.credentials = { accessKeyId, secretAccessKey };
}

this.s3Client = new S3Client(clientConfig);
}

async uploadFile(file: Express.Multer.File): Promise<string> {
if (!this.bucket || !this.region) {
throw new InternalServerErrorException(
'S3 storage is not properly configured (missing bucket or region)',
);
}

try {
const key = sanitizeFilename(file.originalname);

const params: PutObjectCommandInput = {
Bucket: this.bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
// Do NOT set ACL here — bucket policies are the recommended approach.
};

await this.s3Client.send(new PutObjectCommand(params));

return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
} catch (error) {
if (error instanceof InternalServerErrorException) throw error;
throw new InternalServerErrorException(
`S3 file storage failed: ${(error as Error).message}`,
);
}
}
}
5 changes: 5 additions & 0 deletions meridian-api/src/upload/storage-provider.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Express } from 'express';

export interface StorageProvider {
uploadFile(file: Express.Multer.File): Promise<string>;
}
Loading
Loading