release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
300
lib/__tests__/publishedAssets.test.ts
Normal file
300
lib/__tests__/publishedAssets.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* GHSA-gfh4-6275-9gqv: the media routes serve only assets the site shows. Each
|
||||
* source that puts an asset on a public page must land in the set, or that
|
||||
* page loses its photos; nothing else may.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
config: {
|
||||
albums: ['album-pub', 'album-offline'],
|
||||
standaloneAlbums: ['album-pub'],
|
||||
heroImages: ['hero-1'],
|
||||
albumHeroImages: {
|
||||
'album-pub': 'album-hero',
|
||||
'album-offline': 'offline-hero',
|
||||
} as Record<string, string>,
|
||||
subpages: [
|
||||
{ albumIds: [], enabled: true, essayText: '' },
|
||||
// Taken offline with `enabled: false`: its albums stay on the allowlist.
|
||||
{
|
||||
albumIds: ['album-offline'],
|
||||
enabled: false,
|
||||
essayText: '',
|
||||
},
|
||||
] as Array<{ albumIds: string[]; enabled: boolean; essayText?: string }>,
|
||||
cacheTtl: 300,
|
||||
},
|
||||
albums: {
|
||||
'album-pub': { albumThumbnailAssetId: 'thumb', assets: [{ id: 'a1' }, { id: 'a2' }] },
|
||||
'album-offline': { albumThumbnailAssetId: 'offline-thumb', assets: [{ id: 'offline-1' }] },
|
||||
} as Record<string, { albumThumbnailAssetId: string | null; assets: { id: string }[] }>,
|
||||
raw: {
|
||||
'album-journal': [{ id: 'j-album-1' }],
|
||||
'album-essay': [{ id: 'essay-1' }],
|
||||
'album-offline-essay': [{ id: 'offline-essay-1' }],
|
||||
'album-page': [{ id: 'page-album-1' }],
|
||||
} as Record<string, { id: string }[]>,
|
||||
proofAlbums: {
|
||||
'album-proof': { albumThumbnailAssetId: null, assets: [{ id: 'proof-1' }] },
|
||||
'album-expired': { albumThumbnailAssetId: null, assets: [{ id: 'expired-1' }] },
|
||||
} as Record<string, { albumThumbnailAssetId: string | null; assets: { id: string }[] }>,
|
||||
builds: 0,
|
||||
mtime: 1,
|
||||
/** While set, a proofing album lookup reads its answer, then waits for it. */
|
||||
gate: null as Promise<void> | null,
|
||||
onGate: null as (() => void) | null,
|
||||
}));
|
||||
|
||||
vi.mock('../config', () => ({ getConfig: () => state.config }));
|
||||
|
||||
vi.mock('../immich', () => ({
|
||||
immich: {
|
||||
getAlbum: async (id: string) => {
|
||||
state.builds += id === 'album-pub' ? 1 : 0;
|
||||
return state.albums[id] ?? null;
|
||||
},
|
||||
getProofingAlbum: async (id: string) => {
|
||||
const album = state.proofAlbums[id];
|
||||
const answer = album ? { ...album, assets: [...album.assets] } : null;
|
||||
if (state.gate) {
|
||||
state.onGate?.();
|
||||
await state.gate;
|
||||
}
|
||||
return answer;
|
||||
},
|
||||
getAlbumAssetsRaw: async (id: string) => state.raw[id] ?? [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../admin/journal-service', () => ({
|
||||
listJournalEntries: async () => [{ slug: 'trip' }, { slug: 'draft' }],
|
||||
readJournalEntry: async (slug: string) => ({
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed:
|
||||
slug === 'trip'
|
||||
? {
|
||||
frontmatter: { coverAssetId: 'j-cover' },
|
||||
blocks: [
|
||||
{ type: 'photo', assetId: 'j-photo', layout: 'wide' },
|
||||
{ type: 'album', albumId: 'album-journal', layout: 'grid' },
|
||||
],
|
||||
}
|
||||
: { frontmatter: {}, blocks: [{ type: 'photo', assetId: 'draft-photo', layout: 'wide' }] },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../admin/pages-service', () => ({
|
||||
listPages: async () => [{ slug: 'pricing', frontmatter: {} }],
|
||||
readPage: async (slug: string) => ({
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed: {
|
||||
frontmatter: { draft: true },
|
||||
blocks: [
|
||||
{ type: 'photo', assetId: 'page-photo', layout: 'wide' },
|
||||
{ type: 'album', albumId: 'album-page', layout: 'grid' },
|
||||
{
|
||||
type: 'map',
|
||||
line: true,
|
||||
items: [{ kind: 'photo', assetId: 'page-map-photo' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../journal', async (orig) => {
|
||||
const actual = await orig<typeof import('../journal')>();
|
||||
return {
|
||||
...actual,
|
||||
parseJournalMarkdown: (md: string) => {
|
||||
const albumId = /album:([\w-]+)/.exec(md)?.[1];
|
||||
return albumId
|
||||
? { frontmatter: {}, blocks: [{ type: 'album', albumId, layout: 'grid' }] }
|
||||
: actual.parseJournalMarkdown(md);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../proofSessions', () => ({
|
||||
listSessions: async () => [
|
||||
{ albumId: 'album-proof', expiresOn: undefined },
|
||||
{ albumId: 'album-expired', expiresOn: '2000-01-01' },
|
||||
],
|
||||
isExpired: (s: { expiresOn?: string }) => s.expiresOn === '2000-01-01',
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
readFile: async () => '---\nportrait: portrait-1\n---\nBio',
|
||||
stat: async () => ({ mtimeMs: state.mtime }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { isPublishedAsset, resetPublishedAssetsForTest } from '../publishedAssets';
|
||||
|
||||
beforeEach(() => {
|
||||
resetPublishedAssetsForTest();
|
||||
state.builds = 0;
|
||||
});
|
||||
|
||||
describe('isPublishedAsset', () => {
|
||||
it.each([
|
||||
['an album photo', 'a1'],
|
||||
['an album thumbnail', 'thumb'],
|
||||
['a homepage hero', 'hero-1'],
|
||||
['an album hero', 'album-hero'],
|
||||
['the about portrait', 'portrait-1'],
|
||||
['a journal cover', 'j-cover'],
|
||||
['a journal photo block', 'j-photo'],
|
||||
["a journal album block's photo", 'j-album-1'],
|
||||
['a draft entry photo (its page gate hides it)', 'draft-photo'],
|
||||
["an inline essay's album block photo", 'essay-1'],
|
||||
['a content page photo block (draft included)', 'page-photo'],
|
||||
["a content page album block's photo", 'page-album-1'],
|
||||
['a photo of an open proofing link', 'proof-1'],
|
||||
])('serves %s', async (_label, id) => {
|
||||
expect(await isPublishedAsset(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a photo of an expired proofing link', async () => {
|
||||
expect(await isPublishedAsset('expired-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a photo only a content page map block names (maps do not render on pages)', async () => {
|
||||
expect(await isPublishedAsset('page-map-photo')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a photo of its album', 'offline-1'],
|
||||
['its album thumbnail', 'offline-thumb'],
|
||||
['its album hero', 'offline-hero'],
|
||||
["an album block's photo in its inline essay", 'offline-essay-1'],
|
||||
])('refuses %s once a subpage is offline', async (_label, id) => {
|
||||
expect(await isPublishedAsset(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses any other asset in the library', async () => {
|
||||
expect(await isPublishedAsset('never-published')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds once and rebuilds on a miss at most every ten seconds', async () => {
|
||||
await isPublishedAsset('a1');
|
||||
await isPublishedAsset('a2');
|
||||
expect(state.builds).toBe(1);
|
||||
|
||||
await isPublishedAsset('unknown-1'); // forced rebuild
|
||||
await isPublishedAsset('unknown-2'); // throttled
|
||||
expect(state.builds).toBe(2);
|
||||
});
|
||||
|
||||
it('rebuilds at once when a content file changes, e.g. a revoked link', async () => {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(true);
|
||||
delete state.proofAlbums['album-proof'].assets[0];
|
||||
state.proofAlbums['album-proof'].assets = [];
|
||||
state.mtime += 1; // proofing.json rewritten by the admin
|
||||
try {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(false);
|
||||
} finally {
|
||||
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The files used to be checked at most every 2 s. A request in that window
|
||||
* after a save was answered from the set before it — with `immutable`, so
|
||||
* the browser kept the photo.
|
||||
*/
|
||||
it('sees a save made milliseconds after the previous check', async () => {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(true);
|
||||
expect(await isPublishedAsset('a1')).toBe(true); // a check a moment ago
|
||||
state.proofAlbums['album-proof'].assets = [];
|
||||
state.mtime += 1;
|
||||
try {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(false);
|
||||
} finally {
|
||||
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A request that finds a build already running joins it. When that build
|
||||
* read the content before the save, its set must not answer for a request
|
||||
* that saw the save.
|
||||
*/
|
||||
it('does not answer from a build that read the content before the save', async () => {
|
||||
expect(await isPublishedAsset('proof-1')).toBe(true);
|
||||
|
||||
let release!: () => void;
|
||||
state.gate = new Promise<void>((r) => (release = r));
|
||||
const reached = new Promise<void>((r) => (state.onGate = r));
|
||||
state.mtime += 1; // an unrelated save starts a rebuild…
|
||||
const first = isPublishedAsset('a1');
|
||||
await reached; // …which has read the proofing album and is waiting on Immich
|
||||
|
||||
state.proofAlbums['album-proof'].assets = [];
|
||||
state.mtime += 1; // the link is revoked
|
||||
const second = isPublishedAsset('proof-1');
|
||||
|
||||
state.gate = null;
|
||||
state.onGate = null;
|
||||
release();
|
||||
try {
|
||||
expect(await first).toBe(true);
|
||||
expect(await second).toBe(false);
|
||||
} finally {
|
||||
state.proofAlbums['album-proof'].assets = [{ id: 'proof-1' }];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The first view of a newly published album: a burst of image requests for
|
||||
* photos the set has never seen. Only the first one rebuilt; the rest hit
|
||||
* the rebuild throttle and were refused while that rebuild was still
|
||||
* running — 11 of 13 photos broken until a reload.
|
||||
*/
|
||||
describe('a burst of requests for a newly published album', () => {
|
||||
const NEW = Array.from({ length: 13 }, (_, i) => ({ id: `fresh-${i}` }));
|
||||
|
||||
beforeEach(async () => {
|
||||
await isPublishedAsset('a1'); // built, and checked a moment ago
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }];
|
||||
});
|
||||
|
||||
it('serves every photo when the save changed a content file', async () => {
|
||||
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW];
|
||||
state.mtime += 1; // gallery.yaml saved by the admin
|
||||
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
|
||||
expect(answers).toEqual(NEW.map(() => true));
|
||||
});
|
||||
|
||||
it('serves every photo when only Immich changed (misses share one rebuild)', async () => {
|
||||
state.albums['album-pub'].assets = [{ id: 'a1' }, { id: 'a2' }, ...NEW];
|
||||
const before = state.builds;
|
||||
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
|
||||
expect(answers).toEqual(NEW.map(() => true));
|
||||
expect(state.builds - before).toBe(1);
|
||||
});
|
||||
|
||||
it('still refuses what the rebuild did not publish', async () => {
|
||||
const answers = await Promise.all(NEW.map((a) => isPublishedAsset(a.id)));
|
||||
expect(answers).toEqual(NEW.map(() => false));
|
||||
});
|
||||
});
|
||||
|
||||
it('picks up an asset published since the last build', async () => {
|
||||
await isPublishedAsset('a1');
|
||||
state.albums['album-pub'].assets.push({ id: 'new-photo' });
|
||||
try {
|
||||
expect(await isPublishedAsset('new-photo')).toBe(true);
|
||||
} finally {
|
||||
state.albums['album-pub'].assets.pop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user