Initial commit
Deploy / validate-dispatch (push) Successful in 43s
Deploy / scheduled-rebuild-dev (push) Skipped
Deploy / scheduled-rebuild-master (push) Skipped
Deploy / rollback-dev (push) Skipped
Deploy / rollback-master (push) Skipped
Deploy / changes (push) Successful in 13s
Deploy / secret-scan (push) Successful in 9s
Deploy / app-quality (push) Successful in 11m21s
Deploy / security-deps (push) Successful in 14s
Deploy / context (push) Failing after 1s
Deploy / app-image (push) Skipped
Deploy / deploy-dev (push) Skipped
Deploy / deploy-master (push) Skipped

This commit is contained in:
Per Hoener
2026-09-18 23:37:19 +02:00
commit 0d45331f5c
135 changed files with 20651 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import fs from "node:fs";
import path from "node:path";
const DOWNLOADS_DIR = path.resolve(import.meta.dirname, "../public/downloads");
const PDF_METADATA_PATTERN =
/\/(?:Title|Author|Subject|Keywords|Creator|CreationDate|ModDate)\s*\(|<xmp:(?:CreatorTool|CreateDate|ModifyDate)>|<dc:(?:creator|title)>|<pdf:Keywords>/i;
function collectPdfs(directory) {
if (!fs.existsSync(directory)) return [];
return fs
.readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".pdf"))
.map((entry) => path.join(directory, entry.name));
}
const files = collectPdfs(DOWNLOADS_DIR);
const filesWithMetadata = files.filter((file) =>
PDF_METADATA_PATTERN.test(fs.readFileSync(file, "latin1")),
);
for (const file of filesWithMetadata) {
console.error(`[downloads:check-metadata] PDF metadata found: ${file}`);
}
if (filesWithMetadata.length > 0) {
process.exitCode = 1;
} else {
console.log(`[downloads:check-metadata] ${files.length} PDFs checked; no document metadata found.`);
}
+23
View File
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
function cleanDotfiles(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
cleanDotfiles(fullPath);
continue;
}
if (entry.isFile() && entry.name === ".DS_Store") {
fs.rmSync(fullPath);
}
}
}
if (fs.existsSync(PUBLIC_DIR)) {
cleanDotfiles(PUBLIC_DIR);
}
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
previous_image=""
app_container_id=""
wait_for_health() {
attempt=1
while [ "$attempt" -le 40 ]; do
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$app_container_id" 2>/dev/null || echo unknown)"
[ "$status" = healthy ] && return 0
[ "$status" = unhealthy ] && return 1
sleep 3
attempt=$((attempt + 1))
done
return 1
}
verify_routes() {
docker exec -i "$app_container_id" node --input-type=module - <<'NODE'
const request = (path, init) => fetch(`http://127.0.0.1:3000${path}`, { signal: AbortSignal.timeout(20000), ...init });
for (const [path, text] of [["/", "Bereit für Ihre neue Website"], ["/kalender/", "Kalender"], ["/robots.txt", "Sitemap:"]]) {
const response = await request(path);
if (!response.ok || !(await response.text()).includes(text)) throw new Error(`${path} failed`);
}
const events = await request("/api/events");
const etag = events.headers.get("etag");
if (!events.ok || !etag || !Array.isArray(await events.json())) throw new Error("events failed");
if ((await request("/api/events", { headers: { "If-None-Match": etag } })).status !== 304) throw new Error("ETag failed");
for (const path of ["/health/live", "/health/ready"]) if (!(await request(path)).ok) throw new Error(`${path} failed`);
NODE
}
current_id="$(docker compose ps -q app 2>/dev/null || true)"
if [ -n "$current_id" ]; then previous_image="$(docker inspect --format '{{.Config.Image}}' "$current_id" 2>/dev/null || true)"; fi
if ! docker compose up -d --no-build --remove-orphans app; then exit 1; fi
app_container_id="$(docker compose ps -q app)"
if ! wait_for_health || ! verify_routes; then
docker compose ps || true
docker compose logs app || true
if [ -n "$previous_image" ]; then APP_IMAGE_NAME="$previous_image" docker compose up -d --no-build --remove-orphans app; fi
exit 1
fi
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
set -eu
set_output() {
key="$1"
value="$2"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT"
fi
printf '%s=%s\n' "$key" "$value"
}
set_all() {
value="$1"
set_output app "$value"
set_output security_deps "$value"
set_output deploy "$value"
}
event_name="${GITHUB_EVENT_NAME:-}"
if [ "$event_name" = "schedule" ] || [ "$event_name" = "workflow_dispatch" ]; then
echo "Running all jobs for $event_name."
set_all true
exit 0
fi
head_sha="${GITHUB_SHA:-HEAD}"
base_sha="${CHANGESET_PR_BASE_SHA:-}"
before_sha="${CHANGESET_BEFORE:-}"
zero_sha="0000000000000000000000000000000000000000"
if [ -z "$base_sha" ] && [ -n "$before_sha" ] && [ "$before_sha" != "$zero_sha" ]; then
base_sha="$before_sha"
fi
if [ -z "$base_sha" ]; then
echo "No reliable base commit found; running all jobs."
set_all true
exit 0
fi
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git fetch --no-tags --depth=1 origin "$base_sha" || true
fi
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
echo "Base commit $base_sha is unavailable; running all jobs."
set_all true
exit 0
fi
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
if [ -z "$changed_files" ]; then
echo "No changed files detected."
set_all false
exit 0
fi
app=false
security_deps=false
deploy=false
echo "Changed files:"
printf '%s\n' "$changed_files"
for path in $changed_files; do
case "$path" in
*)
app=true
deploy=true
;;
esac
case "$path" in
.gitea/workflows/*|docker-compose*.yml|scripts/trivy-scan.sh|Dockerfile*|package.json|package-lock.json)
security_deps=true
;;
esac
done
set_output app "$app"
set_output security_deps "$security_deps"
set_output deploy "$deploy"
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
set -eu
require_value() {
key="$1"
value="$2"
if [ -z "$value" ]; then
echo "Missing required rollback configuration: $key" >&2
exit 1
fi
}
validate_tag() {
key="$1"
value="$2"
case "$value" in
[A-Za-z0-9_]* ) ;;
*)
echo "Invalid Docker image tag for $key" >&2
exit 1
;;
esac
case "$value" in
*[!A-Za-z0-9_.-]* )
echo "Invalid Docker image tag for $key" >&2
exit 1
;;
esac
if [ "${#value}" -gt 128 ]; then
echo "Docker image tag for $key exceeds 128 characters" >&2
exit 1
fi
}
require_value "DEPLOY_TARGET" "${DEPLOY_TARGET:-}"
require_value "CALENDAR_URL" "${CALENDAR_URL:-}"
require_value "PRIMARY_DOMAIN" "${PRIMARY_DOMAIN:-}"
require_value "TRAEFIK_CERTRESOLVER" "${TRAEFIK_CERTRESOLVER:-}"
require_value "REGISTRY" "${REGISTRY:-}"
require_value "REGISTRY_USERNAME" "${REGISTRY_USERNAME:-}"
require_value "REGISTRY_TOKEN" "${REGISTRY_TOKEN:-}"
case "$DEPLOY_TARGET" in
dev)
default_stack_name="website-starter-dev"
default_traefik_network="traefik-external"
;;
master|production)
default_stack_name="website-starter-prod"
default_traefik_network="traefik"
;;
*)
echo "Unsupported rollback target: $DEPLOY_TARGET" >&2
exit 1
;;
esac
require_value "APP_IMAGE_REPOSITORY" "${APP_IMAGE_REPOSITORY:-}"
require_value "APP_IMAGE_TAG" "${APP_IMAGE_TAG:-}"
validate_tag "APP_IMAGE_TAG" "$APP_IMAGE_TAG"
APP_IMAGE_NAME="$APP_IMAGE_REPOSITORY:$APP_IMAGE_TAG"
APP_URL="${APP_URL:-https://$PRIMARY_DOMAIN}"
CORS_ORIGIN="${CORS_ORIGIN:-$APP_URL}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$default_stack_name}"
TRAEFIK_STACK_NAME="${TRAEFIK_STACK_NAME:-$COMPOSE_PROJECT_NAME}"
TRAEFIK_NETWORK_NAME="${TRAEFIK_NETWORK_NAME:-$default_traefik_network}"
export CALENDAR_URL
export PRIMARY_DOMAIN
export APP_URL
export CORS_ORIGIN
export COMPOSE_PROJECT_NAME
export TRAEFIK_STACK_NAME
export TRAEFIK_CERTRESOLVER
export TRAEFIK_NETWORK_NAME
export APP_IMAGE_NAME
docker_config="$(mktemp -d)"
cleanup_registry_credentials() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup_registry_credentials EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker pull "$APP_IMAGE_NAME"
docker logout "$REGISTRY" >/dev/null
rm -rf "$docker_config"
trap - EXIT HUP INT TERM
unset DOCKER_CONFIG REGISTRY_USERNAME REGISTRY_TOKEN
echo "Rollback target: $DEPLOY_TARGET"
echo "Site host: $PRIMARY_DOMAIN"
echo "Compose project: $COMPOSE_PROJECT_NAME"
echo "App image: $APP_IMAGE_NAME"
sh scripts/deploy-and-verify.sh
+35
View File
@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const legacyRoot = path.join(ROOT, "assets");
const mediaRoot = path.join(ROOT, ".media-source");
const directories = [
"original-content",
"original-slider",
"original-secondary-slider",
"original-herosections",
];
fs.mkdirSync(mediaRoot, { recursive: true });
for (const directory of directories) {
const legacyDir = path.join(legacyRoot, directory);
const targetDir = path.join(mediaRoot, directory);
if (!fs.existsSync(legacyDir)) {
console.log(`[media:migrate] ${directory} bereits ausgelagert oder nicht vorhanden.`);
continue;
}
if (fs.existsSync(targetDir)) {
console.log(`[media:migrate] ${directory} bereits unter .media-source vorhanden, überspringe.`);
continue;
}
fs.renameSync(legacyDir, targetDir);
console.log(`[media:migrate] ${directory} → .media-source/${directory}`);
}
+171
View File
@@ -0,0 +1,171 @@
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const reportsRoot = path.join(root, "src", "trip-reports");
const registryPath = path.join(reportsRoot, "index.ts");
const imageSourceRoot = path.join(root, ".media-source", "original-content");
const supportedImageExtensions = new Set([".jpg", ".jpeg", ".png"]);
const prompt = readline.createInterface({ input, output });
const lines = prompt[Symbol.asyncIterator]();
function slugify(value) {
return value
.replace(/ß/g, "ss")
.replace(/[äÄ]/g, "ae")
.replace(/[öÖ]/g, "oe")
.replace(/[üÜ]/g, "ue")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function componentName(slug) {
const name = slug.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
return /^\d/.test(name) ? `Report${name}` : name;
}
async function ask(label, fallback = "") {
output.write(`${label}${fallback ? ` [${fallback}]` : ""}: `);
const answer = await lines.next();
if (answer.done) throw new Error("Eingabe wurde unerwartet beendet.");
return answer.value.trim() || fallback;
}
async function askRequired(label, fallback = "") {
while (true) {
const answer = await ask(label, fallback);
if (answer) return answer;
console.log("Bitte einen Wert eingeben.");
}
}
async function askBoolean(label, fallback) {
const answer = (await ask(`${label} (${fallback ? "J/n" : "j/N"})`)).toLowerCase();
if (!answer) return fallback;
if (["j", "ja", "y", "yes"].includes(answer)) return true;
if (["n", "nein", "no"].includes(answer)) return false;
throw new Error("Bitte mit ja oder nein antworten.");
}
function parseDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Datum muss YYYY-MM-DD entsprechen.");
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
throw new Error("Das Datum ist ungueltig.");
}
return date;
}
async function collectImages(year) {
const raw = await ask("Bildpfade, kommagetrennt (optional)");
if (!raw) return [];
const images = [];
for (const entry of raw.split(",").map((value) => value.trim()).filter(Boolean)) {
const sourcePath = path.resolve(process.cwd(), entry);
if (!fs.statSync(sourcePath, { throwIfNoEntry: false })?.isFile()) {
throw new Error(`Bild nicht gefunden: ${sourcePath}`);
}
const extension = path.extname(sourcePath).toLowerCase();
if (!supportedImageExtensions.has(extension)) throw new Error(`Nicht unterstuetztes Bildformat: ${sourcePath}`);
const fileName = path.basename(sourcePath);
const alt = await askRequired(`Alt-Text fuer ${fileName}`);
const caption = await ask(`Bildunterschrift fuer ${fileName}`, alt);
images.push({
sourcePath,
destinationPath: path.join(imageSourceRoot, String(year), fileName),
publicPath: `/images/content/${year}/${fileName}`,
alt,
caption,
});
}
return images;
}
function createReport({ slug, title, author, date, published, excerpt, images }) {
const name = componentName(slug);
const imageMarkup = images.map((image) =>
` <TripImage src=${JSON.stringify(image.publicPath)} alt=${JSON.stringify(image.alt)} caption=${JSON.stringify(image.caption)} />`,
);
const imports = images.length ? 'import TripImage from "@/components/TripImage";\n' : "";
const body = [" <p>Berichtstext hier schreiben.</p>", ...imageMarkup].join("\n");
return `${imports}import type { TripReportMetadata } from "./types";
export const metadata = {
slug: ${JSON.stringify(slug)},
title: ${JSON.stringify(title)},
author: ${JSON.stringify(author)},
createdAt: ${JSON.stringify(date.toISOString())},
updatedAt: ${JSON.stringify(date.toISOString())},
published: ${published},
excerpt: ${JSON.stringify(excerpt)},
plainText: ${JSON.stringify(excerpt)},
} satisfies TripReportMetadata;
export default function ${name}() {
return (
<>
${body}
</>
);
}
`;
}
function registerReport(registry, slug) {
const name = componentName(slug);
const importMarker = 'import type { TripReportModule } from "./types";';
const listMarker = "const tripReports: readonly TripReportModule[] = [";
if (!registry.includes(importMarker) || !registry.includes(listMarker)) {
throw new Error("Die Beitrags-Registry hat ein unbekanntes Format.");
}
const importLine = `import ${name}, { metadata as ${name.charAt(0).toLowerCase() + name.slice(1)}Metadata } from "./${slug}";\n`;
const entry = `\n { metadata: ${name.charAt(0).toLowerCase() + name.slice(1)}Metadata, Component: ${name} },`;
return registry
.replace(importMarker, `${importLine}${importMarker}`)
.replace(listMarker, `${listMarker}${entry}`);
}
async function main() {
console.log("Neuen TSX-Beitrag anlegen\n");
const title = await askRequired("Titel");
const author = await askRequired("Autor");
const date = parseDate(await ask("Berichtsdatum (YYYY-MM-DD)", new Date().toISOString().slice(0, 10)));
const year = date.getUTCFullYear();
const suggestedSlug = `${slugify(title)}-${year}`.replace(new RegExp(`-${year}-${year}$`), `-${year}`);
const slug = await askRequired("Slug", suggestedSlug);
if (slugify(slug) !== slug) throw new Error(`Ungueltiger Slug. Vorschlag: ${slugify(slug)}`);
const excerpt = await askRequired("Kurzbeschreibung fuer Uebersicht und SEO");
const published = await askBoolean("Direkt veroeffentlichen?", true);
const images = await collectImages(year);
const reportPath = path.join(reportsRoot, `${slug}.tsx`);
if (fs.existsSync(reportPath)) throw new Error(`Beitrag existiert bereits: ${reportPath}`);
for (const image of images) {
if (fs.existsSync(image.destinationPath)) throw new Error(`Zielbild existiert bereits: ${image.destinationPath}`);
}
const registry = fs.readFileSync(registryPath, "utf8");
const nextRegistry = registerReport(registry, slug);
fs.mkdirSync(path.join(imageSourceRoot, String(year)), { recursive: true });
for (const image of images) fs.copyFileSync(image.sourcePath, image.destinationPath, fs.constants.COPYFILE_EXCL);
fs.writeFileSync(reportPath, createReport({ slug, title, author, date, published, excerpt, images }), { flag: "wx" });
fs.writeFileSync(registryPath, nextRegistry);
console.log(`\nBeitrag angelegt: ${path.relative(process.cwd(), reportPath)}`);
console.log("Naechster Schritt: Berichtstext in der TSX-Datei ausarbeiten.");
}
try {
await main();
} catch (error) {
console.error(`\nFehler: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
} finally {
prompt.close();
}
+641
View File
@@ -0,0 +1,641 @@
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const IMAGE_CACHE_ROOT = process.env.IMAGE_CACHE_ROOT?.trim();
const CACHE_ROOT = IMAGE_CACHE_ROOT
? path.resolve(IMAGE_CACHE_ROOT, 'manifests')
: path.join(ROOT, '.cache', 'image-manifests');
const CACHED_ORIGINALS_ROOT = IMAGE_CACHE_ROOT
? path.resolve(IMAGE_CACHE_ROOT, 'originals')
: null;
const CACHED_OUTPUTS_ROOT = IMAGE_CACHE_ROOT
? path.resolve(IMAGE_CACHE_ROOT, 'optimized')
: null;
const IMAGE_PIPELINE_VERSION = 1;
const PUBLIC_IMAGES_ROOT = path.join(ROOT, 'public/images');
const DEFAULT_MEDIA_ROOT = path.join(ROOT, '.media-source');
const LEGACY_MEDIA_ROOT = path.join(ROOT, 'assets');
// Optional project-specific alt texts keyed by source image name.
const altTexts = {};
const quality = { jpeg: 85, webp: 80, avif: 50, png: 9 };
const supportedExts = new Set(['.jpg', '.jpeg', '.png']);
const SHOULD_PRUNE = process.argv.includes('--prune');
const REQUIRE_IMAGE_SOURCES = process.env.IMAGES_SOURCE_REQUIRED === 'true';
function resolveSourceDir(relativePath) {
const envRoot = process.env.MEDIA_SOURCE_ROOT?.trim();
const candidates = [
envRoot ? path.resolve(ROOT, envRoot, relativePath) : null,
path.join(DEFAULT_MEDIA_ROOT, relativePath),
path.join(LEGACY_MEDIA_ROOT, relativePath),
].filter(Boolean);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return path.join(DEFAULT_MEDIA_ROOT, relativePath);
}
/**
* Pipeline-Konfigurationen
*/
const pipelines = [
{
name: 'content',
sourceDir: resolveSourceDir('original-content'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'content'),
outputRelativeDir: 'content',
recursive: true,
sizes: [
{ suffix: '-sm', width: 800, height: 600 },
{ suffix: '-md', width: 1000, height: 750 },
],
formats: ['original', 'webp', 'avif'],
deleteOriginal: false,
generateJson: false,
},
{
name: 'slider',
sourceDir: resolveSourceDir('original-slider'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'slider'),
outputRelativeDir: 'slider',
recursive: false,
sizes: [
{ suffix: '-sm', width: 640, height: 480 },
{ suffix: '-md', width: 1280, height: 960 },
{ suffix: '', width: 1920, height: 1440 },
],
formats: ['jpg', 'webp', 'avif'],
deleteOriginal: false,
generateJson: true,
jsonFileName: 'slider-images.json',
},
{
name: 'secondary-slider',
sourceDir: resolveSourceDir('original-secondary-slider'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'secondary-slider'),
outputRelativeDir: 'secondary-slider',
recursive: false,
sizes: [
{ suffix: '-sm', width: 640, height: 480 },
{ suffix: '-md', width: 1280, height: 960 },
{ suffix: '', width: 1920, height: 1440 },
],
formats: ['jpg', 'webp', 'avif'],
deleteOriginal: false,
generateJson: true,
jsonFileName: 'slider-images.json',
},
{
name: 'herosections',
sourceDir: resolveSourceDir('original-herosections'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'backgrounds'),
outputRelativeDir: 'backgrounds',
recursive: false,
sizes: [
{ suffix: '', width: 1920, height: 1440 },
{ suffix: '-mobile', width: 1024, height: 768 },
],
formats: ['jpg', 'webp', 'avif'],
deleteOriginal: false,
generateJson: false,
},
];
function isVariantFile(fileName, sizeSuffixes) {
const ext = path.extname(fileName).toLowerCase();
const base = path.basename(fileName, path.extname(fileName)).toLowerCase();
if (ext === '.webp' || ext === '.avif') return true;
if (base.endsWith('-temp')) return true;
return sizeSuffixes.some((s) => s && base.endsWith(s));
}
function collectImages(dirPath, recursive, sizeSuffixes) {
const collected = [];
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory() && recursive) {
collected.push(...collectImages(fullPath, true, sizeSuffixes));
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!supportedExts.has(ext)) continue;
if (isVariantFile(entry.name, sizeSuffixes)) continue;
collected.push(fullPath);
}
return collected;
}
function collectBaseImageRecords(dirPath, recursive, sizeSuffixes) {
if (!fs.existsSync(dirPath)) return [];
return collectImages(dirPath, recursive, sizeSuffixes)
.map((filePath) => ({
name: path.basename(filePath, path.extname(filePath)),
ext: path.extname(filePath),
relDir: path.relative(dirPath, path.dirname(filePath)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
function collectOutputFileNames(dirPath) {
if (!fs.existsSync(dirPath)) return [];
return fs.readdirSync(dirPath, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
}
function readJsonManifest(manifestPath) {
try {
if (!fs.existsSync(manifestPath)) return [];
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function getBaseOutputRecords(outputFileNames, sizeSuffixes) {
return outputFileNames
.filter((fileName) => {
const ext = path.extname(fileName).toLowerCase();
return supportedExts.has(ext) && !isVariantFile(fileName, sizeSuffixes);
})
.map((fileName) => ({
name: path.basename(fileName, path.extname(fileName)),
ext: path.extname(fileName),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
function manifestKeyForRecord(record) {
return record.relDir ? `${record.relDir}/${record.name}` : record.name;
}
function stripGeneratedSuffix(baseName, sizeSuffixes) {
const sortedSuffixes = [...sizeSuffixes]
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const suffix of sortedSuffixes) {
if (baseName.endsWith(suffix)) {
return baseName.slice(0, -suffix.length);
}
}
return baseName;
}
function collectGeneratedOutputFiles(dirPath, rootDir = dirPath) {
if (!fs.existsSync(dirPath)) return [];
const files = [];
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...collectGeneratedOutputFiles(fullPath, rootDir));
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!new Set([...supportedExts, '.webp', '.avif']).has(ext)) continue;
files.push({
path: fullPath,
fileName: entry.name,
relDir: path.relative(rootDir, path.dirname(fullPath)),
});
}
return files;
}
function removeEmptyDirs(dirPath, rootDir) {
if (!fs.existsSync(dirPath) || dirPath === rootDir) return;
if (fs.readdirSync(dirPath).length > 0) return;
fs.rmdirSync(dirPath);
removeEmptyDirs(path.dirname(dirPath), rootDir);
}
function pruneGeneratedAssets(config, sourceRecords, sizeSuffixes) {
const sourceKeys = new Set(sourceRecords.map(manifestKeyForRecord));
let removed = 0;
for (const outputFile of collectGeneratedOutputFiles(config.outputDir)) {
const baseName = stripGeneratedSuffix(
path.basename(outputFile.fileName, path.extname(outputFile.fileName)),
sizeSuffixes,
);
const key = outputFile.relDir ? `${outputFile.relDir}/${baseName}` : baseName;
if (sourceKeys.has(key)) continue;
fs.rmSync(outputFile.path);
removeEmptyDirs(path.dirname(outputFile.path), config.outputDir);
removed++;
}
if (removed > 0) {
console.log(`[${config.name}] ${removed} verwaiste Bilddatei(en) entfernt`);
}
}
export function buildImageManifestEntries({
existingManifest = [],
outputFileNames = [],
sourceRecords = [],
sizeSuffixes = [],
altTextsByName = altTexts,
sourceIsComplete = false,
} = {}) {
const outputRecords = getBaseOutputRecords(outputFileNames, sizeSuffixes);
const outputNames = new Set(outputRecords.map(manifestKeyForRecord));
const sourceNames = new Set(sourceRecords.map(manifestKeyForRecord));
const sourceByName = new Map(sourceRecords.map((record) => [manifestKeyForRecord(record), record]));
const outputByName = new Map(outputRecords.map((record) => [manifestKeyForRecord(record), record]));
const webpNames = new Set(outputFileNames
.filter((fileName) => path.extname(fileName).toLowerCase() === '.webp')
.map((fileName) => path.basename(fileName, path.extname(fileName)).replace(/-(sm|md|mobile)$/i, '')));
const avifNames = new Set(outputFileNames
.filter((fileName) => path.extname(fileName).toLowerCase() === '.avif')
.map((fileName) => path.basename(fileName, path.extname(fileName)).replace(/-(sm|md|mobile)$/i, '')));
const entries = new Map();
const addEntry = (record, existing = {}) => {
if (!record?.name || !record?.ext) return;
const key = manifestKeyForRecord(record);
entries.set(key, {
name: record.name,
ext: record.ext,
alt: existing.alt ?? altTextsByName[record.name],
webp: webpNames.has(record.name),
avif: avifNames.has(record.name),
});
};
for (const item of existingManifest) {
if (!item?.name) continue;
const key = manifestKeyForRecord(item);
if (sourceIsComplete && !sourceNames.has(key)) continue;
if (!sourceIsComplete && !outputNames.has(key) && !sourceNames.has(key)) continue;
const record = sourceByName.get(key) ?? outputByName.get(key) ?? item;
addEntry(record, item);
}
const defaultRecords = sourceIsComplete || existingManifest.length > 0 ? sourceRecords : outputRecords;
for (const record of defaultRecords) {
if (!entries.has(manifestKeyForRecord(record))) {
addEntry(record);
}
}
return [...entries.values()]
.filter((entry) => entry.name && entry.ext)
.sort((a, b) => a.name.localeCompare(b.name));
}
async function collectOutputMetadata(outputDir, outputFileNames) {
const metadata = new Map();
await Promise.all(outputFileNames.map(async (fileName) => {
const ext = path.extname(fileName).toLowerCase();
if (![...supportedExts, '.webp', '.avif'].includes(ext)) return;
const dimensions = await sharp(path.join(outputDir, fileName)).metadata();
if (!dimensions.width || !dimensions.height) return;
metadata.set(fileName, {
width: dimensions.width,
height: dimensions.height,
});
}));
return metadata;
}
export function addImageManifestMetadata({
entries = [],
outputMetadata = new Map(),
sizes = [],
fallbackExtension = '.jpg',
} = {}) {
return entries.flatMap((entry) => {
const variantsForExtension = (extension) => sizes.flatMap(({ suffix }) => {
const file = `${entry.name}${suffix}${extension}`;
const dimensions = outputMetadata.get(file);
return dimensions ? [{ file, ...dimensions }] : [];
});
const fallback = variantsForExtension(fallbackExtension);
const base = fallback.find((variant) => variant.file === `${entry.name}${fallbackExtension}`)
?? fallback.at(-1);
if (!base) return [];
return [{
...entry,
width: base.width,
height: base.height,
variants: {
fallback,
webp: variantsForExtension('.webp'),
avif: variantsForExtension('.avif'),
},
}];
});
}
async function writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete) {
const jsonName = config.jsonFileName || 'images.json';
const jsonPath = path.join(outDir, jsonName);
const outputFileNames = collectOutputFileNames(outDir);
const entries = buildImageManifestEntries({
existingManifest: readJsonManifest(jsonPath),
outputFileNames,
sourceRecords,
sizeSuffixes,
sourceIsComplete,
});
const fallbackExtension = config.formats.includes('original')
? entries[0]?.ext
: '.jpg';
const outputMetadata = await collectOutputMetadata(outDir, outputFileNames);
const imagesList = addImageManifestMetadata({
entries,
outputMetadata,
sizes: config.sizes,
fallbackExtension,
});
fs.writeFileSync(jsonPath, JSON.stringify(imagesList, null, 2));
}
function fileHash(filePath) {
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
}
function getEncoder(extLower) {
if (extLower === '.png') {
return (pipeline) => pipeline.png({ compressionLevel: quality.png, adaptiveFiltering: true });
}
return (pipeline) => pipeline.jpeg({ quality: quality.jpeg, progressive: true, mozjpeg: true });
}
function getFormatPriority(format) {
if (format === 'original' || format === 'jpg') return 0;
if (format === 'webp') return 1;
if (format === 'avif') return 2;
return 3;
}
function replaceFile(fromPath, toPath) {
if (fs.existsSync(toPath)) {
fs.rmSync(toPath);
}
fs.renameSync(fromPath, toPath);
}
function copyFileWithParents(sourcePath, destinationPath) {
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFileSync(sourcePath, destinationPath);
}
function pruneCachedOriginals(config, sourceRecords) {
if (!CACHED_ORIGINALS_ROOT) return;
const cachedDir = path.join(CACHED_ORIGINALS_ROOT, config.name);
const sourceKeys = new Set(sourceRecords.map(manifestKeyForRecord));
for (const cachedFile of collectGeneratedOutputFiles(cachedDir)) {
const baseName = path.basename(cachedFile.fileName, path.extname(cachedFile.fileName));
const key = cachedFile.relDir ? `${cachedFile.relDir}/${baseName}` : baseName;
if (sourceKeys.has(key)) continue;
fs.rmSync(cachedFile.path);
removeEmptyDirs(path.dirname(cachedFile.path), cachedDir);
}
}
function mirrorCachedOutput(config, outDir) {
if (!CACHED_OUTPUTS_ROOT) return;
// These directories contain only generated variants. Replacing them makes a
// removed Git source disappear from the image that is being built as well.
fs.rmSync(config.outputDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(config.outputDir), { recursive: true });
fs.cpSync(outDir, config.outputDir, { recursive: true });
}
function hasFallbackVariants(config, fileOutDir, baseName, ext) {
const fallbackFormat = config.formats.includes('original') ? 'original' : 'jpg';
const fallbackExt = fallbackFormat === 'original' ? ext : '.jpg';
return config.sizes.every((size) =>
fs.existsSync(path.join(fileOutDir, `${baseName}${size.suffix}${fallbackExt}`))
);
}
async function runPipeline(config) {
if (!fs.existsSync(config.sourceDir)) {
const outDir = config.outputDir ?? config.sourceDir;
const hasGeneratedAssets = fs.existsSync(outDir);
if (REQUIRE_IMAGE_SOURCES || !hasGeneratedAssets) {
throw new Error(`[${config.name}] Quellverzeichnis fehlt: ${config.sourceDir}`);
}
console.warn(
`[${config.name}] Quellverzeichnis fehlt (${config.sourceDir}). ` +
`Verwende vorhandene optimierte Assets in ${outDir}.`
);
return;
}
const outDir = CACHED_OUTPUTS_ROOT
? path.join(CACHED_OUTPUTS_ROOT, config.outputRelativeDir)
: (config.outputDir ?? config.sourceDir);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
if (!fs.existsSync(CACHE_ROOT)) {
fs.mkdirSync(CACHE_ROOT, { recursive: true });
}
// Manifest for hash caching outside the public asset tree
const manifestPath = path.join(CACHE_ROOT, `${config.name}.json`);
let manifest = {};
try {
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
} catch { manifest = {}; }
const sizeSuffixes = config.sizes.map((s) => s.suffix);
const files = collectImages(config.sourceDir, config.recursive, sizeSuffixes);
const sourceRecords = collectBaseImageRecords(config.sourceDir, config.recursive, sizeSuffixes);
const sourceIsComplete = fs.existsSync(config.sourceDir);
const outputConfig = { ...config, outputDir: outDir };
let manifestChanged = false;
if (SHOULD_PRUNE && sourceIsComplete) {
pruneGeneratedAssets(outputConfig, sourceRecords, sizeSuffixes);
pruneCachedOriginals(config, sourceRecords);
for (const key of Object.keys(manifest)) {
if (!sourceRecords.some((record) => manifestKeyForRecord(record) === key)) {
delete manifest[key];
manifestChanged = true;
}
}
}
if (files.length === 0) {
console.log(`[${config.name}] Keine Bilder gefunden.`);
if (config.generateJson) {
await writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete);
}
if (manifestChanged) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
mirrorCachedOutput(config, outDir);
return;
}
console.log(`[${config.name}] ${files.length} Bilder gefunden`);
let processed = 0;
for (const inputPath of files) {
const file = path.basename(inputPath);
const ext = path.extname(file);
const extLower = ext.toLowerCase();
const baseName = path.basename(file, ext);
const relDir = path.relative(config.sourceDir, path.dirname(inputPath));
const fileOutDir = relDir ? path.join(outDir, relDir) : outDir;
const hash = fileHash(inputPath);
const manifestKey = relDir ? `${relDir}/${baseName}` : baseName;
if (CACHED_ORIGINALS_ROOT) {
const cachedOriginalPath = path.join(CACHED_ORIGINALS_ROOT, config.name, relDir, file);
if (manifest[manifestKey]?.hash !== hash || !fs.existsSync(cachedOriginalPath)) {
copyFileWithParents(inputPath, cachedOriginalPath);
}
}
if (
manifest[manifestKey]?.hash === hash &&
manifest[manifestKey]?.optimized &&
manifest[manifestKey]?.version === IMAGE_PIPELINE_VERSION &&
hasFallbackVariants(config, fileOutDir, baseName, ext)
) {
continue;
}
if (!fs.existsSync(fileOutDir)) {
fs.mkdirSync(fileOutDir, { recursive: true });
}
let totalVariantSize = 0;
const originalSize = fs.statSync(inputPath).size;
const orderedFormats = [...config.formats].sort((left, right) => getFormatPriority(left) - getFormatPriority(right));
for (const size of config.sizes) {
const resizeOpts = { width: size.width, height: size.height, fit: 'inside', withoutEnlargement: true };
let fallbackVariantSize = Number.POSITIVE_INFINITY;
for (const format of orderedFormats) {
let outputPath;
let pipeline = sharp(inputPath).rotate().resize(resizeOpts);
if (format === 'original') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}${ext}`);
pipeline = getEncoder(extLower)(pipeline);
} else if (format === 'jpg') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.jpg`);
pipeline = pipeline.jpeg({ quality: quality.jpeg, progressive: true, mozjpeg: true });
} else if (format === 'webp') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.webp`);
pipeline = pipeline.webp({ quality: quality.webp });
} else if (format === 'avif') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.avif`);
pipeline = pipeline.avif({ quality: quality.avif });
}
const tempPath = `${outputPath}.tmp`;
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath);
}
await pipeline.toFile(tempPath);
const candidateSize = fs.statSync(tempPath).size;
const existingSize = fs.existsSync(outputPath) ? fs.statSync(outputPath).size : null;
const isDerivedVariant = format === 'webp' || format === 'avif';
if (isDerivedVariant && Number.isFinite(fallbackVariantSize) && candidateSize >= fallbackVariantSize) {
fs.rmSync(tempPath);
if (existingSize !== null && existingSize < fallbackVariantSize) {
totalVariantSize += existingSize;
} else if (existingSize !== null && SHOULD_PRUNE) {
fs.rmSync(outputPath);
}
continue;
}
if (existingSize !== null && existingSize <= candidateSize) {
fs.rmSync(tempPath);
totalVariantSize += existingSize;
if (format === 'original' || format === 'jpg') {
fallbackVariantSize = existingSize;
}
continue;
}
replaceFile(tempPath, outputPath);
totalVariantSize += candidateSize;
if (format === 'original' || format === 'jpg') {
fallbackVariantSize = candidateSize;
}
}
}
manifest[manifestKey] = {
hash,
optimized: true,
originalSize,
version: IMAGE_PIPELINE_VERSION,
timestamp: new Date().toISOString(),
};
manifestChanged = true;
if (config.deleteOriginal) {
fs.rmSync(inputPath);
}
if (totalVariantSize < originalSize) {
const saving = (((originalSize - totalVariantSize) / originalSize) * 100).toFixed(1);
console.log(` ${file}${saving}% Ersparnis`);
} else {
console.log(` ${file} → Varianten aktualisiert`);
}
processed++;
}
// Generate JSON manifest if needed
if (config.generateJson) {
await writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete);
}
if (manifestChanged) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
mirrorCachedOutput(config, outDir);
if (processed > 0) {
console.log(`[${config.name}] ${processed} Bild(er) optimiert`);
} else {
console.log(`[${config.name}] Alle Bilder aktuell`);
}
}
// Run all pipelines
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
for (const pipeline of pipelines) {
await runPipeline(pipeline);
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
# The runner's Docker volume survives individual checkouts. It contains a
# copy of the Git originals, optimized variants, and the hash manifests used
# by optimize-images.mjs.
cache_namespace=$(printf '%s' "${IMAGE_CACHE_NAMESPACE:-shared}" | tr -c 'A-Za-z0-9_.-' '-')
case "$cache_namespace" in
''|*[!A-Za-z0-9_.-]*)
echo "IMAGE_CACHE_NAMESPACE may only contain letters, digits, ., _ and -" >&2
exit 1
;;
esac
cache_volume="website-starter-image-cache-${cache_namespace}"
docker volume create "$cache_volume" >/dev/null
app_dir="$(pwd)"
run_image_prune() {
docker run --rm "$@" \
--mount "type=volume,src=$cache_volume,dst=/image-cache" \
--workdir "$app_dir" \
--env IMAGE_CACHE_ROOT=/image-cache \
--env HOST_UID="$(id -u)" \
--env HOST_GID="$(id -g)" \
node:24-slim \
sh -ec '
npm run images:prune
chown -R "$HOST_UID:$HOST_GID" /image-cache "$PWD/public/images/content" "$PWD/public/images/slider" "$PWD/public/images/secondary-slider" "$PWD/public/images/backgrounds"
'
}
# In Gitea Actions, the Docker daemon cannot bind-mount the job container's
# workspace path. Share its existing workspace volume instead.
if docker inspect "$(hostname)" >/dev/null 2>&1; then
run_image_prune --volumes-from "$(hostname)"
else
run_image_prune --mount "type=bind,src=$app_dir,dst=$app_dir"
fi
+86
View File
@@ -0,0 +1,86 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "..");
const appPort = Number(process.env.E2E_PORT ?? 3100);
const fixturePort = Number(process.env.FIXTURE_PORT ?? 3101);
const baseUrl = `http://127.0.0.1:${appPort}`;
const children = [];
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: root, env: process.env, stdio: "inherit", ...options });
child.once("error", reject);
child.once("exit", (code) => code === 0
? resolve()
: reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`)));
});
}
function start(command, args, env = {}) {
const child = spawn(command, args, {
cwd: root,
env: { ...process.env, ...env },
stdio: "inherit",
});
children.push(child);
return child;
}
async function waitFor(url) {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// The process is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${url}`);
}
async function stopChildren() {
await Promise.all(children.map((child) => new Promise((resolve) => {
if (child.exitCode !== null) return resolve();
child.once("exit", resolve);
child.kill("SIGTERM");
setTimeout(() => {
if (child.exitCode === null) child.kill("SIGKILL");
}, 3_000).unref();
})));
}
async function prepareStandaloneServer() {
const standaloneRoot = path.join(root, ".next", "standalone");
await fs.rm(path.join(standaloneRoot, "public"), { recursive: true, force: true });
await fs.rm(path.join(standaloneRoot, ".next", "static"), { recursive: true, force: true });
await fs.cp(path.join(root, "public"), path.join(standaloneRoot, "public"), { recursive: true });
await fs.cp(
path.join(root, ".next", "static"),
path.join(standaloneRoot, ".next", "static"),
{ recursive: true },
);
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
try {
await run(npm, ["run", "build"]);
await prepareStandaloneServer();
start(process.execPath, ["e2e/fixture-server.mjs"], { FIXTURE_PORT: String(fixturePort) });
start(process.execPath, [".next/standalone/server.js"], {
CALENDAR_URL: `http://127.0.0.1:${fixturePort}/calendar.ics`,
APP_URL: baseUrl,
CORS_ORIGIN: baseUrl,
PORT: String(appPort),
HOSTNAME: "127.0.0.1",
});
await waitFor(`${baseUrl}/api/events`);
await run(process.execPath, ["--test", "e2e/smoke.e2e.mjs"], {
env: { ...process.env, E2E_BASE_URL: baseUrl },
});
} finally {
await stopChildren();
}
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
set -eu
require_value() {
key="$1"
value="$2"
if [ -z "$value" ]; then
echo "Missing required workflow configuration: $key" >&2
exit 1
fi
}
require_value "DEPLOY_TARGET" "${DEPLOY_TARGET:-}"
require_value "CALENDAR_URL" "${CALENDAR_URL:-}"
require_value "PRIMARY_DOMAIN" "${PRIMARY_DOMAIN:-}"
require_value "TRAEFIK_CERTRESOLVER" "${TRAEFIK_CERTRESOLVER:-}"
require_value "REGISTRY" "${REGISTRY:-}"
require_value "APP_IMAGE_REPOSITORY" "${APP_IMAGE_REPOSITORY:-}"
require_value "REGISTRY_USERNAME" "${REGISTRY_USERNAME:-}"
require_value "REGISTRY_TOKEN" "${REGISTRY_TOKEN:-}"
APP_URL="${APP_URL:-https://$PRIMARY_DOMAIN}"
CORS_ORIGIN="${CORS_ORIGIN:-$APP_URL}"
case "$DEPLOY_TARGET" in
dev)
default_stack_name="website-starter-dev"
default_traefik_network="traefik-external"
;;
master|production)
default_stack_name="website-starter-prod"
default_traefik_network="traefik"
;;
*)
echo "Unsupported deployment target: $DEPLOY_TARGET" >&2
exit 1
;;
esac
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$default_stack_name}"
TRAEFIK_STACK_NAME="${TRAEFIK_STACK_NAME:-$COMPOSE_PROJECT_NAME}"
TRAEFIK_NETWORK_NAME="${TRAEFIK_NETWORK_NAME:-$default_traefik_network}"
TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969}"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}"
revision="$(git rev-parse --short=12 HEAD)"
build_timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
APP_IMAGE_TAG="scheduled-$DEPLOY_TARGET-$revision-$build_timestamp"
APP_IMAGE_NAME="$APP_IMAGE_REPOSITORY:$APP_IMAGE_TAG"
export CALENDAR_URL
export PRIMARY_DOMAIN
export APP_URL
export CORS_ORIGIN
export COMPOSE_PROJECT_NAME
export TRAEFIK_STACK_NAME
export TRAEFIK_CERTRESOLVER
export TRAEFIK_NETWORK_NAME
export APP_IMAGE_NAME
export TRIVY_IMAGE
export DOCKER_BUILDKIT
export DEPLOY_MODE=app
echo "Scheduled rebuild target: $DEPLOY_TARGET"
echo "Git revision: $revision"
echo "App image: $APP_IMAGE_NAME"
docker build --pull \
--build-arg BUILD_DATE="$BUILD_DATE" \
--build-arg VCS_REF="$revision" \
--build-arg APP_URL="$APP_URL" \
--build-arg APP_NAME="${APP_NAME:-Example Website}" \
--build-arg COMPANY_NAME="${COMPANY_NAME:-Example Company}" \
--build-arg CONTACT_EMAIL="${CONTACT_EMAIL:-info@example.com}" \
--build-arg CONTACT_PHONE="${CONTACT_PHONE:-+49 000 000000}" \
--build-arg CONTACT_ADDRESS="${CONTACT_ADDRESS:-Example Street 1, 12345 Example City}" \
-f Dockerfile \
-t "$APP_IMAGE_NAME" \
.
echo "Reporting app runtime image vulnerabilities..."
sh scripts/trivy-scan.sh vuln-image "$APP_IMAGE_NAME"
echo "Blocking app critical runtime vulnerabilities..."
TRIVY_SEVERITY=CRITICAL TRIVY_EXIT_CODE=1 sh scripts/trivy-scan.sh vuln-image "$APP_IMAGE_NAME"
docker_config="$(mktemp -d)"
cleanup_registry_credentials() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup_registry_credentials EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker push "$APP_IMAGE_NAME"
docker logout "$REGISTRY" >/dev/null
rm -rf "$docker_config"
trap - EXIT HUP INT TERM
unset DOCKER_CONFIG REGISTRY_USERNAME REGISTRY_TOKEN
sh scripts/deploy-and-verify.sh
+83
View File
@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
const PUBLIC_IMAGES_DIR = path.resolve(import.meta.dirname, "../public/images");
const SUPPORTED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
const METADATA_FIELDS = ["exif", "icc", "iptc", "xmp"];
const CHECK_ONLY = process.argv.includes("--check");
function collectImages(directory) {
if (!fs.existsSync(directory)) return [];
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...collectImages(fullPath));
continue;
}
if (entry.isFile() && SUPPORTED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
files.push(fullPath);
}
}
return files;
}
function getEncoder(pipeline, extension) {
if (extension === ".png") {
return pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
}
if (extension === ".webp") {
return pipeline.webp({ quality: 80 });
}
if (extension === ".avif") {
return pipeline.avif({ quality: 50 });
}
return pipeline.jpeg({ quality: 85, progressive: true, mozjpeg: true });
}
async function hasMetadata(filePath) {
const metadata = await sharp(filePath).metadata();
return METADATA_FIELDS.some((field) => Boolean(metadata[field]));
}
async function stripMetadata(filePath) {
const extension = path.extname(filePath).toLowerCase();
const tempPath = `${filePath}.tmp`;
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath);
}
const pipeline = sharp(filePath).rotate();
await getEncoder(pipeline, extension).toFile(tempPath);
fs.renameSync(tempPath, filePath);
}
const files = collectImages(PUBLIC_IMAGES_DIR);
const filesWithMetadata = [];
for (const file of files) {
if (await hasMetadata(file)) {
filesWithMetadata.push(file);
}
}
if (CHECK_ONLY) {
for (const file of filesWithMetadata) {
console.error(`[images:check-metadata] Metadata found: ${file}`);
}
if (filesWithMetadata.length > 0) {
process.exitCode = 1;
} else {
console.log(`[images:check-metadata] ${files.length} images checked; no metadata blocks found.`);
}
} else {
for (const file of filesWithMetadata) {
await stripMetadata(file);
}
console.log(
`[images:strip-metadata] ${filesWithMetadata.length} of ${files.length} images rewritten.`,
);
}
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
set -eu
TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969}"
TRIVY_CACHE_DIR="${TRIVY_CACHE_DIR:-.cache/trivy}"
TRIVY_SEVERITY="${TRIVY_SEVERITY:-HIGH,CRITICAL}"
TRIVY_EXIT_CODE="${TRIVY_EXIT_CODE:-0}"
TRIVY_IGNORE_UNFIXED="${TRIVY_IGNORE_UNFIXED:-true}"
TRIVY_IMAGE_SOURCE="${TRIVY_IMAGE_SOURCE:-docker}"
usage() {
echo "Usage: $0 secret-fs|vuln-fs|vuln-image <target>" >&2
}
if [ "$#" -ne 2 ]; then
usage
exit 2
fi
mode="$1"
target="$2"
mkdir -p "$TRIVY_CACHE_DIR"
common_args="--cache-dir /trivy-cache"
ignore_unfixed_arg=""
if [ "$TRIVY_IGNORE_UNFIXED" = "true" ]; then
ignore_unfixed_arg="--ignore-unfixed"
fi
case "$mode" in
secret-fs)
container_id="$(docker create \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
fs \
$common_args \
--scanners secret \
--exit-code 1 \
--skip-dirs /work/.git \
--skip-dirs /work/.agents \
--skip-dirs /work/.cache \
--skip-dirs /work/.codex \
--skip-dirs /work/.opencode \
--skip-dirs /work/.venv \
--skip-dirs /work/frontend \
--skip-dirs /work/node_modules \
--skip-dirs /work/.next \
/work)"
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker cp "$target" "$container_id:/work"
docker start -a "$container_id"
;;
vuln-fs)
container_id="$(docker create \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
fs \
$common_args \
--scanners vuln \
--severity "$TRIVY_SEVERITY" \
$ignore_unfixed_arg \
--exit-code "$TRIVY_EXIT_CODE" \
--skip-dirs /work/.git \
--skip-dirs /work/.agents \
--skip-dirs /work/.cache \
--skip-dirs /work/.codex \
--skip-dirs /work/.opencode \
--skip-dirs /work/.venv \
--skip-dirs /work/frontend \
--skip-dirs /work/node_modules \
--skip-dirs /work/.next \
"/work/$target")"
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker cp . "$container_id:/work"
docker start -a "$container_id"
;;
vuln-image)
case "$TRIVY_IMAGE_SOURCE" in
docker)
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
image \
$common_args \
--scanners vuln \
--severity "$TRIVY_SEVERITY" \
$ignore_unfixed_arg \
--exit-code "$TRIVY_EXIT_CODE" \
"$target"
;;
remote)
docker_config_dir="${DOCKER_CONFIG:-$HOME/.docker}"
if [ ! -f "$docker_config_dir/config.json" ]; then
echo "Docker registry credentials not found: $docker_config_dir/config.json" >&2
exit 1
fi
docker run --rm \
-e DOCKER_CONFIG=/root/.docker \
-v "$docker_config_dir/config.json:/root/.docker/config.json:ro" \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
image \
$common_args \
--image-src remote \
--scanners vuln \
--severity "$TRIVY_SEVERITY" \
$ignore_unfixed_arg \
--exit-code "$TRIVY_EXIT_CODE" \
"$target"
;;
*)
echo "Unsupported TRIVY_IMAGE_SOURCE: $TRIVY_IMAGE_SOURCE (expected docker or remote)" >&2
exit 2
;;
esac
;;
*)
usage
exit 2
;;
esac