Add private S3 HLS playlist rewriting
هذا الالتزام موجود في:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
80
src/common/media/hls-playlist.service.spec.ts
Normal file
80
src/common/media/hls-playlist.service.spec.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
160
src/common/media/hls-playlist.service.ts
Normal file
160
src/common/media/hls-playlist.service.ts
Normal file
@@ -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)
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم