release: v0.20.0
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
102
app/api/admin/__tests__/doctor-zoom.test.ts
Normal file
102
app/api/admin/__tests__/doctor-zoom.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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) } },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user