mirror of
https://github.com/Chevron7Locked/kima-hub.git
synced 2026-06-19 07:37:17 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
Reference in New Issue
Block a user