Integrate Oudelaa backend features, security, tests, and deployment updates

This commit is contained in:
boutmoun123
2026-07-26 16:58:53 +03:00
parent 1b24ca4294
commit 2fd5322ef7
178 changed files with 19068 additions and 2592 deletions
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const REQUEST_TIMEOUT_METADATA = 'request_timeout_ms';
/** Override the global request timeout for a controller or a single route. */
export const RequestTimeout = (timeoutMs: number) =>
SetMetadata(REQUEST_TIMEOUT_METADATA, timeoutMs);
/** Disable the application-level timeout for a route that intentionally streams indefinitely. */
export const DisableRequestTimeout = () => SetMetadata(REQUEST_TIMEOUT_METADATA, 0);
@@ -2,6 +2,7 @@ export enum NotificationType {
LIKE = 'like',
COMMENT = 'comment',
FOLLOW = 'follow',
FOLLOW_REQUEST_APPROVED = 'follow_request_approved',
MESSAGE = 'message',
SAVE = 'save',
SHARE = 'share',
@@ -0,0 +1,137 @@
import {
ExecutionContext,
ForbiddenException,
HttpException,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { MultipartFormDataGuard } from './multipart-form-data.guard';
import { RolesGuard } from './roles.guard';
import { SuperAdminPermissionsGuard } from './superadmin-permissions.guard';
import { ThrottleGuard } from './throttle.guard';
const contextFor = (request: Record<string, any>): ExecutionContext =>
({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => request }),
}) as unknown as ExecutionContext;
describe('authorization guards', () => {
describe('RolesGuard', () => {
it('allows routes without role metadata', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
const guard = new RolesGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({}))).toBe(true);
});
it.each([
[{ role: 'admin' }, true],
[{ roles: ['support', 'admin'] }, true],
[{ role: 'user' }, false],
[{}, false],
])('evaluates normalized payload roles %#', (user, expected) => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(['admin']) };
const guard = new RolesGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({ user }))).toBe(expected);
});
});
describe('SuperAdminPermissionsGuard', () => {
it('allows routes without permission requirements', () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue([]) };
const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector);
expect(guard.canActivate(contextFor({}))).toBe(true);
});
it('requires every declared permission', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(['users.read', 'users.manage']),
};
const guard = new SuperAdminPermissionsGuard(reflector as unknown as Reflector);
expect(
guard.canActivate(
contextFor({ user: { permissions: ['users.read', 'users.manage', 'audit.read'] } }),
),
).toBe(true);
expect(() =>
guard.canActivate(contextFor({ user: { permissions: ['users.read'] } })),
).toThrow(ForbiddenException);
expect(() => guard.canActivate(contextFor({}))).toThrow(
'Missing superadmin permission',
);
});
});
describe('ThrottleGuard', () => {
it('does not touch cache for an unthrottled route', async () => {
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(undefined) };
const cache = { incr: jest.fn() };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(guard.canActivate(contextFor({}))).resolves.toBe(true);
expect(cache.incr).not.toHaveBeenCalled();
});
it('uses authenticated subject and a minimum one-second window', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 2, windowMs: 10 }),
};
const cache = { incr: jest.fn().mockResolvedValue(2) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(
guard.canActivate(
contextFor({
user: { sub: 'user-1' },
ip: '127.0.0.1',
baseUrl: '/api/v1/auth',
route: { path: '/login' },
}),
),
).resolves.toBe(true);
expect(cache.incr).toHaveBeenCalledWith(
'rate-limit:/api/v1/auth/login:user-1',
1,
);
});
it('falls back to IP/original URL and rejects requests above the limit', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_500 }),
};
const cache = { incr: jest.fn().mockResolvedValue(2) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(
guard.canActivate(contextFor({ ip: '10.0.0.2', originalUrl: '/fallback' })),
).rejects.toBeInstanceOf(HttpException);
expect(cache.incr).toHaveBeenCalledWith('rate-limit:/fallback:10.0.0.2', 2);
});
it('uses stable unknown placeholders if request identity and route are absent', async () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue({ limit: 1, windowMs: 1_000 }),
};
const cache = { incr: jest.fn().mockResolvedValue(1) };
const guard = new ThrottleGuard(reflector as any, cache as any);
await expect(guard.canActivate(contextFor({}))).resolves.toBe(true);
expect(cache.incr).toHaveBeenCalledWith('rate-limit:unknown-route:unknown', 1);
});
});
describe('MultipartFormDataGuard', () => {
it('accepts multipart requests and rejects other media types', () => {
const guard = new MultipartFormDataGuard();
expect(guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(true) }))).toBe(true);
expect(() =>
guard.canActivate(contextFor({ is: jest.fn().mockReturnValue(false) })),
).toThrow(UnsupportedMediaTypeException);
});
});
});
@@ -0,0 +1,29 @@
import { CallHandler, ExecutionContext } from '@nestjs/common';
import { firstValueFrom, of } from 'rxjs';
import { ResponseEnvelopeInterceptor } from './response-envelope.interceptor';
describe('ResponseEnvelopeInterceptor', () => {
it('wraps data with response status and timestamp', async () => {
const context = {
switchToHttp: () => ({ getResponse: () => ({ statusCode: 201 }) }),
} as unknown as ExecutionContext;
const next = { handle: () => of({ id: 'one' }) } as CallHandler;
const value = await firstValueFrom(new ResponseEnvelopeInterceptor().intercept(context, next));
expect(value).toEqual({
data: { id: 'one' },
meta: { statusCode: 201, timestamp: expect.any(String) },
});
});
it('uses HTTP 200 when an adapter does not expose a status', async () => {
const context = {
switchToHttp: () => ({ getResponse: () => ({}) }),
} as unknown as ExecutionContext;
const value = await firstValueFrom(
new ResponseEnvelopeInterceptor().intercept(context, { handle: () => of(null) }),
);
expect(value).toEqual(
expect.objectContaining({ meta: expect.objectContaining({ statusCode: 200 }) }),
);
});
});
+54 -8
View File
@@ -7,6 +7,7 @@ export type MediaUploadFile = {
originalname?: string;
mimetype?: string;
size?: number;
buffer?: Buffer;
};
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;
@@ -168,17 +169,62 @@ function resolveMediaExtensionOrThrow(file: MediaUploadFile, type: AllowedMediaT
function resolveMediaExtension(file: MediaUploadFile, type: AllowedMediaType): string | null {
const extension = getFileExtension(file.originalname);
if (EXTENSIONS_BY_TYPE[type].includes(extension)) {
return extension;
}
const mimetype = file.mimetype?.toLowerCase();
if (mimetype && EXTENSION_BY_MIMETYPE[type][mimetype]) {
return EXTENSION_BY_MIMETYPE[type][mimetype];
const mimeExtension = mimetype ? EXTENSION_BY_MIMETYPE[type][mimetype] : undefined;
const extensionAllowed = EXTENSIONS_BY_TYPE[type].includes(extension);
if (extension && !extensionAllowed) {
return null;
}
if (mimetype && !mimeExtension) {
return null;
}
if (extensionAllowed && mimeExtension && !extensionsAreCompatible(extension, mimeExtension)) {
return null;
}
// TODO: Add magic-byte sniffing before tightening this contract to require both extension and mimetype.
return null;
const resolved = extensionAllowed ? extension : mimeExtension;
if (!resolved) {
return null;
}
if (file.buffer && file.buffer.length >= 12 && !hasExpectedFileSignature(file.buffer, resolved)) {
return null;
}
return resolved;
}
function extensionsAreCompatible(left: string, right: string): boolean {
const normalize = (value: string) => (value === '.jpeg' ? '.jpg' : value);
return normalize(left) === normalize(right);
}
function hasExpectedFileSignature(buffer: Buffer, extension: string): boolean {
const ascii = buffer.subarray(0, 16).toString('ascii');
switch (extension) {
case '.jpg':
case '.jpeg':
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
case '.png':
return buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
case '.webp':
return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WEBP';
case '.wav':
return ascii.startsWith('RIFF') && ascii.slice(8, 12) === 'WAVE';
case '.ogg':
return ascii.startsWith('OggS');
case '.webm':
return buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3;
case '.mp3':
return ascii.startsWith('ID3') || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0);
case '.aac':
return buffer[0] === 0xff && (buffer[1] & 0xf6) === 0xf0;
case '.m4a':
case '.mp4':
case '.mov':
return ascii.slice(4, 8) === 'ftyp';
default:
return false;
}
}
function defaultInvalidMessage(type: AllowedMediaType): string {
+93
View File
@@ -0,0 +1,93 @@
import {
AllowedMediaType,
AUDIO_MIMETYPES,
IMAGE_MIMETYPES,
MEDIA_MAX_SIZE_BYTES,
VIDEO_MIMETYPES,
} from './allowed-media';
export const DIRECT_UPLOAD_FOLDERS = [
'posts/images',
'posts/video',
'posts/audio',
'users/avatar',
'users/cover',
'chat/media',
'marketplace/images',
'support/images',
'collaboration/audio',
] as const;
export type DirectUploadFolder = (typeof DIRECT_UPLOAD_FOLDERS)[number];
export const DIRECT_UPLOAD_MIME_TYPES = [
...IMAGE_MIMETYPES,
...AUDIO_MIMETYPES,
...VIDEO_MIMETYPES,
] as const;
export type DirectUploadMimeType = (typeof DIRECT_UPLOAD_MIME_TYPES)[number];
type DirectUploadMediaDefinition = {
extension: string;
mediaType: AllowedMediaType;
};
type DirectUploadFolderPolicy = Partial<Record<AllowedMediaType, number>>;
const MEDIA_BY_MIME_TYPE: Record<DirectUploadMimeType, DirectUploadMediaDefinition> = {
'image/jpeg': { extension: '.jpg', mediaType: 'image' },
'image/png': { extension: '.png', mediaType: 'image' },
'image/webp': { extension: '.webp', mediaType: 'image' },
'audio/mpeg': { extension: '.mp3', mediaType: 'audio' },
'audio/mp3': { extension: '.mp3', mediaType: 'audio' },
'audio/mp4': { extension: '.m4a', mediaType: 'audio' },
'audio/x-m4a': { extension: '.m4a', mediaType: 'audio' },
'audio/m4a': { extension: '.m4a', mediaType: 'audio' },
'audio/wav': { extension: '.wav', mediaType: 'audio' },
'audio/x-wav': { extension: '.wav', mediaType: 'audio' },
'audio/wave': { extension: '.wav', mediaType: 'audio' },
'audio/aac': { extension: '.aac', mediaType: 'audio' },
'audio/ogg': { extension: '.ogg', mediaType: 'audio' },
'audio/webm': { extension: '.webm', mediaType: 'audio' },
'video/mp4': { extension: '.mp4', mediaType: 'video' },
'video/quicktime': { extension: '.mov', mediaType: 'video' },
'video/webm': { extension: '.webm', mediaType: 'video' },
};
const FOLDER_POLICIES: Record<DirectUploadFolder, DirectUploadFolderPolicy> = {
'posts/images': { image: MEDIA_MAX_SIZE_BYTES.postsImage },
'posts/video': { video: MEDIA_MAX_SIZE_BYTES.postsVideo },
'posts/audio': { audio: MEDIA_MAX_SIZE_BYTES.postsAudio },
'users/avatar': { image: MEDIA_MAX_SIZE_BYTES.userImage },
'users/cover': { image: MEDIA_MAX_SIZE_BYTES.userImage },
'chat/media': {
image: MEDIA_MAX_SIZE_BYTES.postsImage,
audio: MEDIA_MAX_SIZE_BYTES.postsAudio,
video: MEDIA_MAX_SIZE_BYTES.postsVideo,
},
'marketplace/images': { image: MEDIA_MAX_SIZE_BYTES.marketplaceImage },
'support/images': { image: MEDIA_MAX_SIZE_BYTES.supportImage },
'collaboration/audio': { audio: MEDIA_MAX_SIZE_BYTES.collaborationAudio },
};
export type ResolvedDirectUploadPolicy = DirectUploadMediaDefinition & {
maxSizeBytes: number;
};
export function resolveDirectUploadPolicy(
folder: string,
mimeType: string,
): ResolvedDirectUploadPolicy | null {
if (!DIRECT_UPLOAD_FOLDERS.includes(folder as DirectUploadFolder)) {
return null;
}
const media = MEDIA_BY_MIME_TYPE[mimeType as DirectUploadMimeType];
if (!media) {
return null;
}
const maxSizeBytes = FOLDER_POLICIES[folder as DirectUploadFolder][media.mediaType];
return maxSizeBytes ? { ...media, maxSizeBytes } : null;
}
@@ -6,6 +6,7 @@ import { MediaStorageService } from './media-storage.service';
describe('HlsPlaylistService', () => {
const storageService = {
shouldSignResponseUrls: jest.fn(() => true),
isValidHlsPlaylistAccess: jest.fn(() => true),
getBucketName: jest.fn(() => 'oudelaa'),
isManagedKey: jest.fn((key: string) => key.startsWith('uploads/')),
getObjectText: jest.fn(),
@@ -23,6 +24,16 @@ describe('HlsPlaylistService', () => {
beforeEach(() => {
jest.clearAllMocks();
(storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(true);
});
it('rejects an invalid or expired playlist access signature', async () => {
(storageService.isValidHlsPlaylistAccess as jest.Mock).mockReturnValue(false);
await expect(
service.rewritePlaylist('uploads/posts/hls/stream-1/master.m3u8'),
).rejects.toThrow(BadRequestException);
expect(storageService.getObjectText).not.toHaveBeenCalled();
});
it('rewrites master playlist variants through the backend endpoint', async () => {
+7 -1
View File
@@ -14,12 +14,18 @@ export class HlsPlaylistService {
private readonly appLogger: AppLoggerService,
) {}
async rewritePlaylist(requestedPath: string): Promise<string> {
async rewritePlaylist(
requestedPath: string,
access?: { expires?: string; signature?: string },
): Promise<string> {
if (!this.mediaStorageService.shouldSignResponseUrls()) {
throw new BadRequestException('Private HLS rewrite is disabled');
}
const key = this.normalizePlaylistKey(requestedPath);
if (!this.mediaStorageService.isValidHlsPlaylistAccess(key, access?.expires, access?.signature)) {
throw new BadRequestException('Invalid or expired HLS access signature');
}
const bucket = this.mediaStorageService.getBucketName();
const startedAt = Date.now();
this.appLogger.log(
@@ -0,0 +1,155 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';
jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn() }));
type StorageInternals = {
getS3Client(): { send(command: unknown): Promise<any> };
getSignedUrlExpiresSeconds(): number;
cacheSignedUrl(key: string, url: string, seconds: number): void;
normalizeFolder(folder: string): string;
normalizeUserSegment(user: string): string;
normalizeKey(key: string): string;
};
const internals = (service: MediaStorageService): StorageInternals =>
service as unknown as StorageInternals;
describe('MediaStorageService behavior', () => {
const values: Record<string, unknown> = {
'storage.provider': 's3', 'storage.basePath': '/uploads/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
'security.refreshTokenHashSecret': 'signing-secret',
};
const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
const logger = { error: jest.fn() } as unknown as AppLoggerService;
let send: jest.Mock;
let service: MediaStorageService;
beforeEach(() => {
jest.clearAllMocks();
Object.assign(values, {
'storage.provider': 's3', 'storage.basePath': '/uploads/',
'storage.s3.bucket': 'bucket', 'storage.s3.endpoint': 'https://s3.test/',
'storage.s3.accessKeyId': 'key', 'storage.s3.secretAccessKey': 'secret',
'storage.s3.forcePathStyle': true, 'storage.publicBaseUrl': 'https://cdn.test/',
'storage.mediaAccessMode': 'public', publicBaseUrl: 'https://api.test/', globalPrefix: '/api/v2/',
'storage.signedUrlExpiresSeconds': 60, 'security.refreshTokenHashSecret': 'signing-secret',
});
send = jest.fn().mockResolvedValue({});
service = new MediaStorageService(config, logger);
jest.spyOn(internals(service), 'getS3Client').mockReturnValue({ send });
});
it('uploads generated and exact keys with content metadata', async () => {
const result = await service.uploadFile({ buffer: Buffer.from('abc'), originalname: 'photo.JPG', mimetype: 'image/jpeg' }, '/posts\\images/');
expect(result).toEqual(expect.objectContaining({ key: expect.stringMatching(/^uploads\/posts\/images\/media-[0-9a-f-]+\.jpg$/i), url: expect.stringContaining('https://cdn.test/uploads/'), size: 3, originalName: 'photo.JPG' }));
expect(send.mock.calls[0][0]).toBeInstanceOf(PutObjectCommand);
await expect(service.uploadFileToKey({ buffer: Buffer.from('x') }, '/uploads/hls/master.m3u8')).resolves.toEqual(expect.objectContaining({ key: 'uploads/hls/master.m3u8', mimeType: 'application/octet-stream' }));
const command = send.mock.calls[1][0] as PutObjectCommand;
expect(command.input).toEqual(expect.objectContaining({ CacheControl: 'public, max-age=300', Key: 'uploads/hls/master.m3u8' }));
await expect(service.uploadFileToKey({ buffer: Buffer.alloc(0) }, '../bad')).rejects.toBeInstanceOf(BadRequestException);
});
it('deletes valid keys and ignores invalid keys', async () => {
await service.deleteFile('../bad');
expect(send).not.toHaveBeenCalled();
await service.deleteFile('/uploads/file.jpg');
expect(send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand);
});
it('builds public, path-style, and virtual endpoint URLs', () => {
expect(service.getPublicUrl('/uploads/file.jpg')).toBe('https://cdn.test/uploads/file.jpg');
values['storage.publicBaseUrl'] = '';
expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/bucket/uploads/file.jpg');
values['storage.s3.forcePathStyle'] = false;
expect(service.getPublicUrl('uploads/file.jpg')).toBe('https://s3.test/uploads/file.jpg');
expect(() => service.getPublicUrl('../bad')).toThrow(BadRequestException);
});
it('resolves managed keys from relative, CDN, endpoint, and application URLs', () => {
expect(service.resolveKeyFromUrl('/uploads/posts/a.jpg?token=1')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://cdn.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
values['storage.publicBaseUrl'] = '';
expect(service.resolveKeyFromUrl('https://s3.test/bucket/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://api.test/uploads/posts/a.jpg')).toBe('uploads/posts/a.jpg');
expect(service.resolveKeyFromUrl('https://external.test/a.jpg')).toBeNull();
expect(service.resolveKeyFromUrl('not a url')).toBeNull();
});
it('loads object text and validates missing bodies and unsafe keys', async () => {
send.mockResolvedValueOnce({ Body: { transformToString: jest.fn().mockResolvedValue('#EXTM3U') } });
await expect(service.getObjectText('uploads/master.m3u8')).resolves.toBe('#EXTM3U');
expect(send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
send.mockResolvedValueOnce({});
await expect(service.getObjectText('uploads/empty.txt')).rejects.toThrow('object body is empty');
await expect(service.getObjectText('../bad')).rejects.toBeInstanceOf(BadRequestException);
});
it('builds unsigned and signed HLS endpoints and validates signatures', () => {
const unsigned = service.getHlsPlaylistUrl('uploads/folder name/master.m3u8');
expect(unsigned).toBe('https://api.test/api/v2/media/hls/uploads/folder%20name/master.m3u8');
values['storage.mediaAccessMode'] = 'signed';
const signed = service.getHlsPlaylistUrl('uploads/master.m3u8');
const url = new URL(signed);
const expires = url.searchParams.get('expires') ?? '';
const signature = url.searchParams.get('signature') ?? '';
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, signature)).toBe(true);
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', '1', signature)).toBe(false);
expect(service.isValidHlsPlaylistAccess('../bad', expires, signature)).toBe(false);
expect(service.isValidHlsPlaylistAccess('uploads/master.m3u8', expires, 'short')).toBe(false);
expect(() => service.getHlsPlaylistUrl('uploads/video.mp4')).toThrow(BadRequestException);
});
it('caches signed object URLs until their refresh boundary', async () => {
values['storage.mediaAccessMode'] = 'signed';
jest.mocked(getSignedUrl).mockResolvedValue('https://signed.test/one');
await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
await expect(service.getSignedObjectUrl('uploads/a.jpg')).resolves.toBe('https://signed.test/one');
expect(getSignedUrl).toHaveBeenCalledTimes(1);
await expect(service.getSignedObjectUrl('../bad')).resolves.toBe('');
service.onModuleDestroy();
await service.getSignedObjectUrl('uploads/a.jpg');
expect(getSignedUrl).toHaveBeenCalledTimes(2);
});
it('checks managed prefixes and clamps signing expiration', () => {
expect(service.isManagedKey('/uploads/posts/a.jpg')).toBe(true);
expect(service.isManagedKey('private/a.jpg')).toBe(false);
values['storage.signedUrlExpiresSeconds'] = 0;
expect(internals(service).getSignedUrlExpiresSeconds()).toBe(1);
values['storage.signedUrlExpiresSeconds'] = 9999999;
expect(internals(service).getSignedUrlExpiresSeconds()).toBe(604800);
});
it('validates configuration and normalization helpers', () => {
expect(internals(service).normalizeFolder('posts\\images')).toBe('posts/images');
expect(() => internals(service).normalizeFolder('../bad')).toThrow(BadRequestException);
expect(internals(service).normalizeUserSegment(' user_1-2 ')).toBe('user_1-2');
expect(() => internals(service).normalizeUserSegment('bad/user')).toThrow(BadRequestException);
expect(internals(service).normalizeKey('a\\b')).toBe('a/b');
expect(internals(service).normalizeKey('a//b')).toBe('');
values['storage.s3.bucket'] = '';
expect(() => service.getBucketName()).toThrow(BadRequestException);
values['storage.s3.bucket'] = 'bucket';
const unconfigured = new MediaStorageService({ get: jest.fn(() => '') } as unknown as ConfigService, logger);
expect(() => internals(unconfigured).getS3Client()).toThrow(BadRequestException);
});
it('evicts the oldest signing cache entry at its safety limit', () => {
const target = internals(service);
for (let index = 0; index < 10001; index += 1) {
target.cacheSignedUrl(`uploads/${index}`, `url-${index}`, 10);
}
expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.size).toBe(10000);
expect((service as unknown as { signedUrlCache: Map<string, unknown> }).signedUrlCache.has('uploads/0')).toBe(false);
});
});
@@ -0,0 +1,165 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import { MediaStorageService } from './media-storage.service';
jest.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: jest.fn(),
}));
const mockedGetSignedUrl = jest.mocked(getSignedUrl);
describe('MediaStorageService direct uploads', () => {
const config: Record<string, unknown> = {
'storage.provider': 's3',
'storage.mediaAccessMode': 'signed',
'storage.basePath': 'uploads',
'storage.publicBaseUrl': 'https://cdn.oudelaa.test',
'storage.s3.bucket': 'oudelaa-media',
'storage.s3.region': 'us-east-1',
'storage.s3.endpoint': 'https://s3.oudelaa.test',
'storage.s3.accessKeyId': 'test-access-key',
'storage.s3.secretAccessKey': 'test-secret-key',
'storage.s3.forcePathStyle': true,
};
const configService = {
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
} as unknown as ConfigService;
const appLogger = { error: jest.fn() } as unknown as AppLoggerService;
beforeEach(() => {
mockedGetSignedUrl.mockReset();
mockedGetSignedUrl.mockResolvedValue('https://s3.oudelaa.test/presigned-put');
config['storage.provider'] = 's3';
});
it('creates a user-scoped PUT URL with signed MIME and exact content length', async () => {
const service = new MediaStorageService(configService, appLogger);
const result = await service.createPresignedUpload({
userId: '507f1f77bcf86cd799439011',
folder: 'posts/video',
mimeType: 'video/mp4',
size: 5_242_880,
});
expect(result).toEqual(
expect.objectContaining({
method: 'PUT',
uploadUrl: 'https://s3.oudelaa.test/presigned-put',
mediaUrl: expect.stringMatching(
/^https:\/\/cdn\.oudelaa\.test\/uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/,
),
storageKey: expect.stringMatching(
/^uploads\/posts\/video\/507f1f77bcf86cd799439011\/media-[0-9a-f-]+\.mp4$/,
),
mimeType: 'video/mp4',
size: 5_242_880,
requiredHeaders: { 'Content-Type': 'video/mp4' },
expiresIn: 900,
expiresAt: expect.any(String),
}),
);
expect(mockedGetSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
input: expect.objectContaining({
Bucket: 'oudelaa-media',
Key: result.storageKey,
ContentLength: 5_242_880,
ContentType: 'video/mp4',
CacheControl: 'public, max-age=31536000, immutable',
Metadata: {
owner: '507f1f77bcf86cd799439011',
'upload-source': 'direct',
},
}),
}),
{
expiresIn: 900,
signableHeaders: new Set(['content-type']),
},
);
});
it('is disabled for local storage without attempting to sign', async () => {
config['storage.provider'] = 'local';
const service = new MediaStorageService(configService, appLogger);
await expect(
service.createPresignedUpload({
userId: 'user-1',
folder: 'posts/images',
mimeType: 'image/jpeg',
size: 1024,
}),
).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
it('does not rewrite an existing presigned PUT URL as a signed GET URL', async () => {
const service = new MediaStorageService(configService, appLogger);
const uploadUrl =
'https://s3.oudelaa.test/oudelaa-media/uploads/posts/video/file.mp4' +
'?X-Amz-Algorithm=AWS4-HMAC-SHA256' +
'&X-Amz-Credential=test' +
'&X-Amz-Signature=presigned-put';
await expect(service.resolveResponseUrl(uploadUrl)).resolves.toBe(uploadUrl);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
it.each([
{
name: 'a MIME type that does not match the folder',
input: {
userId: 'user-1',
folder: 'users/avatar' as const,
mimeType: 'video/mp4' as const,
size: 1024,
},
},
{
name: 'an oversized image',
input: {
userId: 'user-1',
folder: 'users/avatar' as const,
mimeType: 'image/jpeg' as const,
size: 5 * 1024 * 1024 + 1,
},
},
{
name: 'a zero-byte body',
input: {
userId: 'user-1',
folder: 'posts/audio' as const,
mimeType: 'audio/mpeg' as const,
size: 0,
},
},
{
name: 'an unsafe user segment',
input: {
userId: '../another-user',
folder: 'posts/images' as const,
mimeType: 'image/jpeg' as const,
size: 1024,
},
},
{
name: 'an unsafe folder',
input: {
userId: 'user-1',
folder: '../posts/images' as unknown as 'posts/images',
mimeType: 'image/jpeg' as const,
size: 1024,
},
},
])('rejects $name', async ({ input }) => {
const service = new MediaStorageService(configService, appLogger);
await expect(service.createPresignedUpload(input)).rejects.toBeInstanceOf(BadRequestException);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ describe('MediaStorageService response URLs', () => {
'storage.s3.forcePathStyle': true,
publicBaseUrl: 'https://api.oudelaa.test',
globalPrefix: 'api/v1',
'security.refreshTokenHashSecret': 'test-hls-signing-secret',
};
const configService = {
get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue),
@@ -64,8 +65,8 @@ describe('MediaStorageService response URLs', () => {
await expect(
service.resolveResponseUrl('https://s3.cumin.dev/uploads/posts/hls/stream-1/master.m3u8'),
).resolves.toBe(
'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/master.m3u8',
).resolves.toMatch(
/^https:\/\/api\.oudelaa\.test\/api\/v1\/media\/hls\/uploads\/posts\/hls\/stream-1\/master\.m3u8\?expires=\d+&signature=/,
);
expect(mockedGetSignedUrl).not.toHaveBeenCalled();
});
+142 -4
View File
@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import {
BadRequestException,
Injectable,
OnModuleDestroy,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
@@ -7,9 +12,14 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
import { posix } from 'path';
import { AppLoggerService } from '../../infrastructure/logging/app-logger.service';
import {
DirectUploadFolder,
DirectUploadMimeType,
resolveDirectUploadPolicy,
} from './direct-upload.policy';
import { getFileExtension } from './allowed-media';
export type MediaStorageUploadFile = {
@@ -27,6 +37,27 @@ export type MediaStorageUploadResult = {
originalName: string;
};
export type CreatePresignedMediaUploadInput = {
userId: string;
folder: DirectUploadFolder;
mimeType: DirectUploadMimeType;
size: number;
};
export type PresignedMediaUploadResult = {
method: 'PUT';
uploadUrl: string;
storageKey: string;
mediaUrl: string;
mimeType: DirectUploadMimeType;
size: number;
requiredHeaders: { 'Content-Type': DirectUploadMimeType };
expiresIn: number;
expiresAt: string;
};
const DIRECT_UPLOAD_EXPIRES_SECONDS = 15 * 60;
@Injectable()
export class MediaStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null;
@@ -78,6 +109,64 @@ export class MediaStorageService implements OnModuleDestroy {
};
}
async createPresignedUpload(
input: CreatePresignedMediaUploadInput,
): Promise<PresignedMediaUploadResult> {
if (this.getProvider() !== 's3') {
throw new ServiceUnavailableException(
'Direct media uploads are available only when S3 storage is enabled',
);
}
const policy = resolveDirectUploadPolicy(input.folder, input.mimeType);
if (!policy) {
throw new BadRequestException('Media type is not allowed for this upload folder');
}
if (!Number.isSafeInteger(input.size) || input.size < 1) {
throw new BadRequestException('Media size must be a positive integer');
}
if (input.size > policy.maxSizeBytes) {
throw new BadRequestException(
`Media size exceeds the ${policy.maxSizeBytes}-byte limit for this upload folder`,
);
}
const userSegment = this.normalizeUserSegment(input.userId);
const fileName = `media-${randomUUID()}${policy.extension}`;
const key = posix.join(this.getBasePath(), input.folder, userSegment, fileName);
const uploadUrl = await getSignedUrl(
this.getS3Client(),
new PutObjectCommand({
Bucket: this.getBucket(),
Key: key,
ContentLength: input.size,
ContentType: input.mimeType,
CacheControl: this.resolveCacheControl(key),
Metadata: {
owner: userSegment,
'upload-source': 'direct',
},
}),
{
expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
signableHeaders: new Set(['content-type']),
},
);
const expiresAt = new Date(Date.now() + DIRECT_UPLOAD_EXPIRES_SECONDS * 1000).toISOString();
return {
method: 'PUT',
uploadUrl,
storageKey: key,
mediaUrl: this.getPublicUrl(key),
mimeType: input.mimeType,
size: input.size,
requiredHeaders: { 'Content-Type': input.mimeType },
expiresIn: DIRECT_UPLOAD_EXPIRES_SECONDS,
expiresAt,
};
}
async deleteFile(key: string): Promise<void> {
const normalizedKey = this.normalizeKey(key);
if (!normalizedKey) {
@@ -139,7 +228,11 @@ export class MediaStorageService implements OnModuleDestroy {
}
async resolveResponseUrl(fileUrl: string): Promise<string> {
if (!this.shouldSignResponseUrls() || !fileUrl.trim()) {
if (
!this.shouldSignResponseUrls() ||
!fileUrl.trim() ||
this.isAlreadyPresignedS3Url(fileUrl)
) {
return fileUrl;
}
@@ -236,7 +329,22 @@ export class MediaStorageService implements OnModuleDestroy {
.join('/');
const path = `/${globalPrefix}/media/hls/${encodedKey}`;
return publicBaseUrl ? `${publicBaseUrl}${path}` : path;
const baseUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : path;
if (!this.shouldSignResponseUrls()) return baseUrl;
const expires = Math.floor(Date.now() / 1000) + this.getSignedUrlExpiresSeconds();
const signature = this.signHlsPlaylistKey(normalizedKey, expires);
return `${baseUrl}?expires=${expires}&signature=${encodeURIComponent(signature)}`;
}
isValidHlsPlaylistAccess(key: string, expiresValue?: string, signature?: string): boolean {
const normalizedKey = this.normalizeKey(key);
const expires = Number(expiresValue);
if (!normalizedKey || !Number.isInteger(expires) || expires <= Math.floor(Date.now() / 1000) || !signature) {
return false;
}
const expected = Buffer.from(this.signHlsPlaylistKey(normalizedKey, expires));
const supplied = Buffer.from(signature);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
}
getBucketName(): string {
@@ -253,6 +361,14 @@ export class MediaStorageService implements OnModuleDestroy {
this.signedUrlCache.clear();
}
private signHlsPlaylistKey(key: string, expires: number): string {
const secret =
this.configService.get<string>('security.refreshTokenHashSecret', { infer: true }) ||
this.configService.get<string>('jwt.accessSecret', { infer: true }) ||
'';
return createHmac('sha256', secret).update(`${key}:${expires}`).digest('base64url');
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
@@ -272,6 +388,7 @@ export class MediaStorageService implements OnModuleDestroy {
region: this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto',
endpoint,
forcePathStyle: this.getForcePathStyle(),
requestChecksumCalculation: 'WHEN_REQUIRED',
credentials: {
accessKeyId,
secretAccessKey,
@@ -355,6 +472,19 @@ export class MediaStorageService implements OnModuleDestroy {
);
}
private isAlreadyPresignedS3Url(fileUrl: string): boolean {
try {
const searchParams = new URL(fileUrl).searchParams;
return (
searchParams.has('X-Amz-Algorithm') &&
searchParams.has('X-Amz-Credential') &&
searchParams.has('X-Amz-Signature')
);
} catch {
return false;
}
}
private cacheSignedUrl(key: string, url: string, refreshAfterSeconds: number): void {
if (this.signedUrlCache.size >= 10000) {
const oldestKey = this.signedUrlCache.keys().next().value as string | undefined;
@@ -380,6 +510,14 @@ export class MediaStorageService implements OnModuleDestroy {
return normalized;
}
private normalizeUserSegment(userId: string): string {
const normalized = userId.trim();
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(normalized)) {
throw new BadRequestException('Invalid media owner id');
}
return normalized;
}
private normalizeKey(key: string): string {
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (
+7 -3
View File
@@ -9,10 +9,14 @@ export const decodeOffsetCursor = (cursor?: string): number | null => {
try {
const raw = Buffer.from(cursor, 'base64url').toString('utf8');
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
if (Number.isInteger(parsed) && parsed >= 0) {
return parsed;
}
return parsed;
const payload = JSON.parse(raw) as { offset?: unknown };
return Number.isInteger(payload.offset) && Number(payload.offset) >= 0
? Number(payload.offset)
: null;
} catch {
return null;
}
+22
View File
@@ -0,0 +1,22 @@
import * as bcrypt from 'bcrypt';
import { compareHash, compareStoredHighEntropyValue, hashHighEntropyValue, hashValue } from './hash.util';
describe('hash utilities', () => {
it('hashes and verifies passwords with bcrypt', async () => {
const hash = await hashValue('secret', 4);
await expect(compareHash('secret', hash)).resolves.toBe(true);
await expect(compareHash('wrong', hash)).resolves.toBe(false);
});
it('uses deterministic secret-keyed hashes for high entropy tokens', async () => {
const stored = hashHighEntropyValue('token', 'pepper');
expect(stored).toMatch(/^sha256:[0-9a-f]{64}$/);
await expect(compareStoredHighEntropyValue('token', stored, 'pepper')).resolves.toBe(true);
await expect(compareStoredHighEntropyValue('other', stored, 'pepper')).resolves.toBe(false);
});
it('supports legacy bcrypt token hashes', async () => {
const stored = await bcrypt.hash('legacy-token', 4);
await expect(compareStoredHighEntropyValue('legacy-token', stored, 'unused')).resolves.toBe(true);
});
});
+1
View File
@@ -0,0 +1 @@
export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+33
View File
@@ -0,0 +1,33 @@
import { ConfigService } from '@nestjs/config';
import { buildAiMusicShareUrl, buildPostShareUrl, buildProfileShareUrl, normalizeShareBaseUrl, resolveShareBaseUrl } from './share-url.util';
describe('share URL utilities', () => {
const originalShare = process.env.SHARE_BASE_URL;
const originalPublicWeb = process.env.PUBLIC_WEB_URL;
afterEach(() => {
if (originalShare === undefined) delete process.env.SHARE_BASE_URL;
else process.env.SHARE_BASE_URL = originalShare;
if (originalPublicWeb === undefined) delete process.env.PUBLIC_WEB_URL;
else process.env.PUBLIC_WEB_URL = originalPublicWeb;
});
it('normalizes defaults and trailing slashes', () => {
expect(normalizeShareBaseUrl(undefined)).toBe('https://oudelaa.com');
expect(normalizeShareBaseUrl(' https://web.test/// ')).toBe('https://web.test');
});
it('resolves environment before configuration', () => {
process.env.SHARE_BASE_URL = 'https://env.test/';
const config = { get: jest.fn(() => 'https://config.test') } as unknown as ConfigService;
expect(resolveShareBaseUrl(config)).toBe('https://env.test');
expect(config.get).not.toHaveBeenCalled();
});
it('builds encoded profile, post, and AI music links', () => {
expect(buildProfileShareUrl('https://web', { username: 'a/b' })).toBe('https://web/u/a%2Fb');
expect(buildProfileShareUrl('https://web', { _id: { toString: () => 'id one' } })).toBe('https://web/profile/id%20one');
expect(buildPostShareUrl('https://web', 'post/1')).toBe('https://web/posts/post%2F1');
expect(buildAiMusicShareUrl('https://web', 'track 1')).toBe('https://web/ai/music/track%201');
});
});
+9
View File
@@ -0,0 +1,9 @@
import { verifyTotp } from './totp.util';
describe('verifyTotp', () => {
it('accepts a known code and rejects invalid values', () => {
expect(verifyTotp('JBSWY3DPEHPK3PXP', '282760', 0)).toBe(true);
expect(verifyTotp('JBSWY3DPEHPK3PXP', '000000', 0)).toBe(false);
expect(verifyTotp('not-base32!', '282760', 0)).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { createHmac, timingSafeEqual } from 'crypto';
const decodeBase32 = (value: string): Buffer => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const normalized = value.toUpperCase().replace(/=|\s|-/g, '');
let bits = '';
for (const character of normalized) {
const index = alphabet.indexOf(character);
if (index < 0) return Buffer.alloc(0);
bits += index.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let offset = 0; offset + 8 <= bits.length; offset += 8) {
bytes.push(Number.parseInt(bits.slice(offset, offset + 8), 2));
}
return Buffer.from(bytes);
};
const generateTotp = (secret: Buffer, counter: number): string => {
const message = Buffer.alloc(8);
message.writeBigUInt64BE(BigInt(counter));
const digest = createHmac('sha1', secret).update(message).digest();
const offset = digest[digest.length - 1] & 0x0f;
return ((digest.readUInt32BE(offset) & 0x7fffffff) % 1_000_000)
.toString()
.padStart(6, '0');
};
export const verifyTotp = (base32Secret: string, suppliedCode: string, nowMs = Date.now()): boolean => {
if (!/^\d{6}$/.test(suppliedCode)) return false;
const secret = decodeBase32(base32Secret);
if (!secret.length) return false;
const counter = Math.floor(nowMs / 30_000);
return [-1, 0, 1].filter((drift) => counter + drift >= 0).some((drift) => {
const expected = Buffer.from(generateTotp(secret, counter + drift));
const supplied = Buffer.from(suppliedCode);
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
});
};