mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user