refactor: arch audit Phase 1 -- quick wins

- Token refresh race: add promise dedup mutex to ApiClient.refreshAccessToken()
- CSP header: add Content-Security-Policy to next.config.ts
- Ephemeral Redis: replace new Redis()/publish/quit in clearPauseState() with enrichmentStateService.publishToChannel()
- Server startup order: move health checks before app.listen() into main()
- Dead code: remove updatePodcastProgress() wrapper, OpenAI config block, assets.fanart.tv remote pattern, tailwind.config.js, unused imports (fs, imageLimiter, cycleResult vars)
- Types: move @types/dompurify (FE) and @types/fluent-ffmpeg/@types/node-cron/@types/qrcode/@types/speakeasy (BE) to devDependencies
This commit is contained in:
Your Name
2026-03-20 15:32:33 -05:00
parent 670ad7d012
commit d4a2a0ba64
10 changed files with 47 additions and 74 deletions
+4 -4
View File
@@ -26,10 +26,6 @@
"@bull-board/express": "^6.20.3",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@prisma/client": "^5.22.0",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node-cron": "^3.0.11",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"bullmq": "^5.70.1",
@@ -64,6 +60,10 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node-cron": "^3.0.11",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
-6
View File
@@ -1,6 +1,5 @@
import dotenv from "dotenv";
import { z } from "zod";
import * as fs from "fs";
import { validateMusicConfig, MusicConfig } from "./utils/configValidator";
import { logger } from "./utils/logger";
import packageJson from "../package.json";
@@ -92,11 +91,6 @@ export const config = {
apiKey: process.env.LASTFM_API_KEY || "c1797de6bf0b7e401b623118120cd9e1",
},
// OpenAI - reads from database
openai: {
apiKey: process.env.OPENAI_API_KEY || "", // Fallback to DB
},
allowedOrigins:
process.env.ALLOWED_ORIGINS?.split(",").map((o) => o.trim()) ||
(process.env.NODE_ENV === "development" ? true : []),
+13 -7
View File
@@ -50,7 +50,6 @@ import { requireAuth, requireAdmin } from "./middleware/auth";
import {
authLimiter,
apiLimiter,
imageLimiter,
} from "./middleware/rateLimiter";
const app = express();
@@ -183,15 +182,15 @@ app.use("/api/events", eventsRoutes);
app.use("/rest", subsonicRouter);
// Health check (keep at root for simple container health checks)
app.get("/health", (req, res) => {
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/api/health", (req, res) => {
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
// Prometheus metrics endpoint
app.get("/api/metrics", requireAuth, async (req, res) => {
app.get("/api/metrics", requireAuth, async (_req, res) => {
try {
const { getMetrics } = await import("./utils/metrics");
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
@@ -274,14 +273,15 @@ async function checkPasswordReset() {
logger.warn("[Password Reset] Admin password has been reset via ADMIN_RESET_PASSWORD env var. Remove this env var and restart.");
}
app.listen(config.port, "0.0.0.0", async () => {
// Verify database connections before proceeding
async function main() {
// Verify database connections before accepting traffic
await checkPostgresConnection();
await checkRedisConnection();
// Check for admin password reset
await checkPasswordReset();
app.listen(config.port, "0.0.0.0", async () => {
logger.debug(
`Kima API running on port ${config.port} (accessible on all network interfaces)`
);
@@ -492,6 +492,12 @@ app.listen(config.port, "0.0.0.0", async () => {
}
})();
});
}
main().catch((err) => {
logger.error("Server failed to start:", err);
process.exit(1);
});
// Graceful shutdown handling
let isShuttingDown = false;
@@ -552,7 +558,7 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
// Global error handlers to prevent silent crashes
process.on("unhandledRejection", (reason, promise) => {
process.on("unhandledRejection", (reason, _promise) => {
logger.error("Unhandled Promise Rejection:", {
reason: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
+8
View File
@@ -293,6 +293,14 @@ class EnrichmentStateService {
await this.redis.del(AUDIO_ANALYSIS_GATE_KEY);
}
/**
* Publish a message to a Redis channel using the shared publisher connection.
* Use this instead of creating ephemeral Redis connections.
*/
async publishToChannel(channel: string, message: string): Promise<void> {
await this.publisher.publish(channel, message);
}
/**
* Cleanup connections
*/
+3 -5
View File
@@ -89,9 +89,7 @@ async function clearPauseState(): Promise<void> {
await enrichmentStateService.clearGate();
// Resume the Python audio analyzer in case it was paused by a prior stop
try {
const pub = new Redis(config.redisUrl);
await pub.publish(AUDIO_ANALYSIS_CONTROL_CHANNEL, "resume");
await pub.quit();
await enrichmentStateService.publishToChannel(AUDIO_ANALYSIS_CONTROL_CHANNEL, "resume");
} catch (err) {
logger.warn(`[Enrichment] Failed to resume audio analyzer: ${(err as Error).message}`);
}
@@ -1629,7 +1627,7 @@ export async function triggerEnrichmentNow(): Promise<{
immediateEnrichmentRequested = true;
// Run full cycle but it will stop after artists phase if paused/stopped
const cycleResult = await runEnrichmentCycle(false);
await runEnrichmentCycle(false);
return { count: result.count };
}
@@ -1647,7 +1645,7 @@ export async function triggerEnrichmentNow(): Promise<{
await clearPauseState();
immediateEnrichmentRequested = true;
const cycleResult = await runEnrichmentCycle(false);
await runEnrichmentCycle(false);
return { count: result.count };
}
+13 -20
View File
@@ -82,6 +82,7 @@ export const getApiBaseUrl = () => {
class ApiClient {
private token: string | null = null;
private tokenInitialized: boolean = false;
private refreshPromise: Promise<boolean> | null = null;
constructor() {
// Try to load token synchronously
@@ -159,10 +160,20 @@ class ApiClient {
}
/**
* Refresh the access token using the refresh token
* @returns true if refresh succeeded, false otherwise
* Refresh the access token using the refresh token.
* Deduplicates concurrent refresh calls -- all concurrent 401s share one refresh attempt.
*/
private async refreshAccessToken(): Promise<boolean> {
if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = this._doRefresh();
try {
return await this.refreshPromise;
} finally {
this.refreshPromise = null;
}
}
private async _doRefresh(): Promise<boolean> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
return false;
@@ -180,14 +191,12 @@ class ApiClient {
);
if (!response.ok) {
// Refresh token invalid or expired - clear tokens
this.clearToken();
return false;
}
const data = await response.json();
// Store new tokens
if (data.token) {
this.setToken(data.token, data.refreshToken);
return true;
@@ -1102,22 +1111,6 @@ class ApiClient {
);
}
async updatePodcastProgress(
podcastId: string,
episodeId: string,
currentTime: number,
duration: number,
isFinished: boolean = false
) {
return this.updatePodcastEpisodeProgress(
podcastId,
episodeId,
currentTime,
duration,
isFinished
);
}
async deletePodcastEpisodeProgress(podcastId: string, episodeId: string) {
return this.request<ApiData>(
`/podcasts/${podcastId}/episodes/${episodeId}/progress`,
+1 -1
View File
@@ -998,7 +998,7 @@ export function AudioControlsProvider({ children }: { children: ReactNode }) {
try {
const [podcastId, episodeId] = podcast.id.split(":");
await api.updatePodcastProgress(
await api.updatePodcastEpisodeProgress(
podcastId,
episodeId,
isFinished ? duration : currentTime,
+4 -5
View File
@@ -59,11 +59,6 @@ const nextConfig: NextConfig = {
hostname: "assets.pippa.io",
pathname: "/**",
},
{
protocol: "https",
hostname: "assets.fanart.tv",
pathname: "/**",
},
{
protocol: "https",
hostname: "is1-ssl.mzstatic.com",
@@ -102,6 +97,10 @@ const nextConfig: NextConfig = {
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Content-Security-Policy",
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://cdn-images.dzcdn.net https://e-cdns-images.dzcdn.net https://lastfm.freetls.fastly.net https://lastfm-img2.akamaized.net https://assets.pippa.io https://is1-ssl.mzstatic.com; media-src 'self' blob:; connect-src 'self' ws: wss:; font-src 'self'; frame-ancestors 'none';",
},
],
},
];
+1 -1
View File
@@ -31,7 +31,6 @@
"@react-three/postprocessing": "^3.0.4",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/dompurify": "^3.0.5",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"deck.gl": "^9.2.11",
@@ -54,6 +53,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.183.1",
"@types/dompurify": "^3.0.5",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"tailwindcss": "^4.2.1",
-25
View File
@@ -1,25 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
DEFAULT: '#fca200',
hover: '#e69200',
light: '#fcb84d',
dark: '#d48c00'
}
},
screens: {
'3xl': '1920px', // TV/Large Desktop
'4xl': '2560px', // 4K TV/Large TV
},
},
},
plugins: [],
}