Files
immich-folio/lib/publishedAssets.ts
Ralksta ad8b09700b 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>
2026-09-29 22:50:30 +02:00

253 lines
9.3 KiB
TypeScript

/**
* The set of Immich assets the public site currently shows somewhere.
*
* Asset tokens are deterministic, so a media URL that was ever served stays
* decodable. Without a membership check, /api/image, /api/video and /api/exif
* kept streaming an asset after its album was unpublished or a proofing link
* was revoked (GHSA-gfh4-6275-9gqv). The media routes now ask this module.
*
* What counts as published — every source that turns an asset ID into a
* public URL:
* - the assets and thumbnail of every standalone album and every album of
* an enabled subpage; a subpage taken offline with `enabled: false` keeps
* its albums on the allowlist, but publishes nothing
* - the homepage hero list, and the hero image of each album above
* - the about.md portrait
* - every journal entry: cover, photo blocks, and the assets of its album
* blocks (drafts and locked entries included: their page gate still hides
* them, and the tokens cannot be guessed)
* - every content page (#722): photo blocks and the assets of its album
* blocks, drafts and locked pages included for the same reason; its map
* blocks are not rendered, so their photos do not count
* - album blocks in an enabled subpage's inline essayText
* - the album of every open (unexpired) proofing link
*
* Built lazily, cached for CACHE_TTL, one build shared by concurrent callers.
* The admin routes run in their own module instances, so a save cannot reach
* this cache directly. Instead the set remembers the modification times of
* the content files it was built from and rebuilds as soon as one changes —
* so unpublishing an album or revoking a proofing link takes effect at once,
* not after the TTL. A miss also forces one rebuild before answering "no".
*
* "At once" means: every answer comes from a set whose build began after the
* request looked at the files. The files are stat'ed on every request (they
* used to be checked at most every 2 s, and a request in that window was
* answered — `immutable` — from the set before the save), and a build reads
* the stamp before the content, so a save racing a build leaves the set
* stamped older than the files instead of newer.
*/
import fs from 'fs/promises';
import path from 'path';
import yaml from 'js-yaml';
import { getConfig } from './config';
import { onlineAlbumIds } from './config/schema';
import { immich } from './immich';
import { collectAssetIds, parseJournalMarkdown, type JournalBlock } from './journal';
import { listJournalEntries, readJournalEntry } from './admin/journal-service';
import { listPages, readPage } from './admin/pages-service';
import { isExpired, listSessions } from './proofSessions';
/** A forced rebuild on a miss at most this often, so misses cannot hammer Immich. */
const MIN_REBUILD_MS = 10_000;
const CONTENT = path.join(process.cwd(), 'content');
const WATCHED = [
'gallery.yaml',
'settings.yaml',
'about.md',
'proofing.json',
'journal',
'essays',
'pages',
];
let current: { assets: Set<string>; builtAt: number; stamp: string; startedAt: number } | null =
null;
let building: Promise<Set<string>> | null = null;
let lastForcedAt = 0;
/**
* Orders file checks and build starts. A set whose `startedAt` is above a
* request's check read the files after that request did, and so answers for
* it whatever the stamps say.
*/
let clock = 0;
function albumBlockIds(blocks: readonly JournalBlock[]): string[] {
return blocks.flatMap((b) => (b.type === 'album' && b.albumId ? [b.albumId] : []));
}
async function aboutPortrait(): Promise<string | null> {
try {
const raw = await fs.readFile(path.join(process.cwd(), 'content', 'about.md'), 'utf-8');
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const meta = match ? (yaml.load(match[1]) as { portrait?: unknown } | null) : null;
return typeof meta?.portrait === 'string' ? meta.portrait : null;
} catch {
return null;
}
}
/** Modification times of everything the set is built from. */
async function contentStamp(): Promise<string> {
const times = await Promise.all(
WATCHED.map((name) =>
fs.stat(path.join(CONTENT, name)).then(
(st) => st.mtimeMs,
() => 0,
),
),
);
return times.join(':');
}
async function build(): Promise<Set<string>> {
const config = getConfig();
const assets = new Set<string>();
const add = (id: string | null | undefined) => {
if (id) assets.add(id);
};
const rawAlbums = new Set<string>();
const online = onlineAlbumIds(config);
// Published albums. A failing album must not empty the whole set.
await Promise.all(
[...online].map(async (id) => {
const album = await immich.getAlbum(id).catch(() => null);
if (!album) return;
add(album.albumThumbnailAssetId);
for (const a of album.assets) add(a.id);
}),
);
config.heroImages.forEach(add);
for (const [albumId, assetId] of Object.entries(config.albumHeroImages)) {
if (online.has(albumId)) add(assetId);
}
add(await aboutPortrait());
// Journal entries, including drafts and locked ones (see above).
const entries = await listJournalEntries().catch(() => []);
await Promise.all(
entries.map(async (entry) => {
const read = await readJournalEntry(entry.slug).catch(() => null);
if (!read) return;
add(read.parsed.frontmatter.coverAssetId);
collectAssetIds(read.parsed.blocks).forEach(add);
albumBlockIds(read.parsed.blocks).forEach((id) => rawAlbums.add(id));
}),
);
// Content pages, drafts and locked ones included. Map blocks are dropped
// before a page renders, so their photos are not published through it.
const pages = await listPages().catch(() => []);
await Promise.all(
pages.map(async (page) => {
const read = await readPage(page.slug).catch(() => null);
if (!read) return;
const blocks = read.parsed.blocks.filter((b) => b.type !== 'map');
collectAssetIds(blocks).forEach(add);
albumBlockIds(blocks).forEach((id) => rawAlbums.add(id));
}),
);
// Inline essays in gallery.yaml: only their album blocks add assets; a photo
// block outside the subpage's albums is dropped before it becomes a URL.
for (const sp of config.subpages) {
if (sp.enabled !== false && sp.essayText) {
albumBlockIds(parseJournalMarkdown(sp.essayText).blocks).forEach((id) => rawAlbums.add(id));
}
}
// Open proofing links. Their albums are usually not published.
const sessions = await listSessions().catch(() => []);
const proofAlbums = new Set(sessions.filter((s) => !isExpired(s)).map((s) => s.albumId));
await Promise.all(
[...proofAlbums].map(async (id) => {
const album = await immich.getProofingAlbum(id).catch(() => null);
for (const a of album?.assets ?? []) add(a.id);
}),
);
await Promise.all(
[...rawAlbums].map(async (id) => {
const list = await immich.getAlbumAssetsRaw(id).catch(() => []);
for (const a of list) add(a.id);
}),
);
return assets;
}
function rebuild(): Promise<Set<string>> {
if (!building) {
const startedAt = ++clock;
// Stamp first, content second: a save landing mid-build then leaves the
// set stamped older than the files, and the next check rebuilds. Read in
// parallel, the stamp could come back newer than the content it labels.
building = contentStamp()
.then(async (stamp) => {
const assets = await build();
current = { assets, builtAt: Date.now(), stamp, startedAt };
return assets;
})
.finally(() => {
building = null;
});
}
return building;
}
/**
* Whether the public site currently shows this asset anywhere.
*
* Fails open when the set cannot be built at all (Immich down on a cold
* start): refusing every photo would take the site down, and the tokens are
* unguessable either way. Once built, a stale set is kept through outages.
*/
export async function isPublishedAsset(assetId: string): Promise<boolean> {
const ttlMs = getConfig().cacheTtl * 1000;
try {
const checkedAt = ++clock;
const stamp = await contentStamp();
const stale = () =>
!current ||
Date.now() - current.builtAt > ttlMs ||
(current.stamp !== stamp && current.startedAt < checkedAt);
// Twice at most: the first rebuild may join a build that started before
// this check; any build started after that one finished is recent enough.
for (let i = 0; i < 2 && stale(); i++) await rebuild();
} catch {
if (!current) return true;
}
if (current!.assets.has(assetId)) return true;
// Not in the set: it may have been published since the last build. A miss
// while a rebuild is running waits for it — the first view of a new album is
// a burst of misses, and refusing all but the one that started the rebuild
// broke most of its photos. Only with nothing running does the throttle
// decide whether a miss may start one.
if (!building) {
if (Date.now() - lastForcedAt < MIN_REBUILD_MS) return false;
lastForcedAt = Date.now();
}
try {
return (await rebuild()).has(assetId);
} catch {
return false;
}
}
/** Drop the cached set, e.g. after a save that publishes or unpublishes photos. */
export function invalidatePublishedAssets(): void {
current = null;
}
/** Test hook. */
export function resetPublishedAssetsForTest(): void {
current = null;
building = null;
lastForcedAt = 0;
clock = 0;
}