fix: validate inputs, harden services, and DRY

route handlers

- DRY timeRange validation into shared parseTimeRange()
- Add jobId format validation, cron schedule validation,
  and date format validation on route inputs
- Add auth middleware to update check/metadata endpoints
- Harden sync start with atomic tryStartSync() to prevent
  race conditions
- Add symlink escape prevention and SVG CSP headers to
  proxy routes
- Cache public settings in CachedSystemSettingsService
- Fix MetadataSyncService: validate allowed hosts (SSRF),
  batch deletes to avoid SQL variable limits, use ?? over
  || for nullable fields
- Remove unnecessary async from synchronous DB methods in
  GameDatabaseUpdater
- Add path traversal check in makeRelativePath()
- Prevent setting creation via set() — only allow updates
- Remove unused getJobStatus/getAllJobs, lastRunTimes map
- Strip internal paths from getSystemInfo() response
- Sanitize update error messages to avoid leaking stderr
- Add job log cleanup interval with unref()
- Replace hardcoded colors with theme tokens, fix React
  Fragment keys, use getErrorMessage() utility
- Convert MetadataUpdateCard ref to state for reactivity
- Use ?? instead of || for config defaults across settings
This commit is contained in:
Darkraise
2026-02-09 23:37:25 +07:00
parent 80e2177b5d
commit 789355cb7e
29 changed files with 450 additions and 316 deletions
+56 -35
View File
@@ -19,13 +19,23 @@ const DEFAULT_TREND_DAYS = 7;
const MAX_TREND_DAYS = 30;
const DEFAULT_TIME_RANGE = '24h' as const;
const VALID_TIME_RANGES = ['24h', '7d', '30d'] as const;
type TimeRange = (typeof VALID_TIME_RANGES)[number];
function parseTimeRange(raw: string | undefined): TimeRange | { error: string } {
if (raw && !VALID_TIME_RANGES.includes(raw as TimeRange)) {
return { error: 'Invalid timeRange. Must be 24h, 7d, or 30d' };
}
return (raw as TimeRange) ?? DEFAULT_TIME_RANGE;
}
const activityQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(DEFAULT_PAGE),
limit: z.coerce.number().int().min(1).max(MAX_PAGE_LIMIT).default(DEFAULT_LIMIT),
userId: z.coerce.number().int().optional(),
username: z.string().optional(),
action: z.string().optional(),
resource: z.string().optional(),
action: z.string().max(200).optional(),
resource: z.string().max(200).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
sortBy: z.enum(['createdAt', 'username', 'action', 'resource', 'ipAddress']).default('createdAt'),
@@ -89,26 +99,30 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const validTimeRanges = ['24h', '7d', '30d'] as const;
const rawTimeRange = req.query.timeRange as string | undefined;
if (
rawTimeRange &&
!validTimeRanges.includes(rawTimeRange as (typeof validTimeRanges)[number])
) {
const timeRangeResult = parseTimeRange(req.query.timeRange as string | undefined);
if (typeof timeRangeResult === 'object' && 'error' in timeRangeResult) {
return res.status(400).json({
success: false,
error: 'Invalid timeRange. Must be 24h, 7d, or 30d',
error: timeRangeResult.error,
});
}
const timeRange = (rawTimeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
const timeRange = timeRangeResult;
const customRange =
req.query.startDate && req.query.endDate
? {
startDate: req.query.startDate as string,
endDate: req.query.endDate as string,
}
: undefined;
// Validate customRange date strings if provided
let customRange: { startDate: string; endDate: string } | undefined;
if (req.query.startDate && req.query.endDate) {
const startDate = req.query.startDate as string;
const endDate = req.query.endDate as string;
if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) {
return res.status(400).json({
success: false,
error: 'Invalid date format. Must be valid ISO date strings.',
});
}
customRange = { startDate, endDate };
}
const stats = await activityService.getStats(timeRange, customRange);
@@ -128,7 +142,11 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const days = Math.min(parseInt(req.query.days as string) || DEFAULT_TREND_DAYS, MAX_TREND_DAYS);
const rawDays = parseInt(req.query.days as string, 10);
const days = Math.min(
isNaN(rawDays) || rawDays < 1 ? DEFAULT_TREND_DAYS : rawDays,
MAX_TREND_DAYS
);
const result = await activityService.getTrend(days);
@@ -144,19 +162,17 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_ACTIONS_LIMIT);
const validTimeRanges = ['24h', '7d', '30d'] as const;
const rawTimeRange = req.query.timeRange as string | undefined;
if (
rawTimeRange &&
!validTimeRanges.includes(rawTimeRange as (typeof validTimeRanges)[number])
) {
const rawLimit = parseInt(req.query.limit as string, 10);
const limit = Math.min(isNaN(rawLimit) || rawLimit < 1 ? 10 : rawLimit, MAX_ACTIONS_LIMIT);
const timeRangeResult = parseTimeRange(req.query.timeRange as string | undefined);
if (typeof timeRangeResult === 'object' && 'error' in timeRangeResult) {
return res.status(400).json({
success: false,
error: 'Invalid timeRange. Must be 24h, 7d, or 30d',
error: timeRangeResult.error,
});
}
const timeRange = (rawTimeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
const timeRange = timeRangeResult;
const result = await activityService.getTopActions(limit, timeRange);
@@ -172,24 +188,29 @@ router.get(
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const groupBy = (req.query.groupBy as 'resource' | 'user' | 'ip') || 'resource';
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_BREAKDOWN_LIMIT);
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
if (!['resource', 'user', 'ip'].includes(groupBy)) {
// Validate groupBy BEFORE assignment
const rawGroupBy = req.query.groupBy as string | undefined;
if (rawGroupBy && !['resource', 'user', 'ip'].includes(rawGroupBy)) {
return res.status(400).json({
success: false,
error: 'Invalid groupBy parameter. Must be one of: resource, user, ip',
});
}
const groupBy = (rawGroupBy as 'resource' | 'user' | 'ip') ?? 'resource';
const validTimeRanges = ['24h', '7d', '30d'] as const;
if (req.query.timeRange && !validTimeRanges.includes(timeRange)) {
// Validate limit with proper parseInt
const rawLimit = parseInt(req.query.limit as string, 10);
const limit = Math.min(isNaN(rawLimit) || rawLimit < 1 ? 10 : rawLimit, MAX_BREAKDOWN_LIMIT);
// Validate timeRange BEFORE assignment
const timeRangeResult = parseTimeRange(req.query.timeRange as string | undefined);
if (typeof timeRangeResult === 'object' && 'error' in timeRangeResult) {
return res.status(400).json({
success: false,
error: 'Invalid timeRange. Must be 24h, 7d, or 30d',
error: timeRangeResult.error,
});
}
const timeRange = timeRangeResult;
const result = await activityService.getBreakdown(groupBy, limit, timeRange);
+32 -4
View File
@@ -33,8 +33,10 @@ router.get(
requirePermission('settings.update'),
asyncHandler(async (req: Request, res: Response) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
const offset = Math.max(0, parseInt(req.query.offset as string, 10) || 0);
const rawLimit = parseInt(req.query.limit as string, 10);
const limit = Math.min(isNaN(rawLimit) ? 100 : rawLimit, 200);
const rawOffset = parseInt(req.query.offset as string, 10);
const offset = Math.max(0, isNaN(rawOffset) ? 0 : rawOffset);
const result = executionService.getAllLogs(limit, offset);
res.json({ ...result, limit, offset });
@@ -52,6 +54,10 @@ router.get(
asyncHandler(async (req: Request, res: Response) => {
try {
const { jobId } = req.params;
if (!/^[a-z0-9-]+$/.test(jobId)) {
return res.status(400).json({ error: { message: 'Invalid job ID format' } });
}
const job = JobScheduler.getJobStatusEnriched(jobId);
if (!job) {
@@ -74,6 +80,10 @@ router.patch(
asyncHandler(async (req: Request, res: Response) => {
try {
const { jobId } = req.params;
if (!/^[a-z0-9-]+$/.test(jobId)) {
return res.status(400).json({ error: { message: 'Invalid job ID format' } });
}
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
@@ -118,6 +128,10 @@ router.post(
asyncHandler(async (req: Request, res: Response) => {
try {
const { jobId } = req.params;
if (!/^[a-z0-9-]+$/.test(jobId)) {
return res.status(400).json({ error: { message: 'Invalid job ID format' } });
}
JobScheduler.startJob(jobId);
res.json({ success: true, message: 'Job started successfully' });
} catch (error) {
@@ -138,6 +152,10 @@ router.post(
asyncHandler(async (req: Request, res: Response) => {
try {
const { jobId } = req.params;
if (!/^[a-z0-9-]+$/.test(jobId)) {
return res.status(400).json({ error: { message: 'Invalid job ID format' } });
}
JobScheduler.stopJob(jobId);
res.json({ success: true, message: 'Job stopped successfully' });
} catch (error) {
@@ -158,6 +176,10 @@ router.post(
asyncHandler(async (req: Request, res: Response) => {
try {
const { jobId } = req.params;
if (!/^[a-z0-9-]+$/.test(jobId)) {
return res.status(400).json({ error: { message: 'Invalid job ID format' } });
}
const userId = req.user!.id.toString();
await JobScheduler.triggerJob(jobId, userId);
@@ -185,8 +207,14 @@ router.get(
asyncHandler(async (req: Request, res: Response) => {
try {
const { jobId } = req.params;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const offset = Math.max(0, parseInt(req.query.offset as string, 10) || 0);
if (!/^[a-z0-9-]+$/.test(jobId)) {
return res.status(400).json({ error: { message: 'Invalid job ID format' } });
}
const rawLimit = parseInt(req.query.limit as string, 10);
const limit = Math.min(isNaN(rawLimit) ? 50 : rawLimit, 100);
const rawOffset = parseInt(req.query.offset as string, 10);
const offset = Math.max(0, isNaN(rawOffset) ? 0 : rawOffset);
const result = executionService.getJobLogs(jobId, limit, offset);
res.json({ ...result, limit, offset });
+51 -5
View File
@@ -42,11 +42,30 @@ async function serveFileWithFallback(
relativePath: string,
externalBaseUrls: string[],
res: Response,
next: NextFunction
next: NextFunction,
allowedBasePath: string
) {
try {
if (fs.existsSync(localPath)) {
logger.debug(`[Proxy] Serving local file: ${localPath}`);
// Resolve symlinks to prevent bypass via symlink to unauthorized location
try {
const realPath = await fsPromises.realpath(localPath);
const realBase = await fsPromises.realpath(allowedBasePath);
if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) {
logger.warn(`[Security] Symlink escape detected: ${localPath} -> ${realPath}`);
return res.status(403).json({ error: { message: 'Access denied', statusCode: 403 } });
}
} catch (realpathError) {
logger.error('[Security] Symlink resolution error:', realpathError);
return res.status(500).json({ error: { message: 'Internal error', statusCode: 500 } });
}
const ext = path.extname(localPath).slice(1).toLowerCase();
if (ext === 'svg') {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'");
}
res.setHeader('X-Content-Type-Options', 'nosniff');
return res.sendFile(localPath);
}
@@ -71,7 +90,7 @@ async function serveFileWithFallback(
const imageBuffer = Buffer.from(response.data);
if (!pendingCacheWrites.has(localPath)) {
if (!pendingCacheWrites.has(localPath) && pendingCacheWrites.size < 100) {
pendingCacheWrites.add(localPath);
(async () => {
try {
@@ -106,6 +125,12 @@ async function serveFileWithFallback(
const contentType = mimeMap[ext] || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
if (ext === 'svg') {
res.setHeader(
'Content-Security-Policy',
"default-src 'none'; style-src 'unsafe-inline'"
);
}
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
@@ -148,7 +173,14 @@ router.get(
const externalImageUrls = await getExternalImageUrls();
await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next);
await serveFileWithFallback(
localPath,
relativePath,
externalImageUrls,
res,
next,
config.flashpointImagesPath
);
})
);
@@ -183,7 +215,14 @@ router.get(
}
});
await serveFileWithFallback(localPath, relativePath, externalUrls, res, next);
await serveFileWithFallback(
localPath,
relativePath,
externalUrls,
res,
next,
config.flashpointLogosPath
);
})
);
@@ -205,7 +244,14 @@ router.get(
const externalImageUrls = await getExternalImageUrls();
await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next);
await serveFileWithFallback(
localPath,
relativePath,
externalImageUrls,
res,
next,
config.flashpointImagesPath
);
})
);
+5 -6
View File
@@ -7,8 +7,9 @@ import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
const ruffleService = new RuffleService();
router.get('/version', (req, res, next) => {
try {
router.get(
'/version',
asyncHandler(async (req, res) => {
const currentVersion = ruffleService.getCurrentVersion();
const isInstalled = ruffleService.verifyInstallation();
@@ -16,10 +17,8 @@ router.get('/version', (req, res, next) => {
currentVersion,
isInstalled,
});
} catch (error) {
next(error);
}
});
})
);
router.get(
'/check-update',
+50 -5
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import cron from 'node-cron';
import { config } from '../config';
import { CachedSystemSettingsService } from '../services/CachedSystemSettingsService';
import { DomainService } from '../services/DomainService';
@@ -27,11 +28,36 @@ function updateJobScheduler(category: string, settings: CategorySettings): void
currentSettings.metadataSyncSchedule ??
'0 * * * *') as string;
if (cronSchedule && !cron.validate(cronSchedule)) {
logger.warn(`[Settings] Invalid cron expression: ${cronSchedule}`);
return;
}
JobScheduler.updateJob('metadata-sync', {
enabled,
cronSchedule,
});
}
if ('ruffleUpdateEnabled' in settings || 'ruffleUpdateSchedule' in settings) {
const currentSettings = systemSettings.getCategory('jobs');
const enabled = (settings.ruffleUpdateEnabled ??
currentSettings.ruffleUpdateEnabled ??
false) as boolean;
const cronSchedule = (settings.ruffleUpdateSchedule ??
currentSettings.ruffleUpdateSchedule ??
'0 0 * * *') as string;
if (cronSchedule && !cron.validate(cronSchedule)) {
logger.warn(`[Settings] Invalid cron expression: ${cronSchedule}`);
return;
}
JobScheduler.updateJob('ruffle-update', {
enabled,
cronSchedule,
});
}
}
}
@@ -99,6 +125,11 @@ router.post(
const { category } = req.body;
if (category) {
if (typeof category !== 'string' || !isValidCategory(category)) {
return res
.status(400)
.json({ error: { message: `Invalid settings category: ${category}` } });
}
systemSettings.clearCategoryCache(category);
} else {
systemSettings.clearCache();
@@ -179,7 +210,21 @@ router.post(
})
);
const VALID_CATEGORIES = ['app', 'auth', 'jobs', 'metadata', 'theme', 'features', 'maintenance'];
const VALID_CATEGORIES = [
'app',
'auth',
'jobs',
'metadata',
'theme',
'features',
'maintenance',
] as const;
type ValidCategory = (typeof VALID_CATEGORIES)[number];
function isValidCategory(category: string): category is ValidCategory {
return VALID_CATEGORIES.includes(category as ValidCategory);
}
router.get(
'/:category',
@@ -188,7 +233,7 @@ router.get(
asyncHandler(async (req: Request, res: Response) => {
const { category } = req.params;
if (!VALID_CATEGORIES.includes(category)) {
if (!isValidCategory(category)) {
return res.status(400).json({
error: { message: `Invalid settings category: ${category}` },
});
@@ -212,7 +257,7 @@ router.patch(
asyncHandler(async (req: Request, res: Response) => {
const { category } = req.params;
if (!VALID_CATEGORIES.includes(category)) {
if (!isValidCategory(category)) {
return res.status(400).json({
error: { message: `Invalid settings category: ${category}` },
});
@@ -246,7 +291,7 @@ router.get(
requirePermission('settings.read'),
asyncHandler(async (req: Request, res: Response) => {
const { category, key } = req.params;
if (!VALID_CATEGORIES.includes(category)) {
if (!isValidCategory(category)) {
return res.status(400).json({
error: { message: `Invalid category: ${category}` },
});
@@ -275,7 +320,7 @@ router.patch(
logActivity('settings.update', 'system_settings'),
asyncHandler(async (req: Request, res: Response) => {
const { category, key } = req.params;
if (!VALID_CATEGORIES.includes(category)) {
if (!isValidCategory(category)) {
return res.status(400).json({
error: { message: `Invalid category: ${category}` },
});
+12 -21
View File
@@ -17,8 +17,10 @@ const metadataSyncService = new MetadataSyncService();
router.get(
'/check',
authenticate,
requirePermission('settings.update'),
asyncHandler(async (req, res) => {
logger.info('[Updates API] Checking for updates...');
logger.debug('[Updates API] Checking for updates...');
const updateInfo = await updateService.checkForUpdates();
res.json(updateInfo);
})
@@ -50,9 +52,11 @@ router.get(
// Includes edition info so frontend knows whether sync is available
router.get(
'/metadata',
authenticate,
requirePermission('settings.update'),
asyncHandler(async (req, res) => {
const edition = metadataUpdateService.getEdition();
logger.info(`[Updates API] Checking for metadata updates... (edition: ${edition})`);
logger.debug(`[Updates API] Checking for metadata updates... (edition: ${edition})`);
const metadataInfo = await metadataUpdateService.getMetadataUpdateInfo();
res.json({ ...metadataInfo, edition });
})
@@ -67,7 +71,7 @@ router.post(
asyncHandler(async (req, res) => {
const syncStatus = SyncStatusService.getInstance();
if (syncStatus.isRunning()) {
if (!syncStatus.tryStartSync()) {
return res.status(409).json({
success: false,
error: 'Sync already in progress',
@@ -79,7 +83,7 @@ router.post(
`[Updates API] Starting metadata sync in background (requested by ${req.user?.username})...`
);
metadataSyncService.syncMetadata().catch((error) => {
metadataSyncService.syncMetadata(true).catch((error) => {
logger.error('[Updates API] Background sync error:', error);
});
@@ -93,24 +97,11 @@ router.post(
router.get(
'/metadata/sync/status',
authenticate,
asyncHandler(async (req, res) => {
try {
const syncStatus = SyncStatusService.getInstance();
const status = syncStatus.getStatus();
// Sanitize error messages - don't leak internal details to unauthenticated users
if (status.error) {
status.error = 'Sync failed. Check server logs for details.';
}
res.json(status);
} catch (error) {
logger.error('[Updates API] Error getting sync status:', error);
res.status(500).json({
isRunning: false,
stage: 'error',
progress: 0,
message: 'Error getting sync status',
});
}
const syncStatus = SyncStatusService.getInstance();
const status = syncStatus.getStatus();
res.json(status);
})
);
+15
View File
@@ -21,6 +21,7 @@ import { PlayTrackingService } from './services/PlayTrackingService';
import { AuthService } from './services/AuthService';
import { GameSearchCache } from './services/GameSearchCache';
import { JobScheduler } from './services/JobScheduler';
import { JobExecutionService } from './services/JobExecutionService';
import { MetadataSyncJob } from './jobs/MetadataSyncJob';
import { RuffleUpdateJob } from './jobs/RuffleUpdateJob';
import { CachedSystemSettingsService } from './services/CachedSystemSettingsService';
@@ -320,6 +321,19 @@ async function startServer() {
logger.error('Failed to cleanup old activity logs:', error);
});
const jobExecutionService = new JobExecutionService();
const jobLogCleanupInterval = setInterval(
() => {
try {
jobExecutionService.cleanupOldLogs(30);
} catch (error) {
logger.error('Failed to cleanup old job execution logs:', error);
}
},
24 * 60 * 60 * 1000
);
jobLogCleanupInterval.unref();
const shutdown = async () => {
logger.info('Shutting down gracefully...');
@@ -328,6 +342,7 @@ async function startServer() {
clearInterval(playSessionCleanupInterval);
clearInterval(loginAttemptsCleanupInterval);
clearInterval(activityLogsCleanupInterval);
clearInterval(jobLogCleanupInterval);
try {
gameZipServer.dispose();
+4 -3
View File
@@ -82,7 +82,7 @@ export class ActivityService {
return { sql, params };
}
async log(data: LogActivityData): Promise<void> {
log(data: LogActivityData): void {
const detailsJson = data.details ? JSON.stringify(data.details) : null;
UserDatabaseService.run(
@@ -235,9 +235,10 @@ export class ActivityService {
// Format peak hour
const formatHourRange = (hour: number) => {
const start = hour % 12 === 0 ? 12 : hour % 12;
const end = (hour + 1) % 12 === 0 ? 12 : (hour + 1) % 12;
const nextHour = (hour + 1) % 24;
const end = nextHour % 12 === 0 ? 12 : nextHour % 12;
const startPeriod = hour < 12 ? 'AM' : 'PM';
const endPeriod = hour + 1 < 12 ? 'AM' : 'PM';
const endPeriod = nextHour < 12 ? 'AM' : 'PM';
return `${start}:00 ${startPeriod} - ${end}:00 ${endPeriod}`;
};
@@ -16,6 +16,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
private cache: Map<string, CacheEntry> = new Map();
private categoryCache: Map<string, CacheEntry> = new Map();
private publicSettingsCache: CacheEntry | null = null;
private cacheTTL: number;
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
@@ -100,6 +101,21 @@ export class CachedSystemSettingsService extends SystemSettingsService {
return value;
}
getPublicSettings(): Record<string, CategorySettings> {
if (this.publicSettingsCache && this.isValid(this.publicSettingsCache.timestamp)) {
return this.publicSettingsCache.value as Record<string, CategorySettings>;
}
const value = super.getPublicSettings();
this.publicSettingsCache = {
value,
timestamp: Date.now(),
};
return value;
}
set(key: string, value: SettingValue, updatedBy?: number): void {
super.set(key, value, updatedBy);
@@ -108,6 +124,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
// Key format is "category.setting_name"
const category = key.split('.')[0];
this.categoryCache.delete(category);
this.publicSettingsCache = null;
}
updateCategory(category: string, settings: CategorySettings, updatedBy?: number): void {
@@ -120,11 +137,13 @@ export class CachedSystemSettingsService extends SystemSettingsService {
}
this.categoryCache.delete(category);
this.publicSettingsCache = null;
}
clearCache(): void {
this.cache.clear();
this.categoryCache.clear();
this.publicSettingsCache = null;
}
clearCategoryCache(category: string): void {
@@ -135,6 +154,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
}
this.categoryCache.delete(category);
this.publicSettingsCache = null;
}
getCacheStats(): {
+18 -20
View File
@@ -125,40 +125,38 @@ export class DomainService {
}
deleteDomain(id: number): void {
const existing = this.db.prepare('SELECT hostname FROM domains WHERE id = ?').get(id) as
| { hostname: string }
| undefined;
const db = this.db;
const transaction = db.transaction(() => {
const current = db
.prepare('SELECT hostname, is_default FROM domains WHERE id = ?')
.get(id) as { hostname: string; is_default: number } | undefined;
if (!existing) {
throw new AppError(404, 'Domain not found');
}
if (!current) {
throw new AppError(404, 'Domain not found');
}
const transaction = this.db.transaction(() => {
// Read is_default inside the transaction to avoid race conditions
const current = this.db.prepare('SELECT is_default FROM domains WHERE id = ?').get(id) as
| { is_default: number }
| undefined;
this.db.prepare('DELETE FROM domains WHERE id = ?').run(id);
db.prepare('DELETE FROM domains WHERE id = ?').run(id);
// If the deleted domain was the default, promote the next one
if (current?.is_default === 1) {
const next = this.db
.prepare('SELECT id FROM domains ORDER BY created_at ASC LIMIT 1')
.get() as { id: number } | undefined;
if (current.is_default === 1) {
const next = db.prepare('SELECT id FROM domains ORDER BY created_at ASC LIMIT 1').get() as
| { id: number }
| undefined;
if (next) {
this.db.prepare('UPDATE domains SET is_default = 1 WHERE id = ?').run(next.id);
db.prepare('UPDATE domains SET is_default = 1 WHERE id = ?').run(next.id);
logger.info(
`[Domains] Auto-promoted domain (id: ${next.id}) as new default after deleting previous default`
);
}
}
return current.hostname;
});
transaction();
const hostname = transaction();
this.invalidateCache();
logger.info(`[Domains] Deleted domain "${existing.hostname}" (id: ${id})`);
logger.info(`[Domains] Deleted domain "${hostname}" (id: ${id})`);
}
setDefault(id: number): Domain {
+19 -42
View File
@@ -16,19 +16,6 @@ export interface GameData {
parameters: string | null;
}
interface GameDataRow {
id: number;
gameId: string;
title: string;
dateAdded: string;
sha256: string;
crc32: number;
presentOnDisk: number;
path: string | null;
size: number;
parameters: string | null;
}
/**
* NOTE: This service intentionally writes to flashpoint.sqlite to update
* presentOnDisk and path fields in game_data/game tables. This matches
@@ -44,7 +31,7 @@ export class GameDatabaseUpdater {
filePath,
});
const gameData = await this.getGameData(gameDataId);
const gameData = this.getGameData(gameDataId);
if (!gameData) {
throw new Error(`Game data not found with id: ${gameDataId}`);
}
@@ -110,7 +97,7 @@ export class GameDatabaseUpdater {
});
updateTransaction();
await this.saveDatabase();
this.saveDatabase();
logger.info('Database updated successfully', {
gameDataId,
@@ -118,7 +105,7 @@ export class GameDatabaseUpdater {
filePath,
});
const updated = await this.getGameData(gameDataId);
const updated = this.getGameData(gameDataId);
if (!updated) {
throw new Error('Failed to fetch updated game data');
}
@@ -137,7 +124,7 @@ export class GameDatabaseUpdater {
}
}
static async getGameData(gameDataId: number): Promise<GameData | null> {
static getGameData(gameDataId: number): GameData | null {
try {
const sql = `
SELECT
@@ -155,7 +142,7 @@ export class GameDatabaseUpdater {
WHERE id = ?
`;
const row = DatabaseService.get(sql, [gameDataId]);
const row = DatabaseService.get<GameData>(sql, [gameDataId]);
return row ? this.mapRowToGameData(row) : null;
} catch (error) {
logger.error('Failed to fetch game data', { gameDataId, error });
@@ -163,7 +150,7 @@ export class GameDatabaseUpdater {
}
}
private static async saveDatabase(): Promise<void> {
private static saveDatabase(): void {
try {
// better-sqlite3 writes automatically; just ensure changes are flushed
DatabaseService.save();
@@ -179,22 +166,11 @@ export class GameDatabaseUpdater {
}
}
private static mapRowToGameData(row: GameDataRow): GameData {
return {
id: row.id,
gameId: row.gameId,
title: row.title,
dateAdded: row.dateAdded,
sha256: row.sha256,
crc32: row.crc32,
presentOnDisk: row.presentOnDisk,
path: row.path,
size: row.size,
parameters: row.parameters,
};
private static mapRowToGameData(row: GameData): GameData {
return row;
}
static async isDownloaded(gameDataId: number): Promise<boolean> {
static isDownloaded(gameDataId: number): boolean {
try {
const sql = `
SELECT presentOnDisk
@@ -202,7 +178,7 @@ export class GameDatabaseUpdater {
WHERE id = ?
`;
const row = DatabaseService.get(sql, [gameDataId]);
const row = DatabaseService.get<{ presentOnDisk: number }>(sql, [gameDataId]);
return row ? row.presentOnDisk === 1 : false;
} catch (error) {
logger.error('Failed to check if game data is downloaded', { gameDataId, error });
@@ -210,7 +186,7 @@ export class GameDatabaseUpdater {
}
}
static async markAsNotDownloaded(gameDataId: number): Promise<void> {
static markAsNotDownloaded(gameDataId: number): void {
try {
const db = DatabaseService.getDatabase();
@@ -226,7 +202,7 @@ export class GameDatabaseUpdater {
updateTransaction();
await this.saveDatabase();
this.saveDatabase();
logger.info('Marked game data as not downloaded', { gameDataId });
} catch (error) {
@@ -236,11 +212,12 @@ export class GameDatabaseUpdater {
}
private static makeRelativePath(absolutePath: string): string {
const relativePath = path.relative(config.flashpointPath, absolutePath);
// Flashpoint uses forward slashes for cross-platform compatibility
const normalizedPath = relativePath.replace(/\\/g, '/');
return normalizedPath;
const resolved = path.resolve(absolutePath);
const base = path.resolve(config.flashpointPath);
if (!resolved.startsWith(base + path.sep) && resolved !== base) {
throw new Error(`Path escapes Flashpoint directory: ${absolutePath}`);
}
const relativePath = path.relative(base, resolved);
return relativePath.replace(/\\/g, '/');
}
}
+4 -4
View File
@@ -142,10 +142,10 @@ export class JobExecutionService {
jobName: row.job_name,
status: row.status as 'running' | 'success' | 'failed',
startedAt: row.started_at,
completedAt: row.completed_at || undefined,
durationSeconds: row.duration_seconds || undefined,
message: row.message || undefined,
errorDetails: row.error_details || undefined,
completedAt: row.completed_at ?? undefined,
durationSeconds: row.duration_seconds ?? undefined,
message: row.message ?? undefined,
errorDetails: row.error_details ?? undefined,
triggeredBy: row.triggered_by,
};
}
+3 -21
View File
@@ -26,7 +26,6 @@ class JobSchedulerService {
private cronTasks: Map<string, cron.ScheduledTask> = new Map();
private executionService = new JobExecutionService();
private currentExecutions: Map<string, number> = new Map(); // jobId -> logId
private lastRunTimes: Map<string, number> = new Map(); // jobId -> timestamp
registerJob(job: Job): void {
this.jobs.set(job.id, job);
@@ -78,7 +77,6 @@ class JobSchedulerService {
// Start execution log
const logId = this.executionService.startExecution(jobId, job.name, triggeredBy);
this.currentExecutions.set(jobId, logId);
this.lastRunTimes.set(jobId, Date.now());
try {
logger.info(`[JobScheduler] Running job: ${job.name}`);
@@ -138,8 +136,9 @@ class JobSchedulerService {
return;
}
// Update job configuration
Object.assign(job, updates);
// Update job configuration (only allowed fields)
if (updates.enabled !== undefined) job.enabled = updates.enabled;
if (updates.cronSchedule !== undefined) job.cronSchedule = updates.cronSchedule;
this.jobs.set(jobId, job);
// Restart job if enabled
@@ -163,23 +162,6 @@ class JobSchedulerService {
logger.info(`Job ${job.name} enabled state updated to: ${enabled}`);
}
getJobStatus(jobId: string): { enabled: boolean; running: boolean; cronSchedule: string } | null {
const job = this.jobs.get(jobId);
if (!job) {
return null;
}
return {
enabled: job.enabled,
running: this.cronTasks.has(jobId),
cronSchedule: job.cronSchedule,
};
}
getAllJobs(): Map<string, Job> {
return new Map(this.jobs);
}
getJobStatusEnriched(jobId: string): JobStatusEnriched | null {
const job = this.jobs.get(jobId);
if (!job) return null;
+63 -77
View File
@@ -94,6 +94,7 @@ interface FlashpointPreferences {
* See: https://github.com/FlashpointProject/launcher/blob/master/src/back/sync.ts
*/
export class MetadataSyncService {
private static readonly ALLOWED_HOSTS = ['fpfss.unstable.life', 'fpfss.flashpointarchive.org'];
private preferencesPath: string;
private syncStatusService: SyncStatusService;
@@ -106,7 +107,21 @@ export class MetadataSyncService {
return config.flashpointEdition;
}
async syncMetadata(): Promise<SyncResult> {
private validateBaseUrl(baseUrl: string): void {
try {
const url = new URL(baseUrl);
if (!MetadataSyncService.ALLOWED_HOSTS.includes(url.hostname)) {
throw new Error(`Untrusted metadata source host: ${url.hostname}`);
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Invalid metadata source URL: ${baseUrl}`);
}
throw e;
}
}
async syncMetadata(alreadyStarted = false): Promise<SyncResult> {
// Ultimate edition does not support metadata sync
if (this.getEdition() === 'ultimate') {
logger.warn('[MetadataSync] Metadata sync is not available for Ultimate edition');
@@ -130,8 +145,10 @@ export class MetadataSyncService {
let latestPlatformDate: string | null = null;
try {
// Mark sync as started
this.syncStatusService.startSync();
// Mark sync as started (skip if already started atomically)
if (!alreadyStarted) {
this.syncStatusService.startSync();
}
// Read preferences file
this.syncStatusService.updateProgress('reading-config', 5, 'Reading preferences...');
@@ -144,6 +161,8 @@ export class MetadataSyncService {
}
const source = sources[0];
// Validate baseUrl before making any HTTP requests
this.validateBaseUrl(source.baseUrl);
logger.info(`[MetadataSync] Starting metadata sync from: ${source.baseUrl}`);
// Sync platforms first (data embedded in game records)
@@ -396,7 +415,7 @@ export class MetadataSyncService {
}
// Delete games from database
await this.deleteGames(deletedIds);
this.deleteGames(deletedIds);
logger.info(`[MetadataSync] Deleted ${deletedIds.length} games`);
return deletedIds.length;
@@ -494,34 +513,34 @@ export class MetadataSyncService {
chunk.forEach((game) => {
values.push(
game.id,
game.parent_game_id || null,
game.title || '',
game.alternate_titles || '',
game.series || '',
game.developer || '',
game.publisher || '',
game.platform || game.platform_name || '',
game.play_mode || '',
game.status || '',
game.parent_game_id ?? null,
game.title ?? '',
game.alternate_titles ?? '',
game.series ?? '',
game.developer ?? '',
game.publisher ?? '',
game.platform ?? game.platform_name ?? '',
game.play_mode ?? '',
game.status ?? '',
game.broken ? 1 : 0,
game.extreme ? 1 : 0,
game.notes || '',
game.source || '',
game.application_path || '',
game.launch_command || '',
game.release_date || '',
game.version || '',
game.original_description || '',
game.language || '',
game.order_title || game.title || '',
game.library || 'arcade',
game.tags_str || '',
game.date_added || new Date().toISOString(),
game.date_modified || new Date().toISOString(),
game.active_data_id || null,
game.notes ?? '',
game.source ?? '',
game.application_path ?? '',
game.launch_command ?? '',
game.release_date ?? '',
game.version ?? '',
game.original_description ?? '',
game.language ?? '',
game.order_title ?? game.title ?? '',
game.library ?? 'arcade',
game.tags_str ?? '',
game.date_added ?? new Date().toISOString(),
game.date_modified ?? new Date().toISOString(),
game.active_data_id ?? null,
game.active_data_on_disk ? 1 : 0,
game.archive_state || 0,
game.ruffle_support || ''
game.archive_state ?? 0,
game.ruffle_support ?? ''
);
});
@@ -624,57 +643,24 @@ export class MetadataSyncService {
* Delete games from database
* Handles parent-child relationships to avoid FOREIGN KEY constraint errors
*/
private async deleteGames(gameIds: string[]): Promise<void> {
// Safety check: ensure gameIds is an array
if (!Array.isArray(gameIds)) {
logger.error('[MetadataSync] deleteGames called with non-array:', typeof gameIds);
throw new Error('deleteGames requires an array of game IDs');
}
if (gameIds.length === 0) return;
private deleteGames(gameIds: string[]): void {
if (!Array.isArray(gameIds) || gameIds.length === 0) return;
const db = DatabaseService.getDatabase();
const placeholders = gameIds.map(() => '?').join(', ');
// Check current FK setting
const fkEnabled = db.pragma('foreign_keys', { simple: true });
// Process in batches to avoid SQL variable limits
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(', ');
try {
// Temporarily disable foreign key constraints (must be outside transaction)
db.pragma('foreign_keys = OFF');
// Use a transaction for atomicity
const transaction = db.transaction(() => {
// Find child games that reference parents being deleted
const childGamesQuery = `SELECT id FROM game WHERE parentGameId IN (${placeholders})`;
const stmt = db.prepare(childGamesQuery);
const childGames = stmt.all(gameIds) as Array<{ id: string }>;
const childGameIds = childGames.map((row) => row.id);
// Update child games to set parentGameId to NULL (orphan them instead of cascade delete)
// This preserves the games but removes the broken reference
if (childGameIds.length > 0) {
const updatePlaceholders = childGameIds.map(() => '?').join(', ');
const updateSql = `UPDATE game SET parentGameId = NULL WHERE id IN (${updatePlaceholders})`;
const updateStmt = db.prepare(updateSql);
updateStmt.run(childGameIds);
logger.debug(
`[MetadataSync] Orphaned ${childGameIds.length} child games (set parentGameId to NULL)`
);
}
// Now safe to delete the games
const deleteSql = `DELETE FROM game WHERE id IN (${placeholders})`;
const deleteStmt = db.prepare(deleteSql);
deleteStmt.run(gameIds);
logger.debug(`[MetadataSync] Deleted ${gameIds.length} games`);
});
// Execute the transaction
transaction();
} finally {
// Restore original FK setting
db.pragma(`foreign_keys = ${fkEnabled ? 'ON' : 'OFF'}`);
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);
})();
}
}
@@ -93,8 +93,8 @@ export class MetadataUpdateService {
const tagsUpdateAvailable = tagsUpdateCount > 0;
const hasUpdates = gamesUpdateAvailable || tagsUpdateAvailable;
logger.info(`[MetadataUpdate] Games: ${gamesUpdateCount} updates available`);
logger.info(
logger.debug(`[MetadataUpdate] Games: ${gamesUpdateCount} updates available`);
logger.debug(
`[MetadataUpdate] Tags: ${tagsUpdateCount} updates available (synced via game records)`
);
@@ -175,7 +175,7 @@ export class MetadataUpdateService {
const source = sources[0];
const fpfssUrl = `${source.baseUrl}/api/info`;
logger.info(`[MetadataUpdate] Checking remote updates from: ${fpfssUrl}`);
logger.debug(`[MetadataUpdate] Checking remote updates from: ${fpfssUrl}`);
// Query FPFSS API for latest metadata info
const response = await axios.get(fpfssUrl, {
+5 -3
View File
@@ -49,8 +49,10 @@ export class RuffleService {
if (!fs.existsSync(packageJsonPath)) {
return null;
}
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version || null;
const packageJson: { version?: string } = JSON.parse(
fs.readFileSync(packageJsonPath, 'utf-8')
);
return packageJson.version ?? null;
} catch (error) {
logger.error('Error reading Ruffle version:', error);
return null;
@@ -111,7 +113,7 @@ export class RuffleService {
const normalizedCurrent = currentVersion ? this.normalizeVersion(currentVersion) : null;
const normalizedLatest = this.normalizeVersion(latest.version);
const updateAvailable = normalizedCurrent !== null && normalizedCurrent !== normalizedLatest;
const updateAvailable = normalizedCurrent === null || normalizedCurrent !== normalizedLatest;
return {
currentVersion,
+12
View File
@@ -66,6 +66,18 @@ export class SyncStatusService {
logger.info('[SyncStatus] Sync started');
}
tryStartSync(): boolean {
if (this.status.isRunning) return false;
this.status = {
isRunning: true,
stage: 'initializing',
progress: 0,
message: 'Starting metadata sync...',
startTime: new Date().toISOString(),
};
return true;
}
updateProgress(stage: string, progress: number, message: string): void {
this.status.stage = stage;
this.status.progress = Math.min(100, Math.max(0, progress));
+10 -2
View File
@@ -64,6 +64,14 @@ export class SystemSettingsService {
}
set(key: string, value: SettingValue, updatedBy?: number): void {
const existing = UserDatabaseService.get<{ key: string }>(
'SELECT key FROM system_settings WHERE key = ?',
[key]
);
if (!existing) {
throw new Error(`Setting '${key}' does not exist`);
}
// Validate before setting
this.validate(key, value);
@@ -73,7 +81,7 @@ export class SystemSettingsService {
`UPDATE system_settings
SET value = ?, updated_by = ?, updated_at = ?
WHERE key = ?`,
[stringValue, updatedBy || null, new Date().toISOString(), key]
[stringValue, updatedBy ?? null, new Date().toISOString(), key]
);
}
@@ -126,7 +134,7 @@ export class SystemSettingsService {
`UPDATE system_settings
SET value = default_value, updated_by = ?, updated_at = ?
WHERE key = ?`,
[updatedBy || null, new Date().toISOString(), key]
[updatedBy ?? null, new Date().toISOString(), key]
);
}
+7 -10
View File
@@ -33,8 +33,8 @@ export class UpdateService {
}
async checkForUpdates(): Promise<UpdateInfo> {
logger.info('[UpdateService] Checking for updates...');
logger.info(`[UpdateService] Flashpoint root: ${this.flashpointRoot}`);
logger.debug('[UpdateService] Checking for updates...');
logger.debug(`[UpdateService] Flashpoint root: ${this.flashpointRoot}`);
const componentsStatus: ComponentStatus[] = [];
@@ -50,7 +50,7 @@ export class UpdateService {
// Read components directory to check installed versions
const componentsDir = path.join(this.flashpointRoot, 'Components');
logger.info(`[UpdateService] Components directory: ${componentsDir}`);
logger.debug(`[UpdateService] Components directory: ${componentsDir}`);
for (const component of coreComponents) {
try {
@@ -73,7 +73,7 @@ export class UpdateService {
const hash = parts[0] || 'unknown';
const currentVersion = hash.substring(0, 8); // Use first 8 chars of hash as version
logger.info(`[UpdateService] Component ${component.id}: ${currentVersion}`);
logger.debug(`[UpdateService] Component ${component.id}: ${currentVersion}`);
componentsStatus.push({
id: component.id,
@@ -103,7 +103,7 @@ export class UpdateService {
try {
await axios.get(this.componentsUrl, { timeout: 5000 });
// Parse XML and compare versions (simplified for now)
logger.info('[UpdateService] Successfully fetched remote component list');
logger.debug('[UpdateService] Successfully fetched remote component list');
} catch (error) {
logger.warn('[UpdateService] Could not fetch remote component list:', error);
}
@@ -197,9 +197,10 @@ export class UpdateService {
});
} else {
logger.error('[UpdateService] Update failed with code:', code);
logger.error('[UpdateService] FPM stderr:', errorOutput);
resolve({
success: false,
message: `Update failed: ${errorOutput || 'Unknown error'}`,
message: 'Update failed. Check server logs for details.',
});
}
});
@@ -220,8 +221,6 @@ export class UpdateService {
}
async getSystemInfo(): Promise<{
flashpointRoot: string;
managerPath: string;
managerExists: boolean;
}> {
const managerExists = await fs
@@ -230,8 +229,6 @@ export class UpdateService {
.catch(() => false);
return {
flashpointRoot: this.flashpointRoot,
managerPath: this.managerPath,
managerExists,
};
}
@@ -15,25 +15,33 @@ import type { CustomTooltipProps } from '@/types/chart';
function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
if (active && payload && payload.length) {
const colorMap: Record<string, string> = {
total: 'bg-blue-500',
authEvents: 'bg-amber-500',
failedActions: 'bg-red-500',
};
const labelMap: Record<string, string> = {
total: 'Total',
authEvents: 'Auth Events',
failedActions: 'Failed Actions',
};
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
<span className="text-muted-foreground text-sm">Total:</span>
<span className="font-semibold text-sm">{payload[0].value}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500"></div>
<span className="text-muted-foreground text-sm">Auth Events:</span>
<span className="font-semibold text-sm">{payload[1].value}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500"></div>
<span className="text-muted-foreground text-sm">Failed Actions:</span>
<span className="font-semibold text-sm">{payload[2].value}</span>
</div>
{payload.map((entry, index) => (
<div key={`tooltip-${index}`} className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${colorMap[entry.dataKey as string] ?? 'bg-gray-500'}`}
></div>
<span className="text-muted-foreground text-sm">
{labelMap[entry.dataKey as string] ?? entry.dataKey}:
</span>
<span className="font-semibold text-sm">{entry.value}</span>
</div>
))}
</div>
</div>
);
+1 -1
View File
@@ -33,7 +33,7 @@ export function JobCard({
const { status } = job.lastExecution;
if (status === 'success') {
return (
<Badge variant="default" className="bg-green-600 gap-1">
<Badge variant="default" className="bg-green-600 dark:bg-green-600 gap-1">
<CheckCircle size={12} /> Success
</Badge>
);
@@ -15,7 +15,7 @@ import { Info, Settings, AlertCircle } from 'lucide-react';
import { systemSettingsApi } from '@/lib/api';
import { useDialog } from '@/contexts/DialogContext';
import { cronToReadable, isValidCron } from '@/lib/cron-utils';
import { AxiosError } from 'axios';
import { getErrorMessage } from '@/types/api-error';
interface JobEditDialogProps {
job: JobStatusEnriched | null;
@@ -46,11 +46,11 @@ export function JobEditDialog({ job, open, onOpenChange }: JobEditDialogProps) {
};
useEffect(() => {
if (job) {
if (open && job) {
setCronSchedule(job.cronSchedule);
setValidationError(null);
}
}, [job]);
}, [open, job?.id]);
const handleCronChange = (value: string) => {
setCronSchedule(value);
@@ -67,8 +67,7 @@ export function JobEditDialog({ job, open, onOpenChange }: JobEditDialogProps) {
onOpenChange(false);
},
onError: (error: unknown) => {
const axiosError = error instanceof AxiosError ? error : null;
const message = axiosError?.response?.data?.error?.message || 'Failed to update job settings';
const message = getErrorMessage(error) || 'Failed to update job settings';
showToast(message, 'error');
},
});
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { JobExecutionLog } from '@/types/jobs';
import { Badge } from '@/components/ui/badge';
import { formatDistanceToNow } from 'date-fns';
import { formatDuration } from '@/lib/cron-utils';
import { CheckCircle, XCircle, Loader2, ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
interface JobExecutionLogTableProps {
logs: JobExecutionLog[];
@@ -29,7 +29,7 @@ export function JobExecutionLogTable({ logs, loading }: JobExecutionLogTableProp
switch (status) {
case 'success':
return (
<Badge variant="default" className="bg-green-600 gap-1">
<Badge variant="default" className="bg-green-600 dark:bg-green-600 gap-1">
<CheckCircle size={12} /> Success
</Badge>
);
@@ -73,8 +73,8 @@ export function JobExecutionLogTable({ logs, loading }: JobExecutionLogTableProp
</thead>
<tbody>
{logs.map((log) => (
<>
<tr key={log.id} className="border-t hover:bg-accent/50">
<React.Fragment key={log.id}>
<tr className="border-t hover:bg-accent/50">
<td className="p-3">
{log.errorDetails ? (
<button
@@ -98,16 +98,16 @@ export function JobExecutionLogTable({ logs, loading }: JobExecutionLogTableProp
<td className="p-3 text-sm text-muted-foreground">{log.message || '-'}</td>
</tr>
{expandedRows.has(log.id) && log.errorDetails ? (
<tr className="border-t bg-red-950/20">
<tr className="border-t bg-destructive/10">
<td colSpan={6} className="p-3">
<div className="text-xs font-mono bg-red-950/50 p-3 rounded">
<p className="font-semibold text-red-400 mb-2">Error Details:</p>
<pre className="whitespace-pre-wrap text-red-300">{log.errorDetails}</pre>
<div className="text-xs font-mono bg-destructive/20 p-3 rounded">
<p className="font-semibold text-destructive mb-2">Error Details:</p>
<pre className="whitespace-pre-wrap text-destructive">{log.errorDetails}</pre>
</div>
</td>
</tr>
) : null}
</>
</React.Fragment>
))}
</tbody>
</table>
@@ -193,7 +193,7 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) {
Default theme mode for new users (light, dark, or system).
</p>
<Select
value={appSettings.defaultTheme || 'dark'}
value={appSettings.defaultTheme ?? 'dark'}
onValueChange={(value: string) => {
updateSystemSettings.mutate({
category: 'app',
@@ -220,7 +220,7 @@ export function AppSettingsTab({ tabContentVariants }: AppSettingsTabProps) {
</Label>
<p className="text-sm text-muted-foreground">Default primary color for new users.</p>
<Select
value={appSettings.defaultPrimaryColor || 'blue'}
value={appSettings.defaultPrimaryColor ?? 'blue'}
onValueChange={(value: string) => {
updateSystemSettings.mutate({
category: 'app',
@@ -7,7 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { systemSettingsApi } from '@/lib/api';
import { useAuthStore } from '@/store/auth';
import { useDialog } from '@/contexts/DialogContext';
import { AxiosError } from 'axios';
import { getErrorMessage } from '@/types/api-error';
interface FeaturesSettingsTabProps {
tabContentVariants: Variants;
@@ -30,12 +30,12 @@ export function FeaturesSettingsTab({ tabContentVariants }: FeaturesSettingsTabP
systemSettingsApi.updateCategory(category, settings),
onSuccess: (updatedSettings, variables) => {
queryClient.setQueryData(['systemSettings', variables.category], updatedSettings);
queryClient.invalidateQueries({ queryKey: ['system-settings', 'public'] });
showToast('Settings updated successfully', 'success');
},
onError: (error: unknown) => {
const axiosError = error instanceof AxiosError ? error : null;
const message = axiosError?.response?.data?.error?.message || 'Failed to update settings';
const message = getErrorMessage(error) || 'Failed to update settings';
showToast(message, 'error');
},
});
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/auth';
import { useDialog } from '@/contexts/DialogContext';
import { usePublicSettings } from '@/hooks/usePublicSettings';
import { UpdateAuthSettingsData } from '@/types/auth';
import { AxiosError } from 'axios';
import { getErrorMessage } from '@/types/api-error';
import {
Select,
SelectContent,
@@ -56,8 +56,7 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
showToast('Settings updated successfully', 'success');
},
onError: (error: unknown) => {
const axiosError = error instanceof AxiosError ? error : null;
const message = axiosError?.response?.data?.error?.message || 'Failed to update settings';
const message = getErrorMessage(error) || 'Failed to update settings';
showToast(message, 'error');
},
});
@@ -70,8 +69,7 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
showToast('Settings updated successfully', 'success');
},
onError: (error: unknown) => {
const axiosError = error instanceof AxiosError ? error : null;
const message = axiosError?.response?.data?.error?.message || 'Failed to update settings';
const message = getErrorMessage(error) || 'Failed to update settings';
showToast(message, 'error');
},
});
@@ -107,7 +105,7 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Web App Version:</span>
<span className="font-medium">1.0.0</span>
<span className="font-medium">{import.meta.env.VITE_APP_VERSION ?? '1.0.0'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Ruffle Emulator:</span>
@@ -136,7 +134,7 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
Choose how dates are displayed throughout the application
</p>
<Select
value={userSettings.date_format || 'MM/dd/yyyy'}
value={userSettings.date_format ?? 'MM/dd/yyyy'}
onValueChange={(value: string) => {
updateUserSettings.mutate({ date_format: value });
}}
@@ -165,7 +163,7 @@ export function GeneralSettingsTab({ tabContentVariants }: GeneralSettingsTabPro
Choose how times are displayed throughout the application
</p>
<Select
value={userSettings.time_format || 'hh:mm a'}
value={userSettings.time_format ?? 'hh:mm a'}
onValueChange={(value: string) => {
updateUserSettings.mutate({ time_format: value });
}}
@@ -17,18 +17,18 @@ export function MetadataUpdateCard() {
const [syncMessage, setSyncMessage] = useState('');
const [metadataInfo, setMetadataInfo] = useState<MetadataUpdateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false);
// Use ref to track poll interval (avoids stale closure issues)
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isFetchingMetadata = useRef(false);
const fetchMetadataInfo = async () => {
if (isFetchingMetadata.current) {
if (isFetchingMetadata) {
return;
}
isFetchingMetadata.current = true;
setIsFetchingMetadata(true);
try {
const data = await updatesApi.getMetadataInfo();
@@ -42,7 +42,7 @@ export function MetadataUpdateCard() {
});
setError('Failed to check metadata updates. Please try again later.');
} finally {
isFetchingMetadata.current = false;
setIsFetchingMetadata(false);
}
};
@@ -51,7 +51,7 @@ export function MetadataUpdateCard() {
});
const checkMetadataUpdates = async () => {
isFetchingMetadata.current = false;
setIsFetchingMetadata(false);
await fetchMetadataInfo();
};
@@ -192,15 +192,15 @@ export function MetadataUpdateCard() {
<TooltipTrigger asChild>
<Button
onClick={checkMetadataUpdates}
disabled={isFetchingMetadata.current}
disabled={isFetchingMetadata}
size="icon"
variant="outline"
>
<RefreshCw size={18} className={isFetchingMetadata.current ? 'animate-spin' : ''} />
<RefreshCw size={18} className={isFetchingMetadata ? 'animate-spin' : ''} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isFetchingMetadata.current ? 'Checking...' : 'Check for Updates'}</p>
<p>{isFetchingMetadata ? 'Checking...' : 'Check for Updates'}</p>
</TooltipContent>
</Tooltip>
) : null}
+3 -2
View File
@@ -84,6 +84,7 @@ export function JobsView() {
};
const handleViewLogs = (jobId: string) => {
queryClient.invalidateQueries({ queryKey: ['jobLogs', jobId] });
setSelectedJobId(jobId);
setShowLogsDialog(true);
};
@@ -116,8 +117,8 @@ export function JobsView() {
) : null}
{error ? (
<div className="bg-red-950/20 border border-red-500 rounded-lg p-4">
<p className="text-red-400">Failed to load jobs. Please try again.</p>
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
<p className="text-destructive">Failed to load jobs. Please try again.</p>
</div>
) : null}
+2 -2
View File
@@ -49,8 +49,8 @@ export function SettingsView() {
const { data: publicSettings } = usePublicSettings();
const flashpointVersion = publicSettings?.metadata?.flashpointVersion || 'Unknown';
const webAppVersion = import.meta.env.VITE_APP_VERSION || '1.0.0';
const flashpointVersion = publicSettings?.metadata?.flashpointVersion ?? 'Unknown';
const webAppVersion = import.meta.env.VITE_APP_VERSION ?? '1.0.0';
return (
<div className="max-w-5xl mx-auto space-y-6">