Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71a47f76a | ||
|
|
d83b0e6e1e | ||
|
|
1f3131d99a | ||
|
|
c3d2f39b82 | ||
|
|
09bbffa6e8 | ||
|
|
38ca4d6c8d | ||
|
|
0cd5294944 | ||
|
|
5d20972c2d | ||
|
|
5dc428b148 | ||
|
|
be11bb94d7 | ||
|
|
f64cc97475 | ||
|
|
005e47bf6e | ||
|
|
9e83001b73 |
+7
-1
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -64,6 +64,12 @@ This project follows a practical chronological changelog rather than autogenerat
|
||||
- Added isolated Spotify credential storage
|
||||
- Added safer cookie handling flow
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Spooty Hardened
|
||||
# Jarri Spooty
|
||||
|
||||
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.
|
||||
|
||||
This hardened branch focuses on:
|
||||
@@ -21,6 +21,8 @@ This hardened branch focuses on:
|
||||
- Persistent SQLite state across container restarts
|
||||
- Deterministic Docker config persistence
|
||||
- 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
|
||||
- Failed YouTube candidate rejection memory
|
||||
- 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/spooty-config:/spooty/backend/config" \
|
||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||
spootyfy-hardened:local
|
||||
jarri-spooty:local
|
||||
```
|
||||
|
||||
Open:
|
||||
@@ -158,8 +162,8 @@ Then:
|
||||
```yaml
|
||||
services:
|
||||
spooty:
|
||||
image: spootyfy-hardened:local
|
||||
container_name: spooty
|
||||
image: jarri-spooty:local
|
||||
container_name: jarri-spooty
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
@@ -204,7 +208,7 @@ services:
|
||||
```bash
|
||||
npm install
|
||||
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/spooty-config:/spooty/backend/config" \
|
||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||
spootyfy-hardened:local
|
||||
jarri-spooty:local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Generated
+1833
-1982
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -17,8 +17,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"changelog": "auto-changelog -p",
|
||||
"release": "release-it",
|
||||
"commit": "cz",
|
||||
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
|
||||
"commit": "cz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/bumper": "^7.0.1",
|
||||
|
||||
@@ -31,18 +31,13 @@
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@nestjs/websockets": "^10.3.10",
|
||||
"@types/fluent-ffmpeg": "^2.1.24",
|
||||
"@types/yt-search": "^2.10.3",
|
||||
"bullmq": "^5.31.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"isomorphic-unfetch": "^4.0.2",
|
||||
"node-id3": "^0.2.9",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "7.8.0",
|
||||
"spotify-url-info": "^3.2.18",
|
||||
"sqlite3": "^5.1.7",
|
||||
"yt-search": "^2.12.1",
|
||||
"ytdlp-nodejs": "^3.4.4"
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
|
||||
+11
-2
@@ -4,7 +4,7 @@ import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||
import { AppModule } from './app.module';
|
||||
import * as fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import helmet from 'helmet';
|
||||
import { EnvironmentEnum } from './environmentEnum';
|
||||
|
||||
@@ -27,7 +27,16 @@ async function bootstrap() {
|
||||
try {
|
||||
// Convenience mode for the single-container Docker image.
|
||||
// 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) {
|
||||
console.log('Unable to run redis server from app');
|
||||
console.log(e);
|
||||
|
||||
@@ -40,7 +40,7 @@ export class PlaylistController {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Get('retry/:id')
|
||||
@Post('retry/:id')
|
||||
retryFailedOfPlaylist(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
|
||||
const providedToken = this.extractToken(request);
|
||||
|
||||
if (providedToken !== expectedToken) {
|
||||
throw new UnauthorizedException('Invalid or missing Spooty auth token');
|
||||
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class UtilsService {
|
||||
}
|
||||
|
||||
ensureInsideDownloadsRoot(candidatePath: string): string {
|
||||
const root = this.getRootDownloadsPath();
|
||||
const root = resolve(this.getRootDownloadsPath());
|
||||
const resolvedCandidate = resolve(candidatePath);
|
||||
const rel = relative(root, resolvedCandidate);
|
||||
|
||||
@@ -34,6 +34,20 @@ export class UtilsService {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TrackEntity } from '../track/track.entity';
|
||||
import { EnvironmentEnum } from '../environmentEnum';
|
||||
import { TrackService } from '../track/track.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as yts from 'yt-search';
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
const NodeID3 = require('node-id3');
|
||||
@@ -63,6 +62,16 @@ interface CandidateScore extends YoutubeMatch {
|
||||
rejected: boolean;
|
||||
}
|
||||
|
||||
interface YtDlpSearchVideo {
|
||||
url?: string;
|
||||
webpage_url?: string;
|
||||
id?: string;
|
||||
title?: string;
|
||||
uploader?: string;
|
||||
channel?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class YoutubeService {
|
||||
private readonly logger = new Logger(TrackService.name);
|
||||
@@ -78,14 +87,14 @@ export class YoutubeService {
|
||||
const query = `${artist} - ${name}`;
|
||||
this.logger.debug(`Searching ${query} on YT`);
|
||||
|
||||
const result = await yts(query);
|
||||
const videos = await this.searchYoutubeVideos(query);
|
||||
const excludedVideoIds = new Set(
|
||||
excludedUrls
|
||||
.map((url) => this.getYoutubeVideoId(url))
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
const candidates = (result.videos || [])
|
||||
const candidates = videos
|
||||
.filter((video: any) => !!video?.url && !!video?.title)
|
||||
.filter((video: any) => {
|
||||
const videoId = this.getYoutubeVideoId(String(video.url));
|
||||
@@ -130,6 +139,55 @@ export class YoutubeService {
|
||||
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(
|
||||
video: any,
|
||||
artist: string,
|
||||
@@ -343,34 +401,69 @@ export class YoutubeService {
|
||||
return tempCookiesFile;
|
||||
}
|
||||
|
||||
private runYtDlp(args: string[]): Promise<void> {
|
||||
private runYtDlpForOutput(args: string[]): Promise<string> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn('yt-dlp', args, {
|
||||
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 stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
rejectPromise(
|
||||
if (code === 0) {
|
||||
settleResolve(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
settleReject(
|
||||
new Error(
|
||||
`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> {
|
||||
this.logger.debug(
|
||||
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
||||
@@ -432,12 +529,47 @@ export class YoutubeService {
|
||||
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) {
|
||||
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();
|
||||
if (arrayBuf.byteLength > maxCoverBytes) {
|
||||
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(arrayBuf);
|
||||
|
||||
NodeID3.write(
|
||||
@@ -445,7 +577,7 @@ export class YoutubeService {
|
||||
title,
|
||||
artist,
|
||||
APIC: {
|
||||
mime: 'image/jpeg',
|
||||
mime: contentType,
|
||||
type: { id: 3, name: 'front cover' },
|
||||
description: 'cover',
|
||||
imageBuffer,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
@@ -66,7 +67,7 @@ export class TrackController {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Get('retry/:id')
|
||||
@Post('retry/:id')
|
||||
retry(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -56,18 +56,34 @@ export class TrackService {
|
||||
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> {
|
||||
const savedTrack = await this.repository.save({ ...track, playlist });
|
||||
await this.enqueueSearch(savedTrack.id);
|
||||
this.io.emit(WsTrackOperation.New, {
|
||||
track: savedTrack,
|
||||
track: this.toClientTrack(savedTrack),
|
||||
playlistId: playlist.id,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, track: TrackEntity): Promise<void> {
|
||||
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[] {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+11
-12
@@ -11,15 +11,14 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.6",
|
||||
"@angular/common": "^19.0.6",
|
||||
"@angular/compiler": "^19.0.6",
|
||||
"@angular/core": "^19.0.6",
|
||||
"@angular/forms": "^19.0.6",
|
||||
"@angular/platform-browser": "^19.0.6",
|
||||
"@angular/platform-browser-dynamic": "^19.0.6",
|
||||
"@angular/router": "^19.0.6",
|
||||
"@distube/ytdl-core": "^4.15.9",
|
||||
"@angular/animations": "^19.2.22",
|
||||
"@angular/common": "^19.2.22",
|
||||
"@angular/compiler": "^19.2.22",
|
||||
"@angular/core": "^19.2.22",
|
||||
"@angular/forms": "^19.2.22",
|
||||
"@angular/platform-browser": "^19.2.22",
|
||||
"@angular/platform-browser-dynamic": "^19.2.22",
|
||||
"@angular/router": "^19.2.22",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@ngneat/elf": "^2.5.1",
|
||||
"@ngneat/elf-cli-ng": "^1.0.0",
|
||||
@@ -36,9 +35,9 @@
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.0.7",
|
||||
"@angular/cli": "^19.0.7",
|
||||
"@angular/compiler-cli": "^19.0.6",
|
||||
"@angular-devkit/build-angular": "^19.2.26",
|
||||
"@angular/cli": "^19.2.26",
|
||||
"@angular/compiler-cli": "^19.2.22",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "SpootyFe",
|
||||
"short_name": "SpootyFe",
|
||||
"name": "Jarri Spooty",
|
||||
"short_name": "Spooty",
|
||||
"description": "Self-hosted Spotify downloader",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
@@ -138,7 +138,7 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -57,7 +57,7 @@ export class TrackService {
|
||||
}
|
||||
|
||||
retry(id: number): void {
|
||||
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe();
|
||||
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
|
||||
}
|
||||
|
||||
private initWsConnection(): void {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SpootyFe</title>
|
||||
<title>Jarri Spooty</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
||||
Reference in New Issue
Block a user