Files
immich-folio/lib/config/settingValues.ts
2026-10-02 18:14:23 +02:00

140 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Values in settings.yaml that the site would silently ignore (QA A-14).
*
* `validateSettings` (settingsSchema.ts) checks types and structure and
* deliberately leaves values to the resolvers, which clamp or fall back so
* hand-edited YAML never takes the site down. For the fields the panel offers
* an input for, that meant a save of `accent: rot` or nine columns in a 1–6
* field was reported as "Saved" while the site showed something else.
*
* This checks the values a resolver bends — the site URL, the accent, the
* corner radius, the grid columns and gap, the message retention and the
* header links — with the resolvers' own rules and bounds, so it can never be
* stricter than what renders. Kept apart from settingsSchema.ts so the
* settings form can run it as you type without bundling zod; the save route
* runs it before writing, and both doctors over settings.yaml as it is.
*
* `npm run doctor` imports this file under Node's own type stripping
* (scripts/doctor.mts), which resolves no extensionless paths: every relative
* import here carries its `.ts`, and the modules it pulls in may only
* `import type` from elsewhere. scripts/__tests__/doctor-cli.test.ts loads the
* graph in a real Node process and fails when that breaks.
*/
import { normaliseSiteUrl } from '../siteUrl.ts';
import { isAccentHex, THEME_RADIUS_MAX } from './theme.ts';
import {
CONTACT_RETENTION_MAX,
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
} from './schema.ts';
import type { SettingsFieldError } from './settingsSchema';
function inRange(value: unknown, min: number, max: number): boolean {
return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
}
/** What sanitizeNavLinks (lib/config/index.ts) keeps as a link target. */
const NAV_LINK_URL = /^https?:\/\//i;
/** One error per offending field; an empty string counts as unset, as in the resolvers. */
export function validateSettingValues(input: unknown): SettingsFieldError[] {
if (!input || typeof input !== 'object') return [];
const s = input as {
url?: unknown;
theme?: unknown;
grid?: { columns?: unknown; gap?: unknown } | null;
contact?: { retentionDays?: unknown } | null;
navLinks?: unknown;
};
const errors: SettingsFieldError[] = [];
// resolveSiteUrl: an unusable value falls back to SITE_URL, or to nothing,
// and the sitemap, feed and structured data go out without the address.
if (typeof s.url === 'string' && s.url.trim() !== '' && !normaliseSiteUrl(s.url)) {
errors.push({
field: 'url',
message: 'Must be a full http(s) address, e.g. https://photos.example.com.',
});
}
const theme =
s.theme && typeof s.theme === 'object'
? (s.theme as { accent?: unknown; radius?: unknown })
: null;
// resolveTheme: anything but hex falls back to the preset's accent.
const accent = theme?.accent;
if (typeof accent === 'string' && accent.trim() !== '' && !isAccentHex(accent)) {
errors.push({ field: 'theme.accent', message: 'Must be a hex colour such as #e60012.' });
}
// resolveRadius clamps into 0–THEME_RADIUS_MAX. The panel has no input for
// it, so the message says where the value lives.
const radius = theme?.radius;
if (
radius != null &&
(typeof radius !== 'number' ||
!Number.isFinite(radius) ||
radius < 0 ||
radius > THEME_RADIUS_MAX)
) {
errors.push({
field: 'theme.radius',
message: `Must be a number from 0 to ${THEME_RADIUS_MAX} (theme.radius in settings.yaml).`,
});
}
// getConfig clamps both into these bounds (lib/config/index.ts).
const grid = s.grid && typeof s.grid === 'object' ? s.grid : null;
if (
grid?.columns != null &&
!inRange(grid.columns, PHOTO_GRID_COLUMNS_MIN, PHOTO_GRID_COLUMNS_MAX)
) {
errors.push({
field: 'grid.columns',
message: `Must be a whole number from ${PHOTO_GRID_COLUMNS_MIN} to ${PHOTO_GRID_COLUMNS_MAX}.`,
});
}
if (grid?.gap != null && !inRange(grid.gap, 0, PHOTO_GRID_GAP_MAX)) {
errors.push({
field: 'grid.gap',
message: `Must be a whole number from 0 to ${PHOTO_GRID_GAP_MAX}.`,
});
}
// resolveContact rounds and clamps into 1–CONTACT_RETENTION_MAX.
const contact = s.contact && typeof s.contact === 'object' ? s.contact : null;
if (contact?.retentionDays != null && !inRange(contact.retentionDays, 1, CONTACT_RETENTION_MAX)) {
errors.push({
field: 'contact.retentionDays',
message: `Must be a whole number of days from 1 to ${CONTACT_RETENTION_MAX}.`,
});
}
// sanitizeNavLinks drops an entry without a label or without an http(s)
// URL, whole — so each half is reported against its own input.
if (Array.isArray(s.navLinks)) {
s.navLinks.forEach((entry: unknown, i) => {
const link = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : {};
const label = typeof link.label === 'string' ? link.label.trim() : '';
const url = typeof link.url === 'string' ? link.url.trim() : '';
if (!label) {
errors.push({
field: `navLinks.${i}.label`,
message: 'Needs a label, or the link is left out.',
});
}
if (!NAV_LINK_URL.test(url)) {
errors.push({
field: `navLinks.${i}.url`,
message: 'Must be a full http(s) address, or the link is left out.',
});
}
});
}
return errors;
}