feat(covers): POST /covers/submit — приём обложки, найденной клиентом

Клиент (со своего IP, не забанен Apple) ищет арт в iTunes и шлёт ссылку.
Сервер качает (CDN из РФ доступен), конвертит в WebP (CoverStorageService),
кладёт к себе, апсертит Track по normKey. Защита: host-whitelist
(mzstatic/dzcdn, против SSRF), идемпотентность, кап одновременных загрузок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 16:59:36 +03:00
parent 52c8c3f69f
commit 1f67e01ac8
3 changed files with 86 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { IsString, MaxLength } from 'class-validator';
import { EnrichmentService } from './enrichment.service';
class SubmitCoverDto {
@IsString()
@MaxLength(300)
artist!: string;
@IsString()
@MaxLength(300)
song!: string;
@IsString()
@MaxLength(1000)
artworkUrl!: string;
}
@ApiTags('covers')
@Controller('covers')
export class CoversController {
constructor(private readonly enrichment: EnrichmentService) {}
// Клиент прислал ссылку на найденную им (со своего IP) обложку iTunes.
// Сервер скачивает её и кладёт WebP к себе; возвращает наш coverUrl.
@Post('submit')
@ApiOperation({ summary: 'Принять обложку, найденную клиентом' })
async submit(@Body() dto: SubmitCoverDto) {
return this.enrichment.submitCover(dto.artist, dto.song, dto.artworkUrl);
}
}

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { DiscogsService } from './discogs.service';
import { CoverStorageService } from './cover-storage.service';
import { EnrichmentService } from './enrichment.service';
import { CoversController } from './covers.controller';
@Module({
controllers: [CoversController],
providers: [DiscogsService, CoverStorageService, EnrichmentService],
exports: [EnrichmentService],
})

View File

@@ -156,6 +156,58 @@ export class EnrichmentService {
return null;
}
// ===== Клиентский сабмит обложки =====
// Клиент (со своего IP) делает iTunes-поиск (наш серверный IP забанен Apple)
// и присылает ССЫЛКУ на арт. Сервер качает её (CDN из РФ доступен) и кладёт
// WebP к себе. SSRF-защита: только доверенные CDN. Идемпотентно (first-write-wins).
private static readonly COVER_HOST_ALLOW = ['mzstatic.com', 'dzcdn.net'];
private submitInFlight = 0;
private readonly submitMaxInFlight = 6;
async submitCover(
artist: string,
song: string,
artworkUrl: string,
): Promise<{ coverUrl: string | null }> {
const a = (artist ?? '').trim();
const s = (song ?? '').trim();
if (!a || !s || !artworkUrl) return { coverUrl: null };
let host = '';
try {
host = new URL(artworkUrl).hostname.toLowerCase();
} catch {
return { coverUrl: null };
}
const allowed = EnrichmentService.COVER_HOST_ALLOW.some(
(h) => host === h || host.endsWith('.' + h),
);
if (!allowed) return { coverUrl: null };
const normKey = this.buildNormKey(a, s);
// Уже есть — отдаём существующую (не качаем повторно, защита от перезаписи).
const existing = await this.prisma.track.findUnique({
where: { normKey },
select: { coverUrl: true },
});
if (existing?.coverUrl) return { coverUrl: existing.coverUrl };
if (this.submitInFlight >= this.submitMaxInFlight) return { coverUrl: null };
this.submitInFlight++;
try {
const stored = await this.covers.store(artworkUrl, normKey);
if (!stored) return { coverUrl: null };
await this.prisma.track.upsert({
where: { normKey },
create: { normKey, artist: a, song: s, coverUrl: stored },
update: { coverUrl: stored },
});
return { coverUrl: stored };
} finally {
this.submitInFlight--;
}
}
// Нормализованный ключ — как в ChartsService.recordPlay
private buildNormKey(artist: string, song: string): string {
return (