diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index f75110c..e2c323a 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -11,7 +11,12 @@ const REQUIRED_ENV_KEYS = [ // Optional keys that, when present, must pass a format check. const BOOLEAN_KEYS = ["AUTH_COOKIE_SECURE"] as const; const NUMBER_KEYS = ["PORT", "MAIL_PORT"] as const; -const URL_KEYS = ["CLIENT_URL", "API_URL", "GOOGLE_CALLBACK_URL", "CDN_URL"] as const; +const URL_KEYS = [ + "CLIENT_URL", + "API_URL", + "GOOGLE_CALLBACK_URL", + "CDN_URL", +] as const; function isValidUrl(value: string): boolean { try { diff --git a/src/playlists/dto/get-playlist-embed-code.dto.ts b/src/playlists/dto/get-playlist-embed-code.dto.ts index 12f18c3..b25ab7b 100644 --- a/src/playlists/dto/get-playlist-embed-code.dto.ts +++ b/src/playlists/dto/get-playlist-embed-code.dto.ts @@ -63,6 +63,6 @@ export class GetPlaylistEmbedCodeResponseDto { @ApiProperty({ example: 'pl_101' }) playlistId!: string; - @ApiProperty({ example: '' }) + @ApiProperty({ example: '' }) embedCode!: string; } diff --git a/src/playlists/playlists.controller.spec.ts b/src/playlists/playlists.controller.spec.ts index bc27719..5c8fc99 100644 --- a/src/playlists/playlists.controller.spec.ts +++ b/src/playlists/playlists.controller.spec.ts @@ -89,7 +89,7 @@ function buildServiceMock() { }), getEmbedCode: jest.fn().mockResolvedValue({ playlistId, - embedCode: ``, + embedCode: ``, }), addTrack: jest.fn().mockResolvedValue({ message: "Track added to playlist successfully", diff --git a/src/playlists/playlists.controller.ts b/src/playlists/playlists.controller.ts index c04f372..2ce91cd 100644 --- a/src/playlists/playlists.controller.ts +++ b/src/playlists/playlists.controller.ts @@ -466,7 +466,7 @@ export class PlaylistsController { schema: { example: { playlistId: 'pl_101', - embedCode: '', + embedCode: '', }, }, }) diff --git a/src/playlists/playlists.service.spec.ts b/src/playlists/playlists.service.spec.ts index db73e46..ad8bef7 100644 --- a/src/playlists/playlists.service.spec.ts +++ b/src/playlists/playlists.service.spec.ts @@ -72,8 +72,14 @@ describe("PlaylistsService", () => { const storageMock = { upload: jest.fn(), }; + const originalEnv = process.env; beforeEach(async () => { + process.env = { + ...originalEnv, + FRONTEND_URL: "https://dev.iqa3.tech", + }; + prisma = buildPrismaMock(); const module: TestingModule = await Test.createTestingModule({ @@ -87,7 +93,10 @@ describe("PlaylistsService", () => { service = module.get(PlaylistsService); }); - afterEach(() => jest.clearAllMocks()); + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); describe("create", () => { it("creates a PUBLIC playlist with initial tracks list", async () => { @@ -1451,16 +1460,33 @@ describe("PlaylistsService", () => { prisma.playlist.findFirst.mockResolvedValue({ id: "pl_101", ownerId: "usr_1", + visibility: PlaylistVisibility.PUBLIC, + secretToken: null, }); const result = await service.getEmbedCode("usr_1", "pl_101"); expect(result).toEqual({ playlistId: "pl_101", - embedCode: '', + embedCode: '', }); }); + it("includes the secret token for secret playlists", async () => { + prisma.playlist.findFirst.mockResolvedValue({ + id: "pl_101", + ownerId: "usr_1", + visibility: PlaylistVisibility.SECRET, + secretToken: "secret-token-123", + }); + + const result = await service.getEmbedCode("usr_1", "pl_101"); + + expect(result.embedCode).toBe( + '', + ); + }); + it("throws when requester is not owner", async () => { prisma.playlist.findFirst.mockResolvedValue({ id: "pl_101", diff --git a/src/playlists/playlists.service.ts b/src/playlists/playlists.service.ts index 008c66d..cc03d28 100644 --- a/src/playlists/playlists.service.ts +++ b/src/playlists/playlists.service.ts @@ -5,6 +5,7 @@ import { Injectable, Logger, NotFoundException, + InternalServerErrorException, } from '@nestjs/common'; import { PlaylistType, PlaylistVisibility, Prisma } from '@prisma/client'; import { randomBytes, randomUUID } from 'crypto'; @@ -1730,8 +1731,7 @@ export class PlaylistsService { ); } - const embedBaseUrl = this.resolveEmbedBaseUrl(); - const embedUrl = new URL(`${embedBaseUrl.replace(/\/+$/, '')}/${playlist.id}`); + const embedUrl = this.buildPlaylistEmbedUrl(playlist.id); if (playlist.visibility === PlaylistVisibility.SECRET && playlist.secretToken) { embedUrl.searchParams.set('token', playlist.secretToken); @@ -1758,13 +1758,35 @@ export class PlaylistsService { }; } - private resolveEmbedBaseUrl(): string { - const explicitBaseUrl = process.env.PLAYLIST_EMBED_BASE_URL?.trim(); - if (explicitBaseUrl) { - return explicitBaseUrl; + private buildPlaylistEmbedUrl(playlistId: string): URL { + const baseUrl = this.resolveFrontendBaseUrl(); + return new URL(`/embed/playlists/${playlistId}`, baseUrl); + } + + private resolveFrontendBaseUrl(): string { + const configuredBaseUrl = + process.env.FRONTEND_URL?.trim() ?? process.env.CLIENT_URL?.trim(); + + if (!configuredBaseUrl) { + const message = + 'Playlist embed base URL is not configured. Set FRONTEND_URL or CLIENT_URL to a valid http/https URL.'; + this.logger.error(message); + throw new InternalServerErrorException(message); } - return 'https://example.com/embed/playlists'; + try { + const url = new URL(configuredBaseUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('URL must use http or https.'); + } + + return url.toString().replace(/\/+$/, ''); + } catch { + const message = + `Playlist embed base URL is invalid: ${configuredBaseUrl}. Set FRONTEND_URL or CLIENT_URL to a valid http/https URL.`; + this.logger.error(message); + throw new InternalServerErrorException(message); + } } private validateTrackIdArray(