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:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -1,35 +1,16 @@
import { notFound } from 'next/navigation';
import { cookies } from 'next/headers';
import type { Metadata } from 'next';
import { readJournalEntry, listJournalEntries } from '@/lib/admin/journal-service';
import {
collectAssetIds,
mapBlockAssetIds,
type JournalBlock,
type MapPin,
type ParsedJournal,
} from '@/lib/journal';
import { entryMapPins } from '@/lib/journalMap';
import { expandAlbumBlocks } from '@/lib/journalAlbum';
import { readJournalEntryForRequest, listJournalEntriesForRequest } from '@/lib/journal.server';
import type { ParsedJournal } from '@/lib/journal';
import { buildEssayPayload } from '@/app/[...path]/essayPayload';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import { isAuthenticated } from '@/lib/auth';
import { journalNeighbours } from '@/lib/journalNav';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import {
imageUrl,
videoUrl,
exifUrl,
assetPlaceholder,
assetAspectRatio,
assetCaption,
assetExifSummary,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import { imageUrl } from '@/lib/urls';
import { EssayView } from '@/app/[...path]/EssayView';
import { JournalNav } from '@/components/JournalNav';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import type { ImmichAsset } from '@/lib/immich';
import PasswordGate from '@/components/PasswordGate';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
@@ -43,9 +24,12 @@ interface JournalDetailPageProps {
export async function generateMetadata({ params }: JournalDetailPageProps): Promise<Metadata> {
const { slug } = await params;
const entry = await readJournalEntry(slug);
const entry = await readJournalEntryForRequest(slug);
const t = getServerDictionary();
if (!entry) return { title: t.journal.notFound };
// Nothing to describe: the requested slug is not echoed, and `robots: null`
// drops the layout's `index, follow`, which would contradict the noindex
// Next adds to the 404 below.
if (!entry) return { title: t.journal.notFound, robots: null };
const { frontmatter } = entry.parsed;
@@ -54,14 +38,15 @@ export async function generateMetadata({ params }: JournalDetailPageProps): Prom
// entry's real title, subtitle and cover image reached the <head> of a
// 200 response no authentication was ever asked for (GHSA-fvgv-97g3-wjr7).
const isAuthedAdmin = await isAdminAuthenticated();
// A draft is a 404 for everyone but an admin; describe it as one.
if (frontmatter.draft && !isAuthedAdmin) return { title: t.journal.notFound, robots: null };
const blocked =
(frontmatter.draft && !isAuthedAdmin) ||
(!!frontmatter.password &&
!isJournalAuthenticated(
slug,
frontmatter.password,
(await cookies()).get(`lb_auth_journal_${slug}`)?.value,
));
!!frontmatter.password &&
!isJournalAuthenticated(
slug,
frontmatter.password,
(await cookies()).get(`lb_auth_journal_${slug}`)?.value,
);
const title = blocked ? t.journal.title : frontmatter.title || slug;
const description = blocked
@@ -117,13 +102,13 @@ function isJournalAuthenticated(
export default async function JournalDetailPage({ params }: JournalDetailPageProps) {
const { slug } = await params;
const entry = await readJournalEntry(slug);
const entry = await readJournalEntryForRequest(slug);
if (!entry) {
notFound();
}
const { frontmatter, blocks: authoredBlocks, referencedAssetIds } = entry.parsed;
const { frontmatter, blocks: authoredBlocks } = entry.parsed;
const isAuthedAdmin = await isAdminAuthenticated();
// If draft, only visible to authenticated admin
@@ -137,7 +122,12 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
const cookieVal = cookieStore.get(`lb_auth_journal_${slug}`)?.value;
if (!isJournalAuthenticated(slug, frontmatter.password, cookieVal)) {
return <PasswordGate slug={slug} title={frontmatter.title || slug} type="journal" />;
// The generic journal title, like generateMetadata above: the index and
// the prev/next links hide a locked entry by name, so its gate must not
// print that name either (GHSA-fvgv-97g3-wjr7).
return (
<PasswordGate slug={slug} title={getServerDictionary().journal.title} type="journal" />
);
}
}
@@ -148,7 +138,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
// here even by name, the same rule the index and this entry's own gate
// already enforce (GHSA-fvgv-97g3-wjr7).
const journalCookies = await cookies();
const allEntries = await listJournalEntries();
const allEntries = await listJournalEntriesForRequest();
const visibleEntries = isAuthedAdmin
? allEntries
: allEntries.filter((e) => {
@@ -162,98 +152,15 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
);
const config = getConfig();
// Fetch all referenced assets from Immich
const assetPromises = referencedAssetIds.map(async (assetId) => {
try {
const asset = await immich.getAssetInfo(assetId);
return asset;
} catch {
return null;
}
const { essay, images, coverToken } = await buildEssayPayload(authoredBlocks, {
logTag: 'journal',
slug,
allowMap: true,
coverAssetId: frontmatter.coverAssetId,
});
const fetchedAssets = (await Promise.all(assetPromises)).filter(
(a): a is ImmichAsset => a !== null,
);
// Album blocks become photo blocks here. The raw fetch bypasses the album
// allowlist on purpose: an entry may already show any single photo by id,
// and the author's pick is the gate for a whole album just the same. An
// album Immich cannot deliver drops its block rather than the page.
const albumAssetsById = new Map<string, ImmichAsset[]>();
for (const block of authoredBlocks) {
if (block.type !== 'album' || !block.albumId || albumAssetsById.has(block.albumId)) continue;
try {
albumAssetsById.set(block.albumId, await immich.getAlbumAssetsRaw(block.albumId));
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[journal] ${slug}: album ${block.albumId} could not be loaded:`, error);
}
}
const { blocks } = expandAlbumBlocks(
authoredBlocks,
(albumId) => albumAssetsById.get(albumId),
config.albumManualOrders,
);
const knownIds = new Set(fetchedAssets.map((a) => a.id));
const rawAssets = [...fetchedAssets];
for (const list of albumAssetsById.values()) {
for (const asset of list) {
if (knownIds.has(asset.id)) continue;
knownIds.add(asset.id);
rawAssets.push(asset);
}
}
// EssayView drops photo blocks it cannot resolve, which is right for visitors
// but leaves no trace of *why* a photo vanished. Legacy positional references
// ("1", "2") are the usual cause: they only resolved against a subpage album,
// and a standalone journal entry has none.
if (fetchedAssets.length < referencedAssetIds.length) {
const resolved = new Set(fetchedAssets.map((a) => a.id));
const missing = referencedAssetIds.filter((id) => !resolved.has(id));
// eslint-disable-next-line no-console
console.warn(
`[journal] ${slug}: ${missing.length} photo reference(s) could not be resolved and will not render: ${missing.join(', ')}`,
);
}
/*
* EssayView resolves a block's photo through PhotoItem.id, and those ids are
* encrypted tokens — while the blocks coming out of the Markdown carry raw
* asset UUIDs. Without this translation every photo block fails its lookup and
* is dropped, so a correctly authored entry renders text only. Doing it here
* also keeps raw UUIDs out of the client payload, per the project rule that
* they must never reach the browser. Unresolvable references collapse to an
* empty string, which EssayView skips.
*/
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
// A map block's pins are computed here, from the photos fetched above and
// under each photo's `location:` precision, and only when the site
// publishes a map at all. With the map switched off the block is dropped
// before it can reach the client; with it on, the client gets pins only —
// never the items, which carry raw asset ids.
const entryPhotoIds = collectAssetIds(blocks.filter((b) => b.type !== 'map'));
const pinsByBlock = new Map<JournalBlock, MapPin[]>();
if (config.map) {
for (const block of blocks) {
if (block.type === 'map') {
pinsByBlock.set(block, await entryMapPins(block.items, rawAssets, entryPhotoIds));
}
}
}
// Photos that only anchor a map pin are fetched for their EXIF but are not
// part of the story, so they stay out of the lightbox sequence.
const shownAssetIds = new Set([
...entryPhotoIds,
...(frontmatter.coverAssetId ? [frontmatter.coverAssetId] : []),
]);
const essayForClient: ParsedJournal = {
...essay,
// Named fields, not a spread of `frontmatter`: EssayView reads only
// title, subtitle, coverAssetId, author and date, but a spread put the
// stored password — plaintext or a scrypt hash — into every visitor's
@@ -263,44 +170,10 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
subtitle: frontmatter.subtitle,
author: frontmatter.author,
date: frontmatter.date,
coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined,
coverAssetId: coverToken,
},
blocks: blocks.flatMap((block): JournalBlock[] => {
if (block.type === 'map') {
const pins = pinsByBlock.get(block);
return pins
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
: [];
}
return [mapBlockAssetIds(block, toToken)];
}),
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
};
const images: PhotoItem[] = rawAssets
.filter((a) => shownAssetIds.has(a.id) && (a.type === 'IMAGE' || a.type === 'VIDEO'))
.map((a) => {
const ph = assetPlaceholder(a);
const exif =
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
? assetExifSummary(a)
: undefined;
const caption = assetCaption(a, config.exif.caption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
aspectRatio: assetAspectRatio(a),
};
});
return (
<div style={{ paddingTop: '2rem' }}>
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '0 1.5rem 1rem' }}>