18 changed files with 2064 additions and 2038 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
All notable changes to the hardened Spooty branch are documented here. All notable changes to Jarri Spooty are documented here.
This project follows a practical chronological changelog rather than autogenerated dependency noise. This project follows a practical chronological changelog rather than autogenerated dependency noise.
@@ -64,6 +64,12 @@ This project follows a practical chronological changelog rather than autogenerat
- Added isolated Spotify credential storage - Added isolated Spotify credential storage
- Added safer cookie handling flow - Added safer cookie handling flow
- Improved Docker secret isolation documentation - Improved Docker secret isolation documentation
- Removed unused downloader dependencies to reduce transitive attack surface
- Hardened download filename sanitization
- Hardened remote cover-art fetching with protocol, MIME, timeout, and size checks
- Added yt-dlp subprocess timeout and single-settlement handling
- Shaped track websocket payloads to avoid leaking internal entity fields
- Normalized visible project naming to Jarri Spooty
### Frontend ### Frontend
+11 -7
View File
@@ -1,8 +1,8 @@
# Spooty Hardened # Jarri Spooty
Self-hosted Spotify playlist and track downloader built with NestJS + Angular. Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
Spooty does not download audio from Spotify itself. Jarri Spooty does not download audio from Spotify itself.
It retrieves metadata from Spotify and locates matching audio on YouTube. It retrieves metadata from Spotify and locates matching audio on YouTube.
This hardened branch focuses on: This hardened branch focuses on:
@@ -21,6 +21,8 @@ This hardened branch focuses on:
- Persistent SQLite state across container restarts - Persistent SQLite state across container restarts
- Deterministic Docker config persistence - Deterministic Docker config persistence
- Improved operational observability - Improved operational observability
- Reduced unused dependency surface
- Hardened filename, cover-art, subprocess, and websocket boundaries
--- ---
@@ -40,6 +42,8 @@ This hardened branch focuses on:
- Automatic YouTube retry/fallback handling - Automatic YouTube retry/fallback handling
- Failed YouTube candidate rejection memory - Failed YouTube candidate rejection memory
- Deterministic yt-dlp error classification - Deterministic yt-dlp error classification
- Hardened cover-art validation and embedding
- Explicit client-facing websocket payload shaping
--- ---
@@ -136,7 +140,7 @@ docker run --rm -p 3000:3000 \
-v "$PWD/downloads:/spooty/backend/downloads" \ -v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \ -v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \ -v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
spootyfy-hardened:local jarri-spooty:local
``` ```
Open: Open:
@@ -158,8 +162,8 @@ Then:
```yaml ```yaml
services: services:
spooty: spooty:
image: spootyfy-hardened:local image: jarri-spooty:local
container_name: spooty container_name: jarri-spooty
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -204,7 +208,7 @@ services:
```bash ```bash
npm install npm install
npm run build npm run build
docker build -t spootyfy-hardened:local . docker build -t jarri-spooty:local .
``` ```
--- ---
@@ -493,7 +497,7 @@ docker run --rm -p 3000:3000 \
-v "$PWD/downloads:/spooty/backend/downloads" \ -v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \ -v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \ -v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
spootyfy-hardened:local jarri-spooty:local
``` ```
--- ---
+1833 -1982
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -17,8 +17,7 @@
"clean": "rimraf dist", "clean": "rimraf dist",
"changelog": "auto-changelog -p", "changelog": "auto-changelog -p",
"release": "release-it", "release": "release-it",
"commit": "cz", "commit": "cz"
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
}, },
"devDependencies": { "devDependencies": {
"@release-it/bumper": "^7.0.1", "@release-it/bumper": "^7.0.1",
+1 -6
View File
@@ -31,18 +31,13 @@
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.10", "@nestjs/websockets": "^10.3.10",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/yt-search": "^2.10.3",
"bullmq": "^5.31.2", "bullmq": "^5.31.2",
"fluent-ffmpeg": "^2.1.3",
"isomorphic-unfetch": "^4.0.2", "isomorphic-unfetch": "^4.0.2",
"node-id3": "^0.2.9", "node-id3": "^0.2.9",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "7.8.0", "rxjs": "7.8.0",
"spotify-url-info": "^3.2.18", "spotify-url-info": "^3.2.18",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7"
"yt-search": "^2.12.1",
"ytdlp-nodejs": "^3.4.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
+11 -2
View File
@@ -4,7 +4,7 @@ import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import * as fs from 'fs'; import * as fs from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';
import { exec } from 'child_process'; import { spawn } from 'child_process';
import helmet from 'helmet'; import helmet from 'helmet';
import { EnvironmentEnum } from './environmentEnum'; import { EnvironmentEnum } from './environmentEnum';
@@ -27,7 +27,16 @@ async function bootstrap() {
try { try {
// Convenience mode for the single-container Docker image. // Convenience mode for the single-container Docker image.
// Hardened deployments should prefer an external Redis service. // Hardened deployments should prefer an external Redis service.
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`); const redisProcess = spawn(
'redis-server',
['--port', String(process.env.REDIS_PORT || 6379)],
{
stdio: 'ignore',
detached: true,
},
);
redisProcess.unref();
} catch (e) { } catch (e) {
console.log('Unable to run redis server from app'); console.log('Unable to run redis server from app');
console.log(e); console.log(e);
@@ -40,7 +40,7 @@ export class PlaylistController {
return this.service.remove(id); return this.service.remove(id);
} }
@Get('retry/:id') @Post('retry/:id')
retryFailedOfPlaylist( retryFailedOfPlaylist(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
): Promise<void> { ): Promise<void> {
+1 -1
View File
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
const providedToken = this.extractToken(request); const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) { if (providedToken !== expectedToken) {
throw new UnauthorizedException('Invalid or missing Spooty auth token'); throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
} }
return true; return true;
+16 -2
View File
@@ -22,7 +22,7 @@ export class UtilsService {
} }
ensureInsideDownloadsRoot(candidatePath: string): string { ensureInsideDownloadsRoot(candidatePath: string): string {
const root = this.getRootDownloadsPath(); const root = resolve(this.getRootDownloadsPath());
const resolvedCandidate = resolve(candidatePath); const resolvedCandidate = resolve(candidatePath);
const rel = relative(root, resolvedCandidate); const rel = relative(root, resolvedCandidate);
@@ -34,6 +34,20 @@ export class UtilsService {
} }
stripFileIllegalChars(text: string): string { stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled'; const sanitized = String(text || '')
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
.trim();
const fallback = sanitized || 'untitled';
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
return `_${fallback}`;
}
return fallback.slice(0, 180);
} }
} }
+144 -12
View File
@@ -3,7 +3,6 @@ import { TrackEntity } from '../track/track.entity';
import { EnvironmentEnum } from '../environmentEnum'; import { EnvironmentEnum } from '../environmentEnum';
import { TrackService } from '../track/track.service'; import { TrackService } from '../track/track.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as yts from 'yt-search';
import * as fs from 'fs'; import * as fs from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
const NodeID3 = require('node-id3'); const NodeID3 = require('node-id3');
@@ -63,6 +62,16 @@ interface CandidateScore extends YoutubeMatch {
rejected: boolean; rejected: boolean;
} }
interface YtDlpSearchVideo {
url?: string;
webpage_url?: string;
id?: string;
title?: string;
uploader?: string;
channel?: string;
duration?: number;
}
@Injectable() @Injectable()
export class YoutubeService { export class YoutubeService {
private readonly logger = new Logger(TrackService.name); private readonly logger = new Logger(TrackService.name);
@@ -78,14 +87,14 @@ export class YoutubeService {
const query = `${artist} - ${name}`; const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`); this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query); const videos = await this.searchYoutubeVideos(query);
const excludedVideoIds = new Set( const excludedVideoIds = new Set(
excludedUrls excludedUrls
.map((url) => this.getYoutubeVideoId(url)) .map((url) => this.getYoutubeVideoId(url))
.filter((id): id is string => !!id), .filter((id): id is string => !!id),
); );
const candidates = (result.videos || []) const candidates = videos
.filter((video: any) => !!video?.url && !!video?.title) .filter((video: any) => !!video?.url && !!video?.title)
.filter((video: any) => { .filter((video: any) => {
const videoId = this.getYoutubeVideoId(String(video.url)); const videoId = this.getYoutubeVideoId(String(video.url));
@@ -130,6 +139,55 @@ export class YoutubeService {
return accepted; return accepted;
} }
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--flat-playlist',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
searchTarget,
];
const output = await this.runYtDlpForOutput(args);
let parsed: any;
try {
parsed = JSON.parse(output);
} catch (error) {
throw new Error(`Failed to parse yt-dlp search output: ${(error as Error).message}`);
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
return entries
.filter((entry: YtDlpSearchVideo) => !!entry?.title && (!!entry?.url || !!entry?.webpage_url || !!entry?.id))
.map((entry: YtDlpSearchVideo) => ({
...entry,
url: this.normalizeYtDlpVideoUrl(entry),
author: entry.uploader || entry.channel || '',
seconds: typeof entry.duration === 'number' ? entry.duration : undefined,
}));
}
private normalizeYtDlpVideoUrl(video: YtDlpSearchVideo): string {
const rawUrl = String(video.webpage_url || video.url || '');
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return rawUrl;
}
const id = String(video.id || rawUrl || '').trim();
if (!id) {
return '';
}
return `https://www.youtube.com/watch?v=${id}`;
}
private scoreCandidate( private scoreCandidate(
video: any, video: any,
artist: string, artist: string,
@@ -343,34 +401,69 @@ export class YoutubeService {
return tempCookiesFile; return tempCookiesFile;
} }
private runYtDlp(args: string[]): Promise<void> { private runYtDlpForOutput(args: string[]): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => { return new Promise((resolvePromise, rejectPromise) => {
const child = spawn('yt-dlp', args, { const child = spawn('yt-dlp', args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const timeoutMs = 10 * 60 * 1000;
let settled = false;
const settleResolve = (value: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolvePromise(value);
};
const settleReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rejectPromise(error);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
settleReject(
new Error(`yt-dlp exceeded maximum runtime of ${timeoutMs}ms`),
);
}, timeoutMs);
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
child.stdout.on('data', (chunk) => { child.stdout?.on('data', (chunk) => {
stdout += chunk.toString(); stdout += chunk.toString();
}); });
child.stderr.on('data', (chunk) => { child.stderr?.on('data', (chunk) => {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
child.on('error', (error) => { child.on('error', (error) => {
rejectPromise(new Error(`Failed to start yt-dlp: ${error.message}`)); settleReject(new Error(`Failed to start yt-dlp: ${error.message}`));
}); });
child.on('close', (code) => { child.on('close', (code) => {
if (code === 0) { if (settled) {
resolvePromise();
return; return;
} }
rejectPromise( if (code === 0) {
settleResolve(stdout);
return;
}
settleReject(
new Error( new Error(
`yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`, `yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`,
), ),
@@ -379,6 +472,10 @@ export class YoutubeService {
}); });
} }
private async runYtDlp(args: string[]): Promise<void> {
await this.runYtDlpForOutput(args);
}
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> { async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
this.logger.debug( this.logger.debug(
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`, `Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
@@ -432,12 +529,47 @@ export class YoutubeService {
return; return;
} }
const res = await fetch(coverUrl); let parsedCoverUrl: URL;
try {
parsedCoverUrl = new URL(coverUrl);
} catch {
throw new Error('Invalid cover art URL');
}
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
throw new Error('Rejected non-HTTP cover art URL');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let res: Response;
try {
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch cover art: ${res.status}`); throw new Error(`Failed to fetch cover art: ${res.status}`);
} }
const contentType = res.headers.get('content-type')?.split(';')[0].toLowerCase() || '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
throw new Error(`Rejected unsupported cover art type: ${contentType || 'unknown'}`);
}
const maxCoverBytes = 5 * 1024 * 1024;
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${contentLength} bytes`);
}
const arrayBuf = await res.arrayBuffer(); const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
}
const imageBuffer = Buffer.from(arrayBuf); const imageBuffer = Buffer.from(arrayBuf);
NodeID3.write( NodeID3.write(
@@ -445,7 +577,7 @@ export class YoutubeService {
title, title,
artist, artist,
APIC: { APIC: {
mime: 'image/jpeg', mime: contentType,
type: { id: 3, name: 'front cover' }, type: { id: 3, name: 'front cover' },
description: 'cover', description: 'cover',
imageBuffer, imageBuffer,
+2 -1
View File
@@ -2,6 +2,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Post,
NotFoundException, NotFoundException,
Param, Param,
ParseIntPipe, ParseIntPipe,
@@ -66,7 +67,7 @@ export class TrackController {
return this.service.remove(id); return this.service.remove(id);
} }
@Get('retry/:id') @Post('retry/:id')
retry( retry(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
): Promise<void> { ): Promise<void> {
+18 -2
View File
@@ -56,18 +56,34 @@ export class TrackService {
this.io.emit(WsTrackOperation.Delete, { id }); this.io.emit(WsTrackOperation.Delete, { id });
} }
private toClientTrack(track: TrackEntity): Partial<TrackEntity> {
return {
id: track.id,
artist: track.artist,
name: track.name,
spotifyUrl: track.spotifyUrl,
youtubeUrl: track.youtubeUrl,
downloadAttemptCount: track.downloadAttemptCount,
status: track.status,
error: track.error,
coverUrl: track.coverUrl,
durationMs: track.durationMs,
createdAt: track.createdAt,
};
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> { async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({ ...track, playlist }); const savedTrack = await this.repository.save({ ...track, playlist });
await this.enqueueSearch(savedTrack.id); await this.enqueueSearch(savedTrack.id);
this.io.emit(WsTrackOperation.New, { this.io.emit(WsTrackOperation.New, {
track: savedTrack, track: this.toClientTrack(savedTrack),
playlistId: playlist.id, playlistId: playlist.id,
}); });
} }
async update(id: number, track: TrackEntity): Promise<void> { async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track); await this.repository.update(id, track);
this.io.emit(WsTrackOperation.Update, track); this.io.emit(WsTrackOperation.Update, this.toClientTrack(track));
} }
private parseRejectedYoutubeUrls(track: TrackEntity): string[] { private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
+1 -1
View File
@@ -1,4 +1,4 @@
# SpootyFe # Jarri Spooty Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3. This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
+11 -12
View File
@@ -11,15 +11,14 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^19.0.6", "@angular/animations": "^19.2.22",
"@angular/common": "^19.0.6", "@angular/common": "^19.2.22",
"@angular/compiler": "^19.0.6", "@angular/compiler": "^19.2.22",
"@angular/core": "^19.0.6", "@angular/core": "^19.2.22",
"@angular/forms": "^19.0.6", "@angular/forms": "^19.2.22",
"@angular/platform-browser": "^19.0.6", "@angular/platform-browser": "^19.2.22",
"@angular/platform-browser-dynamic": "^19.0.6", "@angular/platform-browser-dynamic": "^19.2.22",
"@angular/router": "^19.0.6", "@angular/router": "^19.2.22",
"@distube/ytdl-core": "^4.15.9",
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@ngneat/elf": "^2.5.1", "@ngneat/elf": "^2.5.1",
"@ngneat/elf-cli-ng": "^1.0.0", "@ngneat/elf-cli-ng": "^1.0.0",
@@ -36,9 +35,9 @@
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.0.7", "@angular-devkit/build-angular": "^19.2.26",
"@angular/cli": "^19.0.7", "@angular/cli": "^19.2.26",
"@angular/compiler-cli": "^19.0.6", "@angular/compiler-cli": "^19.2.22",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0", "jasmine-core": "~5.1.0",
"karma": "~6.4.0", "karma": "~6.4.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "SpootyFe", "name": "Jarri Spooty",
"short_name": "SpootyFe", "short_name": "Spooty",
"description": "Self-hosted Spotify downloader", "description": "Self-hosted Spotify downloader",
"icons": [ "icons": [
{ {
@@ -138,7 +138,7 @@ export class PlaylistService {
} }
retryFailed(id: number): void { retryFailed(id: number): void {
this.http.get<void>(`${ENDPOINT}/retry/${id}`).subscribe(); this.http.post<void>(`${ENDPOINT}/retry/${id}`, {}).subscribe();
} }
setActive(id: number, active: boolean): void { setActive(id: number, active: boolean): void {
@@ -57,7 +57,7 @@ export class TrackService {
} }
retry(id: number): void { retry(id: number): void {
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe(); this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
} }
private initWsConnection(): void { private initWsConnection(): void {
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>SpootyFe</title> <title>Jarri Spooty</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">