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:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user