release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -17,9 +17,9 @@ const fsMock = vi.hoisted(() => ({
copyFile: vi.fn(async () => undefined),
readdir: vi.fn(async () => []),
unlink: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
writeFile: vi.fn(async (..._args: unknown[]) => undefined),
rename: vi.fn(async () => undefined),
readFile: vi.fn(async () => {
readFile: vi.fn(async (..._args: unknown[]): Promise<Buffer> => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}),
}));
@@ -43,13 +43,14 @@ const put = (body: unknown) =>
* brand-new file and the save went ahead with no snapshot (#630).
*/
describe('PUT /api/admin/about backup handling', () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
fs.readFile.mockImplementation(async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
it('saves a brand-new file with no backup', async () => {
vi.mocked(fs.access).mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
expect(res.status).toBe(200);
@@ -58,6 +59,7 @@ describe('PUT /api/admin/about backup handling', () => {
});
it('aborts the save when the file exists but the backup copy fails', async () => {
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('---\nname: Old\n---\n'));
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
@@ -65,4 +67,15 @@ describe('PUT /api/admin/about backup handling', () => {
await expect(put({ meta: { name: 'Me' }, body: 'Hello' })).rejects.toThrow('EACCES');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('takes no backup for a save that changes nothing (QA A-19)', async () => {
await put({ meta: { name: 'Me' }, body: 'Hello' });
const written = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
vi.mocked(fs.writeFile).mockClear();
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from(written));
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
expect(res.status).toBe(200);
expect(fs.copyFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* The doctor's zoom check (#467) samples one photo that zoom serves through
* Immich's full-size rendition. A rendition in a format the zoom route refuses
* (WebP) is not "fine" — the doctor used to say OK because one existed.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
COOKIE_NAME: 'folio_admin_session',
}));
const ALBUM = '11111111-1111-1111-1111-111111111111';
const config = {
needsCredentials: false,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [ALBUM],
standaloneAlbums: [ALBUM],
standaloneAlbumZoom: {},
zoom: true,
albumOverrides: {},
albumDownloads: {},
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: 'https://immich.example/api', apiKey: 'key' },
immichTimeoutMs: 1000,
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
contact: { enabled: false, retentionDays: 90 },
privacy: { enabled: true },
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
slugify: (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
vi.mock('@/lib/admin/yaml-service', () => ({ readSettingsYaml: async () => null }));
const immichMock = vi.hoisted(() => ({ getAlbum: vi.fn(), streamFullsize: vi.fn() }));
vi.mock('@/lib/immich', () => ({ immich: immichMock }));
import { GET } from '../doctor/route';
import { NextRequest } from 'next/server';
const zoomFinding = async () => {
const body = await (await GET(new NextRequest('http://localhost/api/admin/doctor'))).json();
return body.findings.find((f: { id: string }) => f.id === 'zoom-renditions');
};
const rendition = (contentType: string) => ({
stream: new ReadableStream(),
contentType,
contentLength: '10',
});
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url.includes('/albums')
? ({ ok: true, json: async () => [{ id: ALBUM, albumName: 'Trip' }] } as Response)
: ({ ok: true, json: async () => ({}) } as Response),
),
);
immichMock.getAlbum.mockResolvedValue({
id: ALBUM,
albumName: 'Trip',
assets: [{ id: 'heic', type: 'IMAGE', originalMimeType: 'image/heic' }],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe('GET /api/admin/doctor — zoom renditions', () => {
it('is OK for a JPEG rendition', async () => {
immichMock.streamFullsize.mockResolvedValue(rendition('image/jpeg'));
expect((await zoomFinding())?.level).toBe('ok');
expect(immichMock.streamFullsize).toHaveBeenCalledWith('heic');
});
it('warns when Immich has none', async () => {
immichMock.streamFullsize.mockResolvedValue(null);
const finding = await zoomFinding();
expect(finding?.level).toBe('warn');
expect(finding?.title).toMatch(/no full-size preview/);
});
it('warns, asking for JPEG, when the rendition is WebP (review of #830)', async () => {
immichMock.streamFullsize.mockResolvedValue(rendition('image/webp'));
const finding = await zoomFinding();
expect(finding?.level).toBe('warn');
expect(finding?.title).toMatch(/not JPEG/);
});
});

View File

@@ -6,6 +6,7 @@ import path from 'path';
import yaml from 'js-yaml';
import { atomicWrite } from '@/lib/atomicWrite';
import {
needsBackup,
VersionConflictError,
assertVersion,
baseVersionFrom,
@@ -90,15 +91,8 @@ export const PUT = withAdmin(async (request: Request) => {
// brand-new file: the save went ahead with no snapshot taken (#630). Only
// ENOENT means there is nothing to back up; anything else aborts the save
// before it overwrites the live file.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException & { code?: string }).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

View File

@@ -3,6 +3,9 @@ import { withAdmin } from '@/lib/admin/withAdmin';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getConfig, slugify } from '@/lib/config';
// From the pure schema module, like lib/auth.ts: tests stub the config barrel.
import { resolveZoom } from '@/lib/config/schema';
import { ZOOM_CONTENT_TYPES, zoomSourceFor } from '@/lib/zoomSource';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { readSettingsYaml } from '@/lib/admin/yaml-service';
@@ -11,6 +14,8 @@ import { readPrivacy } from '@/lib/privacy';
import { listPageSlugsSync } from '@/lib/admin/pages-service';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
import { describeCollision, menuPageSlugs, pageSlugCollision } from '@/lib/pages';
import { immich } from '@/lib/immich';
import { isLocationScrubbable } from '@/lib/locationScrub';
import {
checkAlbumIds,
checkAlbumSlugCollisions,
@@ -20,21 +25,26 @@ import {
checkImmichCalls,
checkContact,
checkContentPages,
checkDownloadMetadata,
checkLegal,
checkPrivacy,
checkSettingValues,
checkPasswords,
checkProxyHops,
checkWritable,
checkZoomRenditions,
countForwardedHops,
PROXY_MARKER_HEADERS,
worstLevel,
type AlbumRef,
type AlbumSlugGroup,
type DoctorFinding,
type DownloadAlbumRef,
type ContactRef,
type LegalRef,
type PasswordRef,
type ZoomAlbumRef,
type ZoomRenditionSample,
} from '@/lib/admin/doctor';
/**
@@ -130,6 +140,81 @@ export const GET = withAdmin(async (request: NextRequest) => {
})),
];
findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify));
// Originals whose metadata a download cannot clean. Through the album
// cache, like the alt-text report; an album that cannot be read is
// already reported above and is skipped here.
const downloadAlbums: DownloadAlbumRef[] = [];
for (const id of config.albums.filter((albumId) => config.albumDownloads?.[albumId])) {
try {
const album = await immich.getAlbum(id);
if (!album) continue;
const uncleanable = album.assets.filter((asset) => !isLocationScrubbable(asset));
downloadAlbums.push({
id,
albumName: album.albumName,
uncleanable: uncleanable.length,
uncleanableWithLocation: uncleanable.filter(
(asset) => asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null,
).length,
});
} catch {
// Immich unreachable: the connection check says so.
}
}
const downloads = checkDownloadMetadata(downloadAlbums);
if (downloads) findings.push(downloads);
// Lightbox zoom (#467): photos a browser cannot show need Immich's
// full-size rendition. Whether Immich has them is only known by asking,
// so one such photo is sampled. "On" here means on for any route to the
// album, standalone or through an enabled subpage.
const zoomAlbums: ZoomAlbumRef[] = [];
let renditionSample: ZoomRenditionSample | null = null;
try {
const zoomOn = (albumId: string) =>
(config.standaloneAlbums.includes(albumId) && resolveZoom(config, albumId)) ||
config.subpages.some(
(sp) =>
sp.enabled !== false &&
sp.albumIds.includes(albumId) &&
resolveZoom(config, albumId, sp),
);
let sample: string | undefined;
for (const id of config.albums.filter(zoomOn)) {
try {
const album = await immich.getAlbum(id);
if (!album) continue;
const viaRendition = album.assets.filter(
(asset) => zoomSourceFor(asset) === 'fullsize',
);
sample ??= viaRendition[0]?.id;
zoomAlbums.push({ id, albumName: album.albumName, needRendition: viaRendition.length });
} catch {
// Immich unreachable: the connection check says so.
}
}
if (sample) {
try {
const rendition = await immich.streamFullsize(sample);
if (!rendition) {
renditionSample = 'missing';
} else {
await (rendition.stream as ReadableStream).cancel().catch(() => {});
// The zoom route serves only what it can scrub: a WebP rendition
// exists, and is still refused.
const type = rendition.contentType.toLowerCase().split(';')[0].trim();
renditionSample = ZOOM_CONTENT_TYPES.has(type) ? 'ok' : { contentType: type };
}
} catch {
// Unknown; reported as nothing rather than as a false alarm.
}
}
} catch {
// Best-effort, like every check here: no finding rather than no report.
}
const zoomFinding = checkZoomRenditions(zoomAlbums, renditionSample);
if (zoomFinding) findings.push(zoomFinding);
}
}

View File

@@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { atomicWrite } from '@/lib/atomicWrite';
import {
needsBackup,
VersionConflictError,
assertVersion,
baseVersionFrom,
@@ -17,6 +18,8 @@ import {
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { PRIVACY_FILENAME, processingFacts, readPrivacy, starterHeadings } from '@/lib/privacy';
import { listSessions } from '@/lib/proofSessions';
import { webhookUrl } from '@/lib/proofWebhook';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const MAX_BACKUPS = 10;
@@ -35,6 +38,12 @@ export const GET = withAdmin(async () => {
!!config.sitePassword ||
config.subpages.some((sp) => !!sp.password) ||
Object.values(config.albumPasswords).some(Boolean),
// An unreadable proofing.json may still hold client data: report it.
hasProofingLinks: await listSessions().then(
(sessions) => sessions.length > 0,
() => true,
),
proofingWebhookUrl: webhookUrl(),
});
// Read before the text: a save landing between the two then makes the
// version stale, which costs a spurious conflict, never a missed one.
@@ -70,15 +79,8 @@ export const PUT = withAdmin(async (request: Request) => {
// Same rule as the about route (#630): only a missing file means there is
// nothing to back up. Any other failure aborts before the live file changes.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

View File

@@ -40,6 +40,9 @@ export const GET = withAdmin(async () => {
// Fields an environment variable overrides, as path → variable name. Only
// the names: the values are secrets (a password, an ntfy topic) (#605).
envLocks: resolveEnvLocks(env),
// What an empty title falls back to before the language default, so the
// General section can show the title the site really uses (QA A-15).
siteTitleEnv: env.SITE_TITLE || null,
},
{ headers: { ETag: etag(version) } },
);

View File

@@ -26,7 +26,10 @@ export const GET = withAdmin(
try {
const res = await fetch(
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail`,
// `edited=true`: the pickers show a photo as edited in Immich, like
// the site does (#831). For a photo that is not edited Immich returns
// the same bytes, so it needs no lookup first.
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail&edited=true`,
{
headers: {
'x-api-key': config.immich.apiKey,