fix: harden downloads, settings, and UI edge cases

- Integrate DownloadRegistry into concurrent download limit
  and clean up timers on removal/clear
- Wrap MetadataSyncService batch deletes in single transaction
- Always re-throw checksum errors in RuffleService to prevent
  verification bypass
- Fix system-settings early return skipping second job update
  when first cron expression is invalid
- Increase DatabaseService hot-reload grace period to 30s
- Add permission guard to metadata sync status endpoint
- Use encodeURIComponent for game launch content URLs
- Clamp limit params to minimum of 1 in jobs routes
- Use non-null assertion for authenticated user permissions
- Fix ConfirmDialog not calling onCancel on overlay dismiss
- Guard FormattedDate against invalid date strings
- Add breadcrumbContext to GameListItem memo comparison
- Fix MetadataUpdateCard stale closure in interval callback
- Move Dockerfile healthcheck to docker-compose for flexibility
- Fix build:analyze script to set VITE_ANALYZE env var
This commit is contained in:
Darkraise
2026-02-11 20:37:06 +07:00
parent f281672faa
commit 79cc256214
17 changed files with 115 additions and 66 deletions
+4 -2
View File
@@ -118,9 +118,10 @@ export class GameZipServer {
return { success: true, downloading: true, statusCode: 202 };
}
if (downloadsInProgress.size >= MAX_CONCURRENT_DOWNLOADS) {
const totalActive = downloadsInProgress.size + DownloadRegistry.getActiveCount();
if (totalActive >= MAX_CONCURRENT_DOWNLOADS) {
logger.warn(
`[GameZipServer] Too many concurrent downloads (${downloadsInProgress.size}), rejecting ${gameId}`
`[GameZipServer] Too many concurrent downloads (${totalActive}), rejecting ${gameId}`
);
return { success: false, statusCode: 503 };
}
@@ -406,6 +407,7 @@ export class GameZipServer {
for (const [gameId, progress] of downloadsInProgress.entries()) {
if (now - progress.startTime > DOWNLOAD_STALE_MS) {
logger.warn(`[GameZipServer] Removing stale download for ${gameId}`);
DownloadRegistry.fail(gameId);
downloadsInProgress.delete(gameId);
}
}
+1 -1
View File
@@ -230,7 +230,7 @@ router.get(
}
}
contentUrl = `${proxyUrl}/${encodeURI(fullUrl)}`;
contentUrl = `${proxyUrl}/${encodeURIComponent(fullUrl)}`;
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ router.get(
asyncHandler(async (req: Request, res: Response) => {
try {
const rawLimit = parseInt(req.query.limit as string, 10);
const limit = Math.min(isNaN(rawLimit) ? 100 : rawLimit, 200);
const limit = Math.min(Math.max(1, isNaN(rawLimit) ? 100 : rawLimit), 200);
const rawOffset = parseInt(req.query.offset as string, 10);
const offset = Math.max(0, isNaN(rawOffset) ? 0 : rawOffset);
@@ -212,7 +212,7 @@ router.get(
}
const rawLimit = parseInt(req.query.limit as string, 10);
const limit = Math.min(isNaN(rawLimit) ? 50 : rawLimit, 100);
const limit = Math.min(Math.max(1, isNaN(rawLimit) ? 50 : rawLimit), 100);
const rawOffset = parseInt(req.query.offset as string, 10);
const offset = Math.max(0, isNaN(rawOffset) ? 0 : rawOffset);
+2 -2
View File
@@ -80,7 +80,7 @@ router.post(
if (data.permissionIds && data.permissionIds.length > 0) {
await roleService.validatePermissionEscalation(
data.permissionIds,
req.user?.permissions || []
req.user!.permissions ?? []
);
}
@@ -127,7 +127,7 @@ router.put(
const data = updatePermissionsSchema.parse(req.body);
await roleService.validatePermissionEscalation(data.permissionIds, req.user?.permissions || []);
await roleService.validatePermissionEscalation(data.permissionIds, req.user!.permissions ?? []);
await roleService.updateRolePermissions(id, data.permissionIds);
const role = await roleService.getRoleById(id);
+12 -14
View File
@@ -29,14 +29,13 @@ function updateJobScheduler(category: string, settings: CategorySettings): void
'0 * * * *') as string;
if (cronSchedule && !cron.validate(cronSchedule)) {
logger.warn(`[Settings] Invalid cron expression: ${cronSchedule}`);
return;
logger.warn(`[Settings] Invalid metadata-sync cron expression: ${cronSchedule}`);
} else {
JobScheduler.updateJob('metadata-sync', {
enabled,
cronSchedule,
});
}
JobScheduler.updateJob('metadata-sync', {
enabled,
cronSchedule,
});
}
if ('ruffleUpdateEnabled' in settings || 'ruffleUpdateSchedule' in settings) {
@@ -49,14 +48,13 @@ function updateJobScheduler(category: string, settings: CategorySettings): void
'0 0 * * *') as string;
if (cronSchedule && !cron.validate(cronSchedule)) {
logger.warn(`[Settings] Invalid cron expression: ${cronSchedule}`);
return;
logger.warn(`[Settings] Invalid ruffle-update cron expression: ${cronSchedule}`);
} else {
JobScheduler.updateJob('ruffle-update', {
enabled,
cronSchedule,
});
}
JobScheduler.updateJob('ruffle-update', {
enabled,
cronSchedule,
});
}
}
}
+1
View File
@@ -98,6 +98,7 @@ router.post(
router.get(
'/metadata/sync/status',
authenticate,
requirePermission('settings.read'),
asyncHandler(async (req, res) => {
const syncStatus = SyncStatusService.getInstance();
const status = syncStatus.getStatus();
+8 -6
View File
@@ -328,8 +328,9 @@ export class DatabaseService {
const oldDb = this.db;
this.db = newDb;
// Close old connection after grace period (5s)
// Allows in-flight queries that captured the old reference to complete
// Close old connection after grace period (30s)
// Allows in-flight queries that captured the old reference to complete.
// 30s accommodates slow queries; the server request timeout is 120s.
if (oldDb) {
setTimeout(() => {
try {
@@ -338,7 +339,7 @@ export class DatabaseService {
} catch (closeError) {
logger.warn('Failed to close old database handle:', closeError);
}
}, 5000).unref(); // .unref() so it doesn't prevent shutdown
}, 30000).unref(); // .unref() so it doesn't prevent shutdown
}
// Update modification time
@@ -397,8 +398,9 @@ export class DatabaseService {
const oldDb = this.db;
this.db = newDb;
// Close old connection after grace period (5s)
// Allows in-flight queries that captured the old reference to complete
// Close old connection after grace period (30s)
// Allows in-flight queries that captured the old reference to complete.
// 30s accommodates slow queries; the server request timeout is 120s.
if (oldDb) {
setTimeout(() => {
try {
@@ -407,7 +409,7 @@ export class DatabaseService {
} catch (closeError) {
logger.warn('Failed to close old database handle:', closeError);
}
}, 5000).unref(); // .unref() so it doesn't prevent shutdown
}, 30000).unref(); // .unref() so it doesn't prevent shutdown
}
// Update modification time
+38 -6
View File
@@ -18,6 +18,7 @@ export interface DownloadEntry {
*/
export class DownloadRegistry {
private static activeDownloads = new Map<string, DownloadEntry>();
private static pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* Register a download. Returns false if already downloading (duplicate prevention).
@@ -31,6 +32,8 @@ export class DownloadRegistry {
logger.debug(`[DownloadRegistry] Download already in progress for ${gameId}`);
return false;
}
// Cancel any pending cleanup timer from a previous download of the same game
this.cancelTimer(gameId);
this.activeDownloads.set(gameId, {
...entry,
startedAt: Date.now(),
@@ -53,9 +56,7 @@ export class DownloadRegistry {
entry.status = 'completed';
logger.debug(`[DownloadRegistry] Download completed: ${gameId}`);
// Remove after short delay to allow SSE clients to see completion status
setTimeout(() => {
this.activeDownloads.delete(gameId);
}, 5000);
this.scheduleRemoval(gameId, entry.startedAt);
}
}
@@ -70,9 +71,36 @@ export class DownloadRegistry {
entry.status = 'failed';
logger.debug(`[DownloadRegistry] Download failed: ${gameId}`);
// Remove after short delay to allow SSE clients to see failure status
setTimeout(() => {
this.scheduleRemoval(gameId, entry.startedAt);
}
}
/**
* Schedule removal of an entry after a delay, verifying entry identity
* to prevent deleting a newer entry for the same gameId.
*/
private static scheduleRemoval(gameId: string, startedAt: number): void {
this.cancelTimer(gameId);
const timer = setTimeout(() => {
this.pendingTimers.delete(gameId);
const current = this.activeDownloads.get(gameId);
// Only delete if the entry is the same one we marked (not a newer download)
if (current && current.startedAt === startedAt) {
this.activeDownloads.delete(gameId);
}, 5000);
}
}, 5000);
timer.unref();
this.pendingTimers.set(gameId, timer);
}
/**
* Cancel a pending cleanup timer for a gameId.
*/
private static cancelTimer(gameId: string): void {
const existing = this.pendingTimers.get(gameId);
if (existing) {
clearTimeout(existing);
this.pendingTimers.delete(gameId);
}
}
@@ -138,9 +166,13 @@ export class DownloadRegistry {
}
/**
* Clear all entries (for testing/cleanup).
* Clear all entries and cancel pending timers (for testing/cleanup).
*/
static clear(): void {
for (const timer of this.pendingTimers.values()) {
clearTimeout(timer);
}
this.pendingTimers.clear();
this.activeDownloads.clear();
logger.debug('[DownloadRegistry] Cleared all entries');
}
+8 -7
View File
@@ -647,21 +647,22 @@ export class MetadataSyncService {
if (!Array.isArray(gameIds) || gameIds.length === 0) return;
const db = DatabaseService.getDatabase();
// Process in batches to avoid SQL variable limits
// Wrap all batches in a single transaction to maintain consistency.
// If the process crashes mid-way, all changes are rolled back.
const BATCH_SIZE = 500;
for (let i = 0; i < gameIds.length; i += BATCH_SIZE) {
const batch = gameIds.slice(i, i + BATCH_SIZE);
const placeholders = batch.map(() => '?').join(', ');
db.transaction(() => {
for (let i = 0; i < gameIds.length; i += BATCH_SIZE) {
const batch = gameIds.slice(i, i + BATCH_SIZE);
const placeholders = batch.map(() => '?').join(', ');
db.transaction(() => {
// Orphan children first (prevents FK violation)
db.prepare(
`UPDATE game SET parentGameId = NULL WHERE parentGameId IN (${placeholders})`
).run(batch);
// Then delete
db.prepare(`DELETE FROM game WHERE id IN (${placeholders})`).run(batch);
})();
}
}
})();
}
private async updatePreferencesTimestamps(
+10 -9
View File
@@ -125,7 +125,9 @@ export class RuffleService {
const normalizedCurrent = currentVersion ? this.normalizeVersion(currentVersion) : null;
const normalizedLatest = this.normalizeVersion(latest.version);
const updateAvailable = normalizedCurrent === null || normalizedCurrent !== normalizedLatest;
// Only flag as "update available" when Ruffle is already installed but outdated.
// When not installed (null), the server auto-installs at startup — no need to show update UI.
const updateAvailable = normalizedCurrent !== null && normalizedCurrent !== normalizedLatest;
return {
currentVersion,
@@ -210,14 +212,13 @@ export class RuffleService {
logger.info('[RuffleService] SHA-256 checksum verification passed');
} catch (error) {
if (error instanceof Error && error.message.includes('checksum mismatch')) {
// Re-throw checksum failures
throw error;
}
// Log but don't fail on other errors (e.g., network issues downloading checksum)
logger.error('[RuffleService] Error during checksum verification:', error);
logger.warn('[RuffleService] Continuing without checksum verification');
// Always re-throw when a checksum URL was explicitly provided —
// skipping verification only when no checksum file exists in the release.
// This prevents an attacker who can block the checksum URL from
// forcing verification bypass while the tampered ZIP goes through.
logger.error('[RuffleService] Checksum verification failed:', error);
const message = error instanceof Error ? error.message : 'Unknown checksum error';
throw new Error(`Checksum verification failed: ${message}`);
}
}
+2 -3
View File
@@ -62,9 +62,8 @@ RUN chown -R nginx:nginx /usr/share/nginx/html && \
# Expose port (8080 for non-root user)
EXPOSE 8080
# Health check (using wget which is built into Alpine)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --spider http://localhost:8080/ || exit 1
# Health check is configured in docker-compose.yml for flexibility.
# When running standalone (docker run), add --health-cmd manually.
# Run as non-root user (nginx user from base image)
USER nginx
+2 -2
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:analyze": "tsc && vite build",
"build:analyze": "VITE_ANALYZE=true tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
@@ -16,7 +16,7 @@
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json}\"",
"check-bundle": "vite build --mode analyze"
"check-bundle": "VITE_ANALYZE=true vite build"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -43,6 +43,9 @@ export function ConfirmDialog({
const variantNormalized = variant === 'danger' ? 'destructive' : variant;
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && onCancel) {
onCancel();
}
if (onOpenChange) {
onOpenChange(newOpen);
}
@@ -12,14 +12,18 @@ export function FormattedDate({ date, type = 'datetime', className }: FormattedD
const getFormattedValue = () => {
if (!date) return 'Unknown';
switch (type) {
case 'date':
return formatDate(date);
case 'time':
return formatTime(date);
case 'datetime':
default:
return formatDateTime(date);
try {
switch (type) {
case 'date':
return formatDate(date);
case 'time':
return formatTime(date);
case 'datetime':
default:
return formatDateTime(date);
}
} catch {
return String(date);
}
};
@@ -194,6 +194,8 @@ export const GameListItem = memo(
prevProps.showAddToPlaylistButton === nextProps.showAddToPlaylistButton &&
prevProps.isFavoritePage === nextProps.isFavoritePage &&
prevProps.shareToken === nextProps.shareToken &&
prevProps.breadcrumbContext?.label === nextProps.breadcrumbContext?.label &&
prevProps.breadcrumbContext?.href === nextProps.breadcrumbContext?.href &&
(prevProps.favoriteGameIds?.has(prevProps.game.id) ?? false) ===
(nextProps.favoriteGameIds?.has(nextProps.game.id) ?? false)
);
@@ -17,6 +17,8 @@ export function MetadataUpdateCard() {
const [syncMessage, setSyncMessage] = useState('');
const [metadataInfo, setMetadataInfo] = useState<MetadataUpdateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
// Use ref for the fetch guard to avoid stale closure issues in interval callbacks
const isFetchingRef = useRef(false);
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false);
// Use ref to track poll interval (avoids stale closure issues)
@@ -24,10 +26,11 @@ export function MetadataUpdateCard() {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const fetchMetadataInfo = async () => {
if (isFetchingMetadata) {
if (isFetchingRef.current) {
return;
}
isFetchingRef.current = true;
setIsFetchingMetadata(true);
try {
@@ -42,6 +45,7 @@ export function MetadataUpdateCard() {
});
setError('Failed to check metadata updates. Please try again later.');
} finally {
isFetchingRef.current = false;
setIsFetchingMetadata(false);
}
};
@@ -51,7 +55,7 @@ export function MetadataUpdateCard() {
});
const checkMetadataUpdates = async () => {
setIsFetchingMetadata(false);
isFetchingRef.current = false;
await fetchMetadataInfo();
};
+2 -2
View File
@@ -6,9 +6,9 @@ import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
// Bundle analyzer - run with: npm run build && open stats.html
// Bundle analyzer - run with: npm run build:analyze && open stats.html
visualizer({
open: false,
open: !!process.env.VITE_ANALYZE,
filename: 'stats.html',
gzipSize: true,
brotliSize: true,