security(api): add internal auth to vibe failure endpoint

Add shared secret authentication to the /api/analysis/vibe/failure
endpoint to prevent external abuse. The CLAP analyzer now sends
X-Internal-Secret header which the backend validates against
INTERNAL_API_SECRET environment variable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-01-30 10:53:33 -06:00
parent 0551447fd3
commit ee56104be8
3 changed files with 195 additions and 20 deletions
+151
View File
@@ -4,12 +4,14 @@ import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { getSystemSettings } from "../utils/systemSettings";
import { enrichmentFailureService } from "../services/enrichmentFailureService";
import os from "os";
const router = Router();
// Redis queue key for audio analysis
const ANALYSIS_QUEUE = "audio:analysis:queue";
const VIBE_QUEUE = "audio:analysis:queue"; // CLAP uses same queue
/**
* GET /api/analysis/status
@@ -438,4 +440,153 @@ router.put("/clap-workers", requireAuth, requireAdmin, async (req, res) => {
}
});
/**
* POST /api/analysis/vibe/failure
* Record a vibe embedding failure (called by CLAP analyzer)
*/
router.post("/vibe/failure", async (req, res) => {
// Internal endpoint - verify shared secret from CLAP analyzer
const internalSecret = req.headers["x-internal-secret"];
if (internalSecret !== process.env.INTERNAL_API_SECRET) {
return res.status(403).json({ error: "Forbidden" });
}
try {
const { trackId, trackName, errorMessage, errorCode } = req.body;
if (!trackId) {
return res.status(400).json({ error: "trackId is required" });
}
await enrichmentFailureService.recordFailure({
entityType: "vibe",
entityId: trackId,
entityName: trackName,
errorMessage: errorMessage || "Vibe embedding generation failed",
errorCode: errorCode,
});
res.json({ message: "Failure recorded" });
} catch (error: any) {
logger.error("Record vibe failure error:", error);
res.status(500).json({ error: "Failed to record failure" });
}
});
/**
* POST /api/analysis/vibe/start
* Queue tracks for vibe embedding generation (admin only)
*
* @param force - If true, delete all embeddings and re-queue all tracks
*/
router.post("/vibe/start", requireAuth, requireAdmin, async (req, res) => {
try {
const { limit = 500, force = false } = req.body;
// If force mode, delete all existing embeddings first
if (force) {
await prisma.$executeRaw`DELETE FROM track_embeddings`;
await enrichmentFailureService.clearAllFailures("vibe");
logger.info("Cleared all vibe embeddings for re-generation");
}
// Find tracks without vibe embeddings (all tracks if force was used)
const tracks = await prisma.$queryRaw<{ id: string; filePath: string; duration: number; title: string }[]>`
SELECT t.id, t."filePath", t.duration, t.title
FROM "Track" t
LEFT JOIN track_embeddings te ON t.id = te.track_id
WHERE te.track_id IS NULL
AND t."filePath" IS NOT NULL
ORDER BY t."fileModified" DESC
LIMIT ${limit}
`;
if (tracks.length === 0) {
return res.json({
message: "All tracks have vibe embeddings",
queued: 0,
});
}
// Queue tracks for CLAP embedding
const pipeline = redisClient.multi();
for (const track of tracks) {
pipeline.rPush(VIBE_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
duration: track.duration,
}));
}
await pipeline.exec();
// Clear any existing vibe failures for these tracks
for (const track of tracks) {
await enrichmentFailureService.clearFailure("vibe", track.id);
}
logger.info(`Queued ${tracks.length} tracks for vibe embedding${force ? " (force reset)" : ""}`);
res.json({
message: `Queued ${tracks.length} tracks for vibe embedding`,
queued: tracks.length,
});
} catch (error: any) {
logger.error("Start vibe embedding error:", error);
res.status(500).json({ error: "Failed to start vibe embedding" });
}
});
/**
* POST /api/analysis/vibe/retry
* Retry failed vibe embeddings (admin only)
*/
router.post("/vibe/retry", requireAuth, requireAdmin, async (req, res) => {
try {
// Get all vibe failures
const { failures } = await enrichmentFailureService.getFailures({
entityType: "vibe",
includeSkipped: false,
includeResolved: false,
});
if (failures.length === 0) {
return res.json({
message: "No vibe failures to retry",
queued: 0,
});
}
// Get track details for failed tracks
const trackIds = failures.map(f => f.entityId);
const tracks = await prisma.track.findMany({
where: { id: { in: trackIds } },
select: { id: true, filePath: true, duration: true, title: true },
});
// Queue for retry
const pipeline = redisClient.multi();
for (const track of tracks) {
pipeline.rPush(VIBE_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
duration: track.duration,
}));
}
await pipeline.exec();
// Reset retry counts
await enrichmentFailureService.resetRetryCount(failures.map(f => f.id));
logger.info(`Retrying ${tracks.length} failed vibe embeddings`);
res.json({
message: `Queued ${tracks.length} failed tracks for vibe embedding retry`,
queued: tracks.length,
});
} catch (error: any) {
logger.error("Retry vibe failures error:", error);
res.status(500).json({ error: "Failed to retry vibe failures" });
}
});
export default router;
+7
View File
@@ -33,6 +33,9 @@ services:
# CORS
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:3030}
# Internal API secret for service-to-service auth
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-lidify-internal-secret-change-me}
# Lidarr webhook callback URL - how Lidarr can reach Lidify
# Use backend:3006 for same-network communication, or external IP:3030 for external Lidarr
LIDIFY_CALLBACK_URL: ${LIDIFY_CALLBACK_URL:-http://backend:3006}
@@ -270,10 +273,12 @@ services:
environment:
REDIS_URL: redis://redis:6379
DATABASE_URL: postgresql://${POSTGRES_USER:-lidifydb}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-lidify}
BACKEND_URL: http://backend:3006
MUSIC_PATH: /music
SLEEP_INTERVAL: ${CLAP_SLEEP_INTERVAL:-5}
NUM_WORKERS: ${CLAP_WORKERS:-2}
THREADS_PER_WORKER: ${CLAP_THREADS_PER_WORKER:-1}
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET:-lidify-internal-secret-change-me}
volumes:
- ${MUSIC_PATH:-./music}:/music:ro
depends_on:
@@ -281,6 +286,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
backend:
condition: service_healthy
restart: unless-stopped
deploy:
resources:
+37 -20
View File
@@ -2,7 +2,7 @@
"""
CLAP Audio Analyzer Service - LAION CLAP embeddings for vibe similarity
This service processes audio files and generates 1024-dimensional embeddings
This service processes audio files and generates 512-dimensional embeddings
using LAION CLAP (Contrastive Language-Audio Pretraining). These embeddings
enable semantic similarity search - finding tracks that "sound similar" based
on learned audio representations.
@@ -31,6 +31,7 @@ from typing import Optional, Tuple
import traceback
import numpy as np
import librosa
import requests
# CPU thread limiting must be set before importing torch
THREADS_PER_WORKER = int(os.getenv('THREADS_PER_WORKER', '1'))
@@ -59,6 +60,7 @@ DATABASE_URL = os.getenv('DATABASE_URL', '')
MUSIC_PATH = os.getenv('MUSIC_PATH', '/music')
SLEEP_INTERVAL = int(os.getenv('SLEEP_INTERVAL', '5'))
NUM_WORKERS = int(os.getenv('NUM_WORKERS', '2'))
BACKEND_URL = os.getenv('BACKEND_URL', 'http://backend:3006')
# Queue and channel names
ANALYSIS_QUEUE = 'audio:analysis:queue'
@@ -152,7 +154,7 @@ class CLAPAnalyzer:
def get_audio_embedding(self, audio_path: str, duration: Optional[float] = None) -> Optional[np.ndarray]:
"""
Generate a 1024-dimensional embedding from an audio file.
Generate a 512-dimensional embedding from an audio file.
Extracts the middle 60 seconds of the track for embedding, which
captures the vibe while avoiding intros/outros and reducing memory.
@@ -162,7 +164,7 @@ class CLAPAnalyzer:
duration: Pre-computed duration in seconds (avoids file read)
Returns:
numpy array of shape (1024,) or None on error
numpy array of shape (512,) or None on error
"""
if self.model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
@@ -188,20 +190,12 @@ class CLAPAnalyzer:
use_tensor=False
)
# Result is shape (1, 512) for base model, normalized
# Result is shape (1, 512) for HTSAT-base model, normalized
embedding = embeddings[0]
# Verify embedding dimension
if embedding.shape[0] != 512:
logger.warning(f"Unexpected embedding dimension: {embedding.shape}")
# CLAP base model outputs 512-dim, but we need 1024 for our schema
# Pad with zeros to match the expected dimension
if embedding.shape[0] < 1024:
padded = np.zeros(1024, dtype=np.float32)
padded[:embedding.shape[0]] = embedding
embedding = padded
return embedding.astype(np.float32)
except Exception as e:
@@ -211,13 +205,13 @@ class CLAPAnalyzer:
def get_text_embedding(self, text: str) -> Optional[np.ndarray]:
"""
Generate a 1024-dimensional embedding from a text query.
Generate a 512-dimensional embedding from a text query.
Args:
text: Natural language description (e.g., "upbeat electronic dance music")
Returns:
numpy array of shape (1024,) or None on error
numpy array of shape (512,) or None on error
"""
if self.model is None:
raise RuntimeError("Model not loaded. Call load_model() first.")
@@ -236,11 +230,8 @@ class CLAPAnalyzer:
embedding = embeddings[0]
# Pad to 1024 dimensions if needed
if embedding.shape[0] < 1024:
padded = np.zeros(1024, dtype=np.float32)
padded[:embedding.shape[0]] = embedding
embedding = padded
if embedding.shape[0] != 512:
logger.warning(f"Unexpected text embedding dimension: {embedding.shape}")
return embedding.astype(np.float32)
@@ -418,9 +409,14 @@ class Worker:
cursor.close()
def _mark_failed(self, track_id: str, error: str):
"""Mark track as failed"""
"""Mark track as failed and record in enrichment failures"""
cursor = self.db.get_cursor()
try:
# Get track name for better failure visibility
cursor.execute('SELECT title FROM "Track" WHERE id = %s', (track_id,))
row = cursor.fetchone()
track_name = row['title'] if row else None
cursor.execute("""
UPDATE "Track"
SET
@@ -431,6 +427,27 @@ class Worker:
""", (error[:500], track_id))
self.db.commit()
logger.error(f"Track {track_id} failed: {error}")
# Report failure to backend enrichment failure service
try:
headers = {
"Content-Type": "application/json",
"X-Internal-Secret": os.getenv("INTERNAL_API_SECRET", "")
}
requests.post(
f"{BACKEND_URL}/api/analysis/vibe/failure",
json={
"trackId": track_id,
"trackName": track_name,
"errorMessage": error[:500],
"errorCode": "VIBE_EMBEDDING_FAILED"
},
headers=headers,
timeout=5
)
except Exception as report_err:
logger.warning(f"Failed to report failure to backend: {report_err}")
except Exception as e:
logger.error(f"Failed to mark track as failed: {e}")
self.db.rollback()