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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user