security: add Trivy container scan to Docker publish workflow (#399)

* feat(admin): add backup manager UI with 1-click restore

* feat(admin): collapse older backup entries by default

* fix(theme): isolate admin layout, instant DOM theme updates, and add Light/Dark mode toggle

* feat(admin): replace text controls for Photo Frame and Hero Style with visual preview cards

* fix(admin): define missing CSS rules for button system (.btn, .preset-btn)

* feat(admin): redesign theme settings with segmented mode toggle, accent color swatches, and custom iOS-style toggle cards

* feat(admin): upgrade all settings tabs with visual grid previews, Google SEO snippet preview, and toggle cards

* feat(admin): overhaul Pages Builder UX with slide-over drawer, card hover badges, real-time search, and floating save bar

* fix(admin): remove leftover dangling syntax snippet in PageBuilder.tsx

* fix(admin): add position: relative to subpage-tile so hover overlay is contained inside the tile

* style(admin): replace all emojis with clean Leica-style vector SVG icons

* fix(types): extend IconProps with SVGProps to allow style prop on Leica SVG icons

* feat(admin): add subpage live-preview, privacy analytics, and asset protection settings

* fix(typecheck): fix AlbumEntry property access in subpage drawer preview mode

* fix(types): fix ImmichAlbumInfo property names thumbnailAssetId and albumName in PageBuilder.tsx

* fix(types): add protection and watermark optional properties to AppConfig interface

* feat(settings): add toggle for disabling analytics tracking (defaults to enabled)

* feat(ux): show status banner when analytics tracking is disabled + add live sync indicator

* feat(watermark): render customizable watermark overlay on Lightbox images + rename section to Portfolio Features & Modules

* fix(types): import type LightboxWatermark in PhotoGrid.tsx

* fix(types): destructure watermark prop in PhotoGrid function signature

* feat(seo): add configurable subpage title template and metadata descriptions

* feat(seo): add generateMetadata to about page for title template support

* feat(proofing): add client proofing, favorites selection, compact URL bitmasking, and export modal

* feat(essay): add Photo Essay storytelling mode with RSC markdown parser and fullbleed/pair layouts

* feat(subpages): add page enabled toggle to deactivate subpages without deleting them

* feat(admin): add visual essay block builder for photo essay storytelling pages

* security: add Trivy container scan to Docker publish workflow

Scans the built image for CRITICAL/HIGH CVEs on every release and
uploads results to the GitHub Security tab. Non-blocking for now
(exit-code 0) so releases aren't gated on findings. Also documents
the scan and existing Dependabot coverage in the README.

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-08-07 11:08:04 +02:00
committed by GitHub
parent 716ae14b0f
commit c9036fdc94
38 changed files with 5872 additions and 519 deletions

View File

@@ -31,6 +31,8 @@ import { isProtected, isAuthenticated } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate';
import { AlbumDetailView } from './AlbumDetailView';
import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -46,18 +48,13 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
const slug = path[0];
let title = slug;
let subtitle = '';
if (path.length === 2) {
const album = await immich.getAlbumBySlug(path[1], slug);
if (album) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else if (immich.isSubpageSlug(slug)) {
let description: string | undefined = undefined;
if (path.length === 1 && immich.isSubpageSlug(slug)) {
const result = await immich.getSubpageAlbums(slug);
if (result) {
// Single album → use album name; multiple → use subpage name
if (result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album) {
@@ -66,9 +63,16 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else {
title = result.subpage.name;
title = result.subpage.title || result.subpage.name;
}
}
} else if (path.length === 2) {
const album = await immich.getAlbumBySlug(path[1], slug);
if (album) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else {
const album = await immich.getAlbumBySlug(slug);
if (album) {
@@ -82,8 +86,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
return {
title,
openGraph: { title, images: [ogUrl] },
twitter: { card: 'summary_large_image', title, images: [ogUrl] },
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
openGraph: {
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
images: [ogUrl],
},
twitter: {
card: 'summary_large_image',
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
images: [ogUrl],
},
};
}
@@ -204,6 +218,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle(spGrid)}
backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
{...heroData}
/>
);
@@ -225,6 +240,57 @@ export default async function PathPage({ params }: PathPageProps) {
const { albums } = result;
// ── Photo Essay / Storytelling Mode ───────────────────────
const isEssay =
result.subpage.grid?.layout === 'essay' ||
!!result.subpage.essayFile ||
!!result.subpage.essayText;
if (isEssay) {
let essayParsed = result.subpage.essayText
? parseEssayMarkdown(result.subpage.essayText)
: result.subpage.essayFile
? loadEssayFromFile(result.subpage.essayFile)
: null;
// Fetch assets from all subpage albums
const allAlbums = await Promise.all(
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(allAssets, config.exifOnHover);
// Fallback structured essay if layout: 'essay' is set without custom markdown file
if (!essayParsed) {
essayParsed = {
frontmatter: {
title: result.subpage.title || result.subpage.name,
subtitle: result.subpage.subtitle,
},
blocks: albums.flatMap((a) => [
{ type: 'heading' as const, level: 2, text: a.albumName },
...a.assets.map((asset) => ({
type: 'photo' as const,
assetId: encodeAssetId(asset.id),
caption: asset.exifInfo?.description || undefined,
layout: 'contained' as const,
})),
]),
referencedAssetIds: [],
};
}
return (
<EssayView
essay={essayParsed}
assets={images}
title={result.subpage.title || result.subpage.name}
subtitle={result.subpage.subtitle}
watermark={config.watermark}
/>
);
}
// ── Single album → full-bleed (skip album grid) ──────────
if (albums.length === 1) {
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
@@ -247,6 +313,7 @@ export default async function PathPage({ params }: PathPageProps) {
subtitle={result.subpage.subtitle}
backLinkHref="/"
backLinkLabel="Back to Gallery"
watermark={config.watermark}
{...heroData}
/>
);
@@ -303,6 +370,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle()}
backLinkHref="/"
backLinkLabel="Back to Gallery"
watermark={config.watermark}
{...heroData}
/>
);