Skip to content

Commit caf3cae

Browse files
author
Your Name
committed
release: v1.5.8 — mobile playback fixes, security hardening, lint cleanup
Fix 4 mobile/iOS playback bugs: infinite network retry loop, silence keepalive running during active playback, play button failing outside gesture window, and MediaSession handlers never registering after app restore. Add resumeWithGesture() across 13 call sites. Security hardening: safeError() across ~82 catch blocks to prevent error leakage, SSRF protection on cover art proxy, login timing normalization, crypto.randomInt() for device links, select clauses on user queries, metrics auth gate, registration gate with rate limiting, admin role check fix. Production cleanup: remove dead code/imports, fix all ESLint warnings, add cover art fetch retry for transient network errors.
1 parent d67c8f4 commit caf3cae

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+475
-680
lines changed

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@ All notable changes to Kima will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.5.8] - 2026-02-26
9+
10+
### Fixed
11+
12+
- **Mobile playback: infinite network retry loop**: On mobile networks, transient `MEDIA_ERR_NETWORK` errors triggered a retry cycle that never terminated -- `canplay` and `playing` events reset the retry counter to 0 on every cycle, and `audio.load()` reset `currentTime` to 0, causing the "2-3 seconds then starts over" symptom. Fixed by removing the premature counter resets (counter now only resets on new track load) and saving/restoring playback position across retries.
13+
- **Mobile playback: silence keepalive running during active playback**: The silence keepalive element (used to hold the iOS/Android audio session while paused in background) was started via `prime()` from a non-gesture context, then `stop()` failed to pause it because the `play()` promise hadn't resolved yet, making `el.paused` still true. Fixed by adding proper async play-promise tracking with a `pendingStop` flag, and removing the non-gesture `prime()`/`stop()` calls from the audio engine's `playing` event handler.
14+
- **Mobile playback: play button tap fails to resume on iOS**: All in-app play buttons called `resume()` which only set React state; the actual `audio.play()` ran in a `useEffect` after re-render, outside the iOS user-gesture activation window. Fixed by adding a `resumeWithGesture()` helper that calls `audioEngine.tryResume()` and `silenceKeepalive.prime()` synchronously within the gesture context -- the same pattern already used by MediaSession lock-screen handlers. Applied across all 13 play/resume call sites.
15+
- **Mobile playback: lock screen / notification controls unresponsive after app restore**: MediaSession action handlers were never registered when the app loaded with a server-restored track because the `hasPlayedLocallyRef` guard blocked registration, and the handler registration effect's dependency array was missing `isPlaying`, so it never re-ran when the flag was set. Fixed by adding `isPlaying` to the dependency array.
16+
- **Cover art proxy transient fetch errors**: External cover art fetches that hit transient TCP errors (`ECONNRESET`, `ETIMEDOUT`, `UND_ERR_SOCKET`) now retry once with a 500ms delay before failing.
17+
18+
### Security
19+
20+
- **Error message leakage**: All ~82 backend route catch blocks replaced with a `safeError()` helper that logs the full error server-side but returns only `"Internal server error"` to the client. Prevents stack traces, file paths, and internal details from leaking to users.
21+
- **SSRF protection on cover art proxy**: The cover-art proxy endpoint now validates URLs before fetching -- blocks private/loopback IPs, non-HTTP schemes, and resolves DNS to check for rebinding attacks. Audiobook cover paths also block directory traversal.
22+
- **Login timing side-channel**: Login endpoint previously returned early on user-not-found, allowing username enumeration via response timing. Now runs a dummy bcrypt compare against an invalid hash to normalize response times regardless of whether the user exists.
23+
- **Device link code generation**: Replaced `Math.random()` with `crypto.randomInt()` for cryptographically secure device link codes.
24+
- **Unscoped user queries**: Added `select` clauses to all Prisma user queries that previously loaded full rows (including `passwordHash`) when only the ID or specific fields were needed.
25+
- **Metrics endpoint authentication**: `/api/metrics` now requires authentication.
26+
- **Registration gate**: Added `registrationOpen` system setting (default: closed) and rate limiter on the registration endpoint. After the first user is created, new registrations require an admin to explicitly open registration.
27+
- **Admin password reset role check**: Fixed case mismatch (`"ADMIN"` vs `"admin"`) that could allow non-admin users to trigger password resets.
28+
29+
### Housekeeping
30+
31+
- Removed unused `sectionIndex` variables in audiobooks, home, and podcasts pages.
32+
- Removed dead commented-out album cover grid code and unused imports in DiscoverHero.
33+
- Fixed missing `useCallback` wrapper for `loadPresets` in MoodMixer.
34+
- Added missing `previewLoadState` to effect dependency array in usePodcastData.
35+
836
## [1.5.7] - 2026-02-23
937

