From 3298338e701b0a5f9efc59a57fc303704901633b Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Thu, 25 Jun 2026 19:04:28 +0300 Subject: [PATCH] Add private S3 HLS playlist rewriting --- .../media-url.interceptor.spec.ts | 30 +++- src/common/media/hls-playlist.service.spec.ts | 80 +++++++++ src/common/media/hls-playlist.service.ts | 160 ++++++++++++++++++ src/common/media/media-storage.module.ts | 5 +- .../media/media-storage.service.spec.ts | 12 ++ src/common/media/media-storage.service.ts | 53 ++++++ .../utils/post-media-response.util.spec.ts | 11 ++ src/modules/media/media.controller.ts | 23 ++- 8 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 src/common/media/hls-playlist.service.spec.ts create mode 100644 src/common/media/hls-playlist.service.ts diff --git a/src/common/interceptors/media-url.interceptor.spec.ts b/src/common/interceptors/media-url.interceptor.spec.ts index a1ffad8..10b3ca1 100644 --- a/src/common/interceptors/media-url.interceptor.spec.ts +++ b/src/common/interceptors/media-url.interceptor.spec.ts @@ -6,9 +6,12 @@ import { MediaUrlInterceptor } from './media-url.interceptor'; describe('MediaUrlInterceptor', () => { const storageService = { shouldSignResponseUrls: jest.fn(() => true), - resolveResponseUrl: jest.fn(async (url: string) => - url.includes('s3.cumin.dev') ? `${url}?signed=true` : url, - ), + resolveResponseUrl: jest.fn(async (url: string) => { + if (url.endsWith('.m3u8')) { + return 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/master.m3u8'; + } + return url.includes('s3.cumin.dev') ? `${url}?signed=true` : url; + }), } as unknown as MediaStorageService; const interceptor = new MediaUrlInterceptor(storageService); const context = { @@ -48,4 +51,25 @@ describe('MediaUrlInterceptor', () => { expect(result.media.preferredPlaybackUrl).toContain('?signed=true'); expect(result.externalUrl).toBe('https://cdn.example.com/image.jpg'); }); + + it('uses the rewritten HLS endpoint while keeping signed MP4 as fallback', async () => { + const response = { + videoUrl: 'https://s3.cumin.dev/uploads/posts/video/video.mp4', + hlsUrl: 'https://s3.cumin.dev/uploads/posts/hls/stream/master.m3u8', + media: { + videoUrl: 'https://s3.cumin.dev/uploads/posts/video/video.mp4', + hlsUrl: 'https://s3.cumin.dev/uploads/posts/hls/stream/master.m3u8', + preferredPlaybackUrl: 'https://s3.cumin.dev/uploads/posts/hls/stream/master.m3u8', + }, + }; + const next = { handle: () => of(response) } as CallHandler; + + const result = (await lastValueFrom(interceptor.intercept(context, next))) as typeof response; + + expect(result.hlsUrl).toContain('/api/v1/media/hls/'); + expect(result.media.hlsUrl).toContain('/api/v1/media/hls/'); + expect(result.media.preferredPlaybackUrl).toContain('/api/v1/media/hls/'); + expect(result.videoUrl).toContain('signed=true'); + expect(result.media.videoUrl).toContain('signed=true'); + }); }); diff --git a/src/common/media/hls-playlist.service.spec.ts b/src/common/media/hls-playlist.service.spec.ts new file mode 100644 index 0000000..f98e1e3 --- /dev/null +++ b/src/common/media/hls-playlist.service.spec.ts @@ -0,0 +1,80 @@ +import { BadRequestException } from '@nestjs/common'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { HlsPlaylistService } from './hls-playlist.service'; +import { MediaStorageService } from './media-storage.service'; + +describe('HlsPlaylistService', () => { + const storageService = { + shouldSignResponseUrls: jest.fn(() => true), + getBucketName: jest.fn(() => 'oudelaa'), + isManagedKey: jest.fn((key: string) => key.startsWith('uploads/')), + getObjectText: jest.fn(), + getHlsPlaylistUrl: jest.fn((key: string) => `https://api.oudelaa.test/api/v1/media/hls/${key}`), + getSignedObjectUrl: jest.fn( + async (key: string) => `https://s3.cumin.dev/oudelaa/${key}?X-Amz-Signature=signed`, + ), + resolveKeyFromUrl: jest.fn(() => null), + } as unknown as MediaStorageService; + const logger = { + log: jest.fn(), + error: jest.fn(), + } as unknown as AppLoggerService; + const service = new HlsPlaylistService(storageService, logger); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rewrites master playlist variants through the backend endpoint', async () => { + (storageService.getObjectText as jest.Mock).mockResolvedValue( + [ + '#EXTM3U', + '#EXT-X-STREAM-INF:BANDWIDTH=800000', + 'stream_0/playlist.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=1600000', + 'stream_1/playlist.m3u8', + ].join('\n'), + ); + + const result = await service.rewritePlaylist('uploads/posts/hls/stream-1/master.m3u8'); + + expect(result).toContain( + 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/stream_0/playlist.m3u8', + ); + expect(result).toContain( + 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream-1/stream_1/playlist.m3u8', + ); + expect(storageService.getSignedObjectUrl).not.toHaveBeenCalled(); + }); + + it('rewrites variant segments and EXT-X-MAP assets to presigned S3 URLs', async () => { + (storageService.getObjectText as jest.Mock).mockResolvedValue( + [ + '#EXTM3U', + '#EXT-X-MAP:URI="init.mp4"', + '#EXTINF:4.0,', + 'segment-000.m4s', + '#EXTINF:4.0,', + 'segment-001.m4s', + ].join('\n'), + ); + + const result = await service.rewritePlaylist( + 'uploads/posts/hls/stream-1/stream_0/playlist.m3u8', + ); + + expect(result).toContain( + 'URI="https://s3.cumin.dev/oudelaa/uploads/posts/hls/stream-1/stream_0/init.mp4?X-Amz-Signature=signed"', + ); + expect(result).toContain( + 'https://s3.cumin.dev/oudelaa/uploads/posts/hls/stream-1/stream_0/segment-000.m4s?X-Amz-Signature=signed', + ); + expect(storageService.getSignedObjectUrl).toHaveBeenCalledTimes(3); + }); + + it('rejects playlists outside managed storage', async () => { + await expect(service.rewritePlaylist('private/master.m3u8')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/common/media/hls-playlist.service.ts b/src/common/media/hls-playlist.service.ts new file mode 100644 index 0000000..3fc696c --- /dev/null +++ b/src/common/media/hls-playlist.service.ts @@ -0,0 +1,160 @@ +import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common'; +import { posix } from 'path'; +import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; +import { MediaStorageService } from './media-storage.service'; + +const PLAYLIST_EXTENSION = '.m3u8'; +const SIGNED_ASSET_EXTENSIONS = new Set(['.m4s', '.ts', '.mp4', '.aac', '.vtt']); +const URI_ATTRIBUTE_PATTERN = /URI=(["'])(.*?)\1/g; + +@Injectable() +export class HlsPlaylistService { + constructor( + private readonly mediaStorageService: MediaStorageService, + private readonly appLogger: AppLoggerService, + ) {} + + async rewritePlaylist(requestedPath: string): Promise { + if (!this.mediaStorageService.shouldSignResponseUrls()) { + throw new BadRequestException('Private HLS rewrite is disabled'); + } + + const key = this.normalizePlaylistKey(requestedPath); + const bucket = this.mediaStorageService.getBucketName(); + const startedAt = Date.now(); + this.appLogger.log( + { + event: 'hls_playlist_rewrite_started', + key, + bucket, + }, + HlsPlaylistService.name, + ); + + try { + const playlist = await this.mediaStorageService.getObjectText(key); + const rewrittenLines = await Promise.all( + playlist.split(/\r?\n/).map((line) => this.rewriteLine(line, key)), + ); + const rewritten = rewrittenLines.join('\n'); + + this.appLogger.log( + { + event: 'hls_playlist_rewrite_completed', + key, + bucket, + durationMs: Date.now() - startedAt, + }, + HlsPlaylistService.name, + ); + return rewritten; + } catch (error) { + const errorName = error instanceof Error ? error.name : 'UnknownError'; + const errorMessage = error instanceof Error ? error.message : String(error); + this.appLogger.error( + { + event: 'hls_playlist_rewrite_failed', + key, + bucket, + durationMs: Date.now() - startedAt, + errorName, + errorMessage, + }, + error instanceof Error ? error.stack : undefined, + HlsPlaylistService.name, + ); + throw new BadGatewayException('Unable to load HLS playlist'); + } + } + + private async rewriteLine(line: string, playlistKey: string): Promise { + const trimmed = line.trim(); + if (!trimmed) { + return line; + } + + if (!trimmed.startsWith('#')) { + return this.rewriteUri(trimmed, playlistKey); + } + + const matches = Array.from(line.matchAll(URI_ATTRIBUTE_PATTERN)); + if (!matches.length) { + return line; + } + + let rewritten = line; + for (const match of matches) { + const originalUri = match[2]; + const rewrittenUri = await this.rewriteUri(originalUri, playlistKey); + rewritten = rewritten.replace(match[0], `URI=${match[1]}${rewrittenUri}${match[1]}`); + } + return rewritten; + } + + private async rewriteUri(uri: string, playlistKey: string): Promise { + const targetKey = this.resolveTargetKey(uri, playlistKey); + if (!targetKey) { + return uri; + } + + const extension = posix.extname(targetKey).toLowerCase(); + if (extension === PLAYLIST_EXTENSION) { + return this.mediaStorageService.getHlsPlaylistUrl(targetKey); + } + if (!SIGNED_ASSET_EXTENSIONS.has(extension)) { + return uri; + } + + const signedUrl = await this.mediaStorageService.getSignedObjectUrl(targetKey); + if (!signedUrl) { + throw new Error(`Failed to sign HLS asset "${targetKey}"`); + } + return signedUrl; + } + + private resolveTargetKey(uri: string, playlistKey: string): string | null { + const cleanUri = uri.split('#')[0].split('?')[0].trim(); + if (!cleanUri) { + return null; + } + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(cleanUri) || cleanUri.startsWith('/')) { + const resolvedKey = this.mediaStorageService.resolveKeyFromUrl(cleanUri); + return resolvedKey && this.mediaStorageService.isManagedKey(resolvedKey) ? resolvedKey : null; + } + + let decodedUri = cleanUri; + try { + decodedUri = decodeURIComponent(cleanUri); + } catch { + decodedUri = cleanUri; + } + + const targetKey = posix.normalize(posix.join(posix.dirname(playlistKey), decodedUri)); + return targetKey.startsWith('../') || + targetKey === '..' || + !this.mediaStorageService.isManagedKey(targetKey) + ? null + : targetKey; + } + + private normalizePlaylistKey(requestedPath: string): string { + let decodedPath = requestedPath; + try { + decodedPath = decodeURIComponent(requestedPath); + } catch { + decodedPath = requestedPath; + } + + const normalized = decodedPath.replace(/\\/g, '/').replace(/^\/+/, ''); + if ( + !normalized || + !normalized.toLowerCase().endsWith(PLAYLIST_EXTENSION) || + normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..') || + !this.mediaStorageService.isManagedKey(normalized) + ) { + throw new BadRequestException('Invalid HLS playlist path'); + } + return normalized; + } +} diff --git a/src/common/media/media-storage.module.ts b/src/common/media/media-storage.module.ts index 9310f70..628b926 100644 --- a/src/common/media/media-storage.module.ts +++ b/src/common/media/media-storage.module.ts @@ -1,9 +1,10 @@ import { Global, Module } from '@nestjs/common'; +import { HlsPlaylistService } from './hls-playlist.service'; import { MediaStorageService } from './media-storage.service'; @Global() @Module({ - providers: [MediaStorageService], - exports: [MediaStorageService], + providers: [MediaStorageService, HlsPlaylistService], + exports: [MediaStorageService, HlsPlaylistService], }) export class MediaStorageModule {} diff --git a/src/common/media/media-storage.service.spec.ts b/src/common/media/media-storage.service.spec.ts index 1b6cf09..d663793 100644 --- a/src/common/media/media-storage.service.spec.ts +++ b/src/common/media/media-storage.service.spec.ts @@ -23,6 +23,7 @@ describe('MediaStorageService response URLs', () => { 'storage.s3.secretAccessKey': 'test-secret-key', 'storage.s3.forcePathStyle': true, publicBaseUrl: 'https://api.oudelaa.test', + globalPrefix: 'api/v1', }; const configService = { get: jest.fn((key: string, defaultValue?: unknown) => config[key] ?? defaultValue), @@ -58,6 +59,17 @@ describe('MediaStorageService response URLs', () => { ); }); + it('rewrites a managed HLS URL to the backend playlist endpoint', async () => { + const service = new MediaStorageService(configService, appLogger); + + 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', + ); + expect(mockedGetSignedUrl).not.toHaveBeenCalled(); + }); + it('leaves external URLs unchanged', async () => { const service = new MediaStorageService(configService, appLogger); diff --git a/src/common/media/media-storage.service.ts b/src/common/media/media-storage.service.ts index d633328..1a915e6 100644 --- a/src/common/media/media-storage.service.ts +++ b/src/common/media/media-storage.service.ts @@ -148,6 +148,10 @@ export class MediaStorageService implements OnModuleDestroy { return fileUrl; } + if (key.toLowerCase().endsWith('.m3u8')) { + return this.getHlsPlaylistUrl(key); + } + return this.getSignedObjectUrl(key); } @@ -195,6 +199,55 @@ export class MediaStorageService implements OnModuleDestroy { } } + async getObjectText(key: string): Promise { + const normalizedKey = this.normalizeKey(key); + if (!normalizedKey) { + throw new BadRequestException('Invalid media storage key'); + } + + const response = await this.getS3Client().send( + new GetObjectCommand({ + Bucket: this.getBucket(), + Key: normalizedKey, + }), + ); + if (!response.Body) { + throw new Error(`S3 object body is empty for key "${normalizedKey}"`); + } + + return response.Body.transformToString('utf-8'); + } + + getHlsPlaylistUrl(key: string): string { + const normalizedKey = this.normalizeKey(key); + if (!normalizedKey || !normalizedKey.toLowerCase().endsWith('.m3u8')) { + throw new BadRequestException('Invalid HLS playlist key'); + } + + const publicBaseUrl = ( + this.configService.get('publicBaseUrl', { infer: true }) ?? '' + ).replace(/\/$/, ''); + const globalPrefix = ( + this.configService.get('globalPrefix', { infer: true }) ?? 'api/v1' + ).replace(/^\/+|\/+$/g, ''); + const encodedKey = normalizedKey + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + const path = `/${globalPrefix}/media/hls/${encodedKey}`; + + return publicBaseUrl ? `${publicBaseUrl}${path}` : path; + } + + getBucketName(): string { + return this.getBucket(); + } + + isManagedKey(key: string): boolean { + const normalizedKey = this.normalizeKey(key); + return !!normalizedKey && normalizedKey.startsWith(`${this.getBasePath()}/`); + } + onModuleDestroy(): void { this.s3Client = null; this.signedUrlCache.clear(); diff --git a/src/common/utils/post-media-response.util.spec.ts b/src/common/utils/post-media-response.util.spec.ts index 07e3636..5796b2b 100644 --- a/src/common/utils/post-media-response.util.spec.ts +++ b/src/common/utils/post-media-response.util.spec.ts @@ -35,4 +35,15 @@ describe('post media response util', () => { expect(media.waveformPeaksPreview).toEqual([]); expect(media.waveformPeaksDetailed).toEqual([]); }); + + it('prefers rewritten HLS playback and keeps MP4 as fallback', () => { + const media = buildPostMediaResponse({ + postType: PostType.VIDEO, + hlsUrl: 'https://api.oudelaa.test/api/v1/media/hls/uploads/posts/hls/stream/master.m3u8', + videoUrl: 'https://s3.cumin.dev/oudelaa/uploads/posts/video/video.mp4?X-Amz-Signature=signed', + }); + + expect(media.preferredPlaybackUrl).toBe(media.hlsUrl); + expect(media.videoUrl).toContain('X-Amz-Signature=signed'); + }); }); diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index b4c4fe1..0ae3615 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -1,16 +1,33 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Post, Res, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { Throttle } from '../../common/decorators/throttle.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtPayload } from '../../common/interfaces/jwt-payload.interface'; +import { HlsPlaylistService } from '../../common/media/hls-playlist.service'; import { TextToMusicDto } from './dto/text-to-music.dto'; import { MediaService } from './media.service'; @ApiTags('Media') @Controller('media') export class MediaController { - constructor(private readonly mediaService: MediaService) {} + constructor( + private readonly mediaService: MediaService, + private readonly hlsPlaylistService: HlsPlaylistService, + ) {} + + @Get('hls/*') + @ApiExcludeEndpoint() + async hlsPlaylist( + @Param() params: Record, + @Res({ passthrough: true }) response: Response, + ): Promise { + response.type('application/vnd.apple.mpegurl'); + response.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + return this.hlsPlaylistService.rewritePlaylist(params['0'] ?? ''); + } @ApiBearerAuth() @UseGuards(JwtAuthGuard)