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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}` },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(): {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user