1038
### Added

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "kima-backend",
3-
"version": "1.5.7",
3+
"version": "1.5.8",
44
"description": "Kima backend API server",
55
"license": "GPL-3.0",
66
"repository": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "SystemSettings" ADD COLUMN "registrationOpen" BOOLEAN NOT NULL DEFAULT false;

backend/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ model SystemSettings {
570570
lastfmUserKey String?
571571
lastfmEnabled Boolean?
572572
publicUrl String?
573+
registrationOpen Boolean @default(false)
573574
}
574575

575576
model Track {

backend/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@ app.use(
132132
app.use("/api/auth/login", authLimiter);
133133
app.use("/api/auth/register", authLimiter);
134134
app.use("/api/auth", authRoutes);
135-
app.use("/api/onboarding", onboardingRoutes); // Public onboarding routes
135+
app.use("/api/onboarding/register", authLimiter);
136+
app.use("/api/onboarding", onboardingRoutes);
136137

137138
// Apply general API rate limiting to all API routes
138139
app.use("/api/api-keys", apiLimiter, apiKeysRoutes);
@@ -181,7 +182,7 @@ app.get("/api/health", (req, res) => {
181182
});
182183

183184
// Prometheus metrics endpoint
184-
app.get("/api/metrics", async (req, res) => {
185+
app.get("/api/metrics", requireAuth, async (req, res) => {
185186
try {
186187
const { getMetrics } = await import("./utils/metrics");
187188
res.set("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
@@ -270,7 +271,10 @@ async function checkPasswordReset() {
270271
if (!resetPassword) return;
271272

272273
const bcrypt = await import("bcrypt");
273-
const adminUser = await prisma.user.findFirst({ where: { role: "ADMIN" } });
274+
const adminUser = await prisma.user.findFirst({
275+
where: { role: "admin" },
276+
select: { id: true },
277+
});
274278
if (!adminUser) {
275279
logger.warn("[Password Reset] No admin user found");
276280
return;

backend/src/routes/artists.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { deezerService } from "../services/deezer";
77
import { redisClient } from "../utils/redis";
88
import { normalizeToArray } from "../utils/normalize";
99
import { requireAuthOrToken } from "../middleware/auth";
10+
import { safeError } from "../utils/errors";
1011

1112
const router = Router();
1213
router.use(requireAuthOrToken);
@@ -35,12 +36,8 @@ router.get("/preview/:artistName/:trackTitle", async (req, res) => {
3536
} else {
3637
res.status(404).json({ error: "Preview not found" });
3738
}
38-
} catch (error: any) {
39-
logger.error("Preview fetch error:", error);
40-
res.status(500).json({
41-
error: "Failed to fetch preview",
42-
message: error.message,
43-
});
39+
} catch (error) {
40+
safeError(res, "Track preview fetch", error);
4441
}
4542
});
4643

@@ -375,12 +372,8 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
375372
}
376373

377374
res.json(response);
378-
} catch (error: any) {
379-
logger.error("Artist discovery error:", error);
380-
res.status(500).json({
381-
error: "Failed to fetch artist details",
382-
message: error.message,
383-
});
375+
} catch (error) {
376+
safeError(res, "Artist discovery", error);
384377
}
385378
});
386379

@@ -574,12 +567,8 @@ router.get("/album/:mbid", async (req, res) => {
574567
}
575568

576569
res.json(response);
577-
} catch (error: any) {
578-
logger.error("Album discovery error:", error);
579-
res.status(500).json({
580-
error: "Failed to fetch album details",
581-
message: error.message,
582-
});
570+
} catch (error) {
571+
safeError(res, "Album discovery", error);
583572
}
584573
});
585574

backend/src/routes/audiobooks.ts

Lines changed: 25 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Router } from "express";
22
import * as fs from "fs";
33
import * as path from "path";
44
import { logger } from "../utils/logger";
5+
import { safeError } from "../utils/errors";
56
import { audiobookshelfService } from "../services/audiobookshelf";
67
import { audiobookCacheService } from "../services/audiobookCache";
78
import { prisma } from "../utils/db";
@@ -58,12 +59,8 @@ router.get(
5859
});
5960

