Add private S3 HLS playlist rewriting

هذا الالتزام موجود في:
boutmoun123
2026-06-25 19:04:28 +03:00
الأصل fc7c93ed3e
التزام 3298338e70
8 ملفات معدلة مع 366 إضافات و8 حذوفات

عرض الملف

@@ -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');
});
});

عرض الملف

@@ -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,
);
});
});

عرض الملف

@@ -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<string> {
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<string> {
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<string> {
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;
}
}

عرض الملف

@@ -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 {}

عرض الملف

@@ -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);

عرض الملف

@@ -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<string> {
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<string>('publicBaseUrl', { infer: true }) ?? ''
).replace(/\/$/, '');
const globalPrefix = (
this.configService.get<string>('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();

عرض الملف

@@ -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');
});
});

عرض الملف

@@ -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<string, string>,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
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)