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
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:
@@ -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.`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Executable
+43
@@ -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
|
||||
Executable
+83
@@ -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"
|
||||
Executable
+100
@@ -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
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+40
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
Executable
+123
@@ -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
|
||||
Reference in New Issue
Block a user