Integrate Oudelaa backend features, security, tests, and deployment updates
This commit is contained in:
@@ -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 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user