6061
res.json(transformed);
61-
} catch (error: any) {
62-
logger.error("Error fetching continue listening:", error);
63-
res.status(500).json({
64-
error: "Failed to fetch continue listening",
65-
message: error.message,
66-
});
62+
} catch (error) {
63+
safeError(res, "Failed to fetch continue listening", error);
6764
}
6865
}
6966
);
@@ -107,12 +104,8 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
107104
success: true,
108105
result,
109106
});
110-
} catch (error: any) {
111-
logger.error("Audiobook sync failed:", error);
112-
res.status(500).json({
113-
error: "Sync failed",
114-
message: error.message,
115-
});
107+
} catch (error) {
108+
safeError(res, "Audiobook sync failed", error);
116109
}
117110
});
118111

@@ -180,9 +173,8 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
180173
sampleSeriesData: allSeriesInfo,
181174
fullSampleWithSeries: fullSample,
182175
});
183-
} catch (error: any) {
184-
logger.error("[Audiobooks] Debug series error:", error);
185-
res.status(500).json({ error: error.message });
176+
} catch (error) {
177+
safeError(res, "Debug series fetch failed", error);
186178
}
187179
});
188180

@@ -206,12 +198,8 @@ router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
206198

207199
const results = await audiobookshelfService.searchAudiobooks(q);
208200
res.json(results);
209-
} catch (error: any) {
210-
logger.error("Error searching audiobooks:", error);
211-
res.status(500).json({
212-
error: "Failed to search audiobooks",
213-
message: error.message,
214-
});
201+
} catch (error) {
202+
safeError(res, "Failed to search audiobooks", error);
215203
}
216204
});
217205

@@ -293,12 +281,8 @@ router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
293281
});
294282

295283
res.json(audiobooksWithProgress);
296-
} catch (error: any) {
297-
logger.error("Error fetching audiobooks:", error);
298-
res.status(500).json({
299-
error: "Failed to fetch audiobooks",
300-
message: error.message,
301-
});
284+
} catch (error) {
285+
safeError(res, "Failed to fetch audiobooks", error);
302286
}
303287
});
304288

@@ -387,12 +371,8 @@ router.get(
387371
});
388372

389373
res.json(seriesBooks);
390-
} catch (error: any) {
391-
logger.error("Error fetching series:", error);
392-
res.status(500).json({
393-
error: "Failed to fetch series",
394-
message: error.message,
395-
});
374+
} catch (error) {
375+
safeError(res, "Failed to fetch series", error);
396376
}
397377
}
398378
);
@@ -492,12 +472,8 @@ router.get("/:id/cover", async (req, res) => {
492472

493473
// No cover available
494474
return res.status(404).json({ error: "Cover not found" });
495-
} catch (error: any) {
496-
logger.error("Error serving cover:", error);
497-
res.status(500).json({
498-
error: "Failed to serve cover",
499-
message: error.message,
500-
});
475+
} catch (error) {
476+
safeError(res, "Failed to serve cover", error);
501477
}
502478
});
503479

