feat(metadata): allow admins to set a metadata source
The Ultimate edition ships preferences.json without gameMetadataSources, so metadata sync was permanently unavailable there with no way to opt in. Add an admin-only override (metadata.custom_source_enabled / _url) resolved ahead of preferences, restricted to the two trusted FPFSS hosts and validated on save. While it is off, the Metadata Update card explains that Ultimate does not support metadata updates and that updating means a newer full package, pointing advanced admins at the override. Enabling it requires confirming a dialog that spells out what a sync rewrites.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
-- ===================================
|
||||
-- Migration 005: Admin metadata source override
|
||||
-- ===================================
|
||||
-- The Ultimate edition ships preferences.json without gameMetadataSources, so
|
||||
-- metadata sync is unavailable there. These settings let an admin opt in to a
|
||||
-- source explicitly. Not public: only admins read or change them.
|
||||
|
||||
INSERT OR IGNORE INTO system_settings (key, value, data_type, category, description, is_public, default_value, validation_schema)
|
||||
VALUES
|
||||
('metadata.custom_source_enabled', '0', 'boolean', 'metadata', 'Use an admin-configured metadata source instead of Flashpoint preferences', 0, '0', '{"type":"boolean"}'),
|
||||
('metadata.custom_source_url', '', 'string', 'metadata', 'Metadata source base URL used when the override is enabled', 0, '', '{"type":"string","maxLength":200}');
|
||||
@@ -11,6 +11,7 @@ import { logActivity } from '../middleware/activityLogger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { JobScheduler } from '../services/JobScheduler';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ALLOWED_METADATA_HOSTS, isAllowedMetadataSourceUrl } from '../utils/metadataSources';
|
||||
import { CategorySettings } from '../types/settings';
|
||||
|
||||
const router = Router();
|
||||
@@ -273,6 +274,21 @@ router.patch(
|
||||
}
|
||||
|
||||
const settings = validation.data;
|
||||
|
||||
// The metadata source drives writes to flashpoint.sqlite, so an admin may only
|
||||
// point it at a host the project trusts.
|
||||
if (category === 'metadata' && typeof settings.customSourceUrl === 'string') {
|
||||
const url = settings.customSourceUrl.trim();
|
||||
if (url !== '' && !isAllowedMetadataSourceUrl(url)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: `Metadata source must be one of: ${ALLOWED_METADATA_HOSTS.join(', ')}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
settings.customSourceUrl = url;
|
||||
}
|
||||
|
||||
const userId = req.user!.id;
|
||||
|
||||
systemSettings.updateCategory(category, settings, userId);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const getCategory = vi.fn();
|
||||
|
||||
vi.mock('./CachedSystemSettingsService', () => ({
|
||||
CachedSystemSettingsService: {
|
||||
getInstance: () => ({ getCategory }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./PreferencesService', () => ({
|
||||
PreferencesService: {
|
||||
getGameMetadataSources: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { PreferencesService } from './PreferencesService';
|
||||
import { MetadataSourceService } from './MetadataSourceService';
|
||||
|
||||
const PREFERENCES_SOURCE = {
|
||||
name: 'Flashpoint Archive',
|
||||
baseUrl: 'https://fpfss.flashpointarchive.org',
|
||||
games: { actualUpdateTime: '', latestDeleteTime: '', latestUpdateTime: '' },
|
||||
tags: { actualUpdateTime: '', latestDeleteTime: '', latestUpdateTime: '' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCategory.mockReturnValue({});
|
||||
vi.mocked(PreferencesService.getGameMetadataSources).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('MetadataSourceService', () => {
|
||||
it('reports no source when preferences define none and the override is off', async () => {
|
||||
expect(await MetadataSourceService.hasSource()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to preferences when the override is off', async () => {
|
||||
vi.mocked(PreferencesService.getGameMetadataSources).mockResolvedValue([PREFERENCES_SOURCE]);
|
||||
|
||||
const sources = await MetadataSourceService.getEffectiveSources();
|
||||
|
||||
expect(sources).toEqual([PREFERENCES_SOURCE]);
|
||||
expect(MetadataSourceService.isUsingOverride()).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the override when enabled, so an edition without preferences can sync', async () => {
|
||||
getCategory.mockReturnValue({
|
||||
customSourceEnabled: true,
|
||||
customSourceUrl: 'https://fpfss.flashpointarchive.org',
|
||||
});
|
||||
|
||||
const sources = await MetadataSourceService.getEffectiveSources();
|
||||
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0].baseUrl).toBe('https://fpfss.flashpointarchive.org');
|
||||
expect(MetadataSourceService.isUsingOverride()).toBe(true);
|
||||
expect(await MetadataSourceService.hasSource()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an override pointing at an untrusted host', async () => {
|
||||
getCategory.mockReturnValue({
|
||||
customSourceEnabled: true,
|
||||
customSourceUrl: 'https://evil.example/fpfss',
|
||||
});
|
||||
vi.mocked(PreferencesService.getGameMetadataSources).mockResolvedValue([PREFERENCES_SOURCE]);
|
||||
|
||||
const sources = await MetadataSourceService.getEffectiveSources();
|
||||
|
||||
expect(sources).toEqual([PREFERENCES_SOURCE]);
|
||||
expect(MetadataSourceService.isUsingOverride()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores an override that is enabled with no URL', async () => {
|
||||
getCategory.mockReturnValue({ customSourceEnabled: true, customSourceUrl: '' });
|
||||
|
||||
expect(await MetadataSourceService.getEffectiveSources()).toEqual([]);
|
||||
expect(MetadataSourceService.isUsingOverride()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a URL that is set while the override is disabled', async () => {
|
||||
getCategory.mockReturnValue({
|
||||
customSourceEnabled: false,
|
||||
customSourceUrl: 'https://fpfss.flashpointarchive.org',
|
||||
});
|
||||
|
||||
expect(await MetadataSourceService.getEffectiveSources()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CachedSystemSettingsService } from './CachedSystemSettingsService';
|
||||
import { PreferencesService, GameMetadataSource } from './PreferencesService';
|
||||
import { isAllowedMetadataSourceUrl } from '../utils/metadataSources';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const EPOCH = '1970-01-01';
|
||||
|
||||
export interface MetadataSourceOverride {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which metadata source to use.
|
||||
*
|
||||
* The Flashpoint Ultimate build ships preferences.json without
|
||||
* `gameMetadataSources`, so reading preferences alone leaves that edition with no
|
||||
* way to update metadata. An admin can opt in to a source explicitly; when they
|
||||
* have not, preferences remain the only source and the feature stays unavailable.
|
||||
*/
|
||||
export class MetadataSourceService {
|
||||
static getOverride(): MetadataSourceOverride {
|
||||
try {
|
||||
const settings = CachedSystemSettingsService.getInstance().getCategory('metadata');
|
||||
const url = typeof settings.customSourceUrl === 'string' ? settings.customSourceUrl : '';
|
||||
return { enabled: settings.customSourceEnabled === true, url };
|
||||
} catch (error: unknown) {
|
||||
logger.warn('[MetadataSource] Could not read override settings:', error);
|
||||
return { enabled: false, url: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources to use, override first. An override with an untrusted or malformed
|
||||
* URL is ignored rather than trusted, so a bad value cannot redirect a sync.
|
||||
*/
|
||||
static async getEffectiveSources(): Promise<GameMetadataSource[]> {
|
||||
const override = this.getOverride();
|
||||
|
||||
if (override.enabled && override.url) {
|
||||
if (!isAllowedMetadataSourceUrl(override.url)) {
|
||||
logger.warn(`[MetadataSource] Ignoring override with untrusted URL: ${override.url}`);
|
||||
return PreferencesService.getGameMetadataSources();
|
||||
}
|
||||
|
||||
const timestamps = {
|
||||
actualUpdateTime: EPOCH,
|
||||
latestDeleteTime: EPOCH,
|
||||
latestUpdateTime: EPOCH,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'Admin configured',
|
||||
baseUrl: override.url,
|
||||
games: { ...timestamps },
|
||||
tags: { ...timestamps },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return PreferencesService.getGameMetadataSources();
|
||||
}
|
||||
|
||||
static async hasSource(): Promise<boolean> {
|
||||
const sources = await this.getEffectiveSources();
|
||||
return sources.length > 0 && !!sources[0]?.baseUrl;
|
||||
}
|
||||
|
||||
/** True when the active source came from the admin override, not preferences. */
|
||||
static isUsingOverride(): boolean {
|
||||
const override = this.getOverride();
|
||||
return override.enabled && !!override.url && isAllowedMetadataSourceUrl(override.url);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { MetadataSourceService } from './MetadataSourceService';
|
||||
import { ALLOWED_METADATA_HOSTS } from '../utils/metadataSources';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import {
|
||||
PreferencesService,
|
||||
@@ -88,7 +90,7 @@ interface GameColumnMapping {
|
||||
* 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 static readonly ALLOWED_HOSTS = ALLOWED_METADATA_HOSTS;
|
||||
|
||||
/** All possible game columns with their value extractors */
|
||||
private static readonly GAME_COLUMN_MAPPINGS: GameColumnMapping[] = [
|
||||
@@ -115,7 +117,11 @@ export class MetadataSyncService {
|
||||
{ dbCol: 'orderTitle', getValue: (g) => g.order_title ?? g.title ?? '' },
|
||||
{ dbCol: 'library', getValue: (g) => g.library ?? 'arcade' },
|
||||
{ dbCol: 'tagsStr', getValue: (g) => g.tags_str ?? '' },
|
||||
{ dbCol: 'dateAdded', getValue: (g) => g.date_added ?? new Date().toISOString(), excludeFromUpdate: true },
|
||||
{
|
||||
dbCol: 'dateAdded',
|
||||
getValue: (g) => g.date_added ?? new Date().toISOString(),
|
||||
excludeFromUpdate: true,
|
||||
},
|
||||
{ dbCol: 'dateModified', getValue: (g) => g.date_modified ?? new Date().toISOString() },
|
||||
{ dbCol: 'activeDataId', getValue: (g) => g.active_data_id ?? null },
|
||||
{ dbCol: 'activeDataOnDisk', getValue: (g) => (g.active_data_on_disk ? 1 : 0) },
|
||||
@@ -195,7 +201,10 @@ export class MetadataSyncService {
|
||||
this.syncStatusService.updateProgress('reading-config', 5, 'Reading preferences...');
|
||||
const preferencesContent = await fs.readFile(this.preferencesPath, 'utf-8');
|
||||
const preferences = JSON.parse(preferencesContent);
|
||||
const sources: GameMetadataSource[] = preferences.gameMetadataSources || [];
|
||||
|
||||
// Prefer an admin-configured source: editions such as Ultimate ship without
|
||||
// gameMetadataSources, and preferences alone would leave sync unavailable.
|
||||
const sources: GameMetadataSource[] = await MetadataSourceService.getEffectiveSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
throw new Error('No game metadata sources found in preferences');
|
||||
@@ -538,7 +547,9 @@ export class MetadataSyncService {
|
||||
for (let i = 0; i < parentIdArray.length; i += BATCH_SIZE) {
|
||||
const batch = parentIdArray.slice(i, i + BATCH_SIZE);
|
||||
const placeholders = batch.map(() => '?').join(', ');
|
||||
const rows = db.prepare(`SELECT id FROM game WHERE id IN (${placeholders})`).all(batch) as Array<{ id: string }>;
|
||||
const rows = db
|
||||
.prepare(`SELECT id FROM game WHERE id IN (${placeholders})`)
|
||||
.all(batch) as Array<{ id: string }>;
|
||||
rows.forEach((row) => existingParentIds.add(row.id));
|
||||
}
|
||||
}
|
||||
@@ -679,7 +690,9 @@ export class MetadataSyncService {
|
||||
|
||||
// Log cycle summary once (not per-game)
|
||||
if (cycleCount > 0) {
|
||||
logger.debug(`[MetadataSync] Found ${cycleCount} circular parent references in batch (handled)`);
|
||||
logger.debug(
|
||||
`[MetadataSync] Found ${cycleCount} circular parent references in batch (handled)`
|
||||
);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { logger } from '../utils/logger';
|
||||
import { MetadataSourceService } from './MetadataSourceService';
|
||||
import { PreferencesService, GameMetadataSource } from './PreferencesService';
|
||||
|
||||
export { GameMetadataSource } from './PreferencesService';
|
||||
@@ -21,7 +22,7 @@ export class MetadataUpdateService {
|
||||
* This determines whether metadata sync feature is available
|
||||
*/
|
||||
async hasMetadataSource(): Promise<boolean> {
|
||||
return PreferencesService.hasMetadataSource();
|
||||
return MetadataSourceService.hasSource();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,7 @@ export class MetadataUpdateService {
|
||||
*/
|
||||
async getMetadataUpdateInfo(): Promise<MetadataUpdateInfo> {
|
||||
try {
|
||||
const sources = await PreferencesService.getGameMetadataSources();
|
||||
const sources = await MetadataSourceService.getEffectiveSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
logger.warn('[MetadataUpdate] No game metadata sources found in preferences');
|
||||
@@ -127,7 +128,7 @@ export class MetadataUpdateService {
|
||||
*/
|
||||
async checkRemoteUpdates(): Promise<{ latestUpdateTime: string } | null> {
|
||||
try {
|
||||
const sources = await PreferencesService.getGameMetadataSources();
|
||||
const sources = await MetadataSourceService.getEffectiveSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Trusted metadata source hosts.
|
||||
*
|
||||
* Metadata sync rewrites rows in flashpoint.sqlite, so the source is restricted
|
||||
* to hosts the project trusts rather than any URL an admin can type.
|
||||
*/
|
||||
export const ALLOWED_METADATA_HOSTS: readonly string[] = [
|
||||
'fpfss.unstable.life',
|
||||
'fpfss.flashpointarchive.org',
|
||||
];
|
||||
|
||||
/** The source the Flashpoint Infinity build ships with, used as the default offer. */
|
||||
export const DEFAULT_METADATA_SOURCE_URL = 'https://fpfss.flashpointarchive.org';
|
||||
|
||||
export function isAllowedMetadataSourceUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
return ALLOWED_METADATA_HOSTS.includes(new URL(baseUrl).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user