@@ -587,12 +563,8 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
587563
};
588564

589565
res.json(response);
590-
} catch (error: any) {
591-
logger.error("Error fetching audiobook__", error);
592-
res.status(500).json({
593-
error: "Failed to fetch audiobook",
594-
message: error.message,
595-
});
566+
} catch (error) {
567+
safeError(res, "Failed to fetch audiobook", error);
596568
}
597569
});
598570

@@ -663,23 +635,16 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
663635

664636
stream.pipe(res);
665637

666-
stream.on("error", (error: any) => {
638+
stream.on("error", (error: unknown) => {
667639
logger.error("[Audiobook Stream] Stream error:", error);
668640
if (!res.headersSent) {
669-
res.status(500).json({
670-
error: "Failed to stream audiobook",
671-
message: error.message,
672-
});
641+
safeError(res, "Audiobook stream error", error);
673642
} else {
674643
res.end();
675644
}
676645
});
677-
} catch (error: any) {
678-
logger.error("[Audiobook Stream] Error:", error.message);
679-
res.status(500).json({
680-
error: "Failed to stream audiobook",
681-
message: error.message,
682-
});
646+
} catch (error) {
647+
safeError(res, "Failed to stream audiobook", error);
683648
}
684649
});
685650

@@ -844,12 +809,8 @@ router.post(
844809
isFinished: progress.isFinished,
845810
},
846811
});
847-
} catch (error: any) {
848-
logger.error("Error updating progress:", error);
849-
res.status(500).json({
850-
error: "Failed to update progress",
851-
message: error.message,
852-
});
812+
} catch (error) {
813+
safeError(res, "Failed to update progress", error);
853814
}
854815
}
855816
);
@@ -905,12 +866,8 @@ router.delete(
905866
success: true,
906867
message: "Progress removed",
907868
});
908-
} catch (error: any) {
909-
logger.error("Error removing progress:", error);
910-
res.status(500).json({
911-
error: "Failed to remove progress",
912-
message: error.message,
913-
});
869+
} catch (error) {
870+
safeError(res, "Failed to remove progress", error);
914871
}
915872
}
916873
);

backend/src/routes/auth.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,12 @@ router.post("/login", async (req, res) => {
6969
const { token } = req.body; // 2FA token if provided
7070

7171
const user = await prisma.user.findUnique({ where: { username } });
72-
if (!user) {
73-
logger.debug(`[AUTH] User not found: ${username}`);
74-
return res.status(401).json({ error: "Invalid credentials" });
75-
}
7672

77-
logger.debug(`[AUTH] Verifying password for user: ${username}`);
78-
const valid = await bcrypt.compare(password, user.passwordHash);
79-
if (!valid) {
80-
logger.debug(`[AUTH] Invalid password for user: ${username}`);
73+
// Timing-safe: always run bcrypt to prevent username enumeration
74+
const dummyHash = "$2b$10$invalidhashfortimingsafety.00000000000000000000";
75+
const valid = await bcrypt.compare(password, user?.passwordHash ?? dummyHash);
76+
if (!user || !valid) {
77+
logger.debug(`[AUTH] Invalid credentials for: ${username}`);
8178
return res.status(401).json({ error: "Invalid credentials" });
8279
}
8380
logger.debug(`[AUTH] Password verified for user: ${username}`);
@@ -292,6 +289,7 @@ router.post("/change-password", requireAuth, async (req, res) => {
292289
// Verify current password
293290
const user = await prisma.user.findUnique({
294291
where: { id: req.user!.id },
292+
select: { id: true, passwordHash: true },
295293
});
296294

297295
if (!user) {
@@ -548,6 +546,7 @@ router.post("/2fa/disable", requireAuth, async (req, res) => {
548546

549547
const user = await prisma.user.findUnique({
550548
where: { id: req.user!.id },
549+
select: { id: true, passwordHash: true, twoFactorSecret: true },
551550
});
552551

553552
if (!user) {

0 commit comments

Comments
 (0)