Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d6a1c5de | ||
|
|
d678d80116 | ||
|
|
bce973679b | ||
|
|
45f705a104 | ||
|
|
c4f17d5f78 | ||
|
|
247f6ea272 | ||
|
|
ebcfec4ec7 | ||
|
|
e22ccfeda3 | ||
|
|
3729639596 | ||
|
|
4887f599fd | ||
|
|
69db8de6d7 | ||
|
|
7bb5f339c0 | ||
|
|
5982a1bfd1 | ||
|
|
f46a24ee4e | ||
|
|
76c4d5c9bf | ||
|
|
a6fc557fad | ||
|
|
a2bdbf9035 | ||
|
|
f9193eebfd | ||
|
|
1a7a4f8a02 | ||
|
|
60246f3941 | ||
|
|
2af70c238b | ||
|
|
30f4b9d8c5 | ||
|
|
390b016cc2 | ||
|
|
e9d7ce262d | ||
|
|
fdd3793ee1 |
@@ -0,0 +1,29 @@
|
|||||||
|
# Ohne diese Datei wandert das komplette Repo zum Fly-Remote-Builder,
|
||||||
|
# inklusive der ~50 MB Git-Historie und des gesamten Backends. Der
|
||||||
|
# Frontend-Build braucht davon nichts.
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
|
||||||
|
# Das Backend wird als eigene Fly-App aus backend/ heraus gebaut
|
||||||
|
backend
|
||||||
|
|
||||||
|
# Nicht vom Frontend-Build verwendet
|
||||||
|
.woodpecker.yml
|
||||||
|
Dockerfile.caddy
|
||||||
|
docker-compose.yml
|
||||||
|
pnpm-lock.yaml
|
||||||
|
pnpm-workspace.yaml
|
||||||
|
MIGRATION_README.md
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
@@ -5,9 +5,17 @@ steps:
|
|||||||
- npm install --package-lock-only
|
- npm install --package-lock-only
|
||||||
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed"
|
- npm audit --audit-level=moderate --json > audit-result.json 2>&1 || echo "Audit completed"
|
||||||
- npm audit --audit-level=moderate > audit-output.txt 2>&1 || echo "Audit completed"
|
- npm audit --audit-level=moderate > audit-output.txt 2>&1 || echo "Audit completed"
|
||||||
|
# Nur wenn sich Abhaengigkeiten geaendert haben - bei reinen
|
||||||
|
# Inhaltsaenderungen kaeme sonst jedes Mal derselbe Bericht
|
||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- '.woodpecker.yml'
|
||||||
|
ignore_message: '[ALL]'
|
||||||
|
|
||||||
discord_notify_audit:
|
discord_notify_audit:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
@@ -76,9 +84,41 @@ steps:
|
|||||||
echo "No audit results found - listing workspace files:"
|
echo "No audit results found - listing workspace files:"
|
||||||
ls -la
|
ls -la
|
||||||
fi
|
fi
|
||||||
|
# Gleicher Filter wie der Audit-Schritt, sonst meldet Discord bei jedem
|
||||||
|
# Inhalts-Commit "keine Ergebnisse gefunden"
|
||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- '.woodpecker.yml'
|
||||||
|
ignore_message: '[ALL]'
|
||||||
|
|
||||||
|
# Backend zuerst - die Admin-Seite braucht die API.
|
||||||
|
# Laeuft nur, wenn sich am Backend etwas geaendert hat. Die haeufigsten
|
||||||
|
# Commits sind Inhaltsaenderungen aus dem CMS und fassen nur src/ und
|
||||||
|
# public/ an - die brauchen kein Backend-Deployment.
|
||||||
|
deploy_backend:
|
||||||
|
image: node:20
|
||||||
|
environment:
|
||||||
|
FLY_API_TOKEN:
|
||||||
|
from_secret: FLY_API_TOKEN
|
||||||
|
commands:
|
||||||
|
- curl -L https://fly.io/install.sh | sh
|
||||||
|
- export PATH="$HOME/.fly/bin:$PATH"
|
||||||
|
# aus backend/ heraus, damit Build-Kontext und Dockerfile stimmen
|
||||||
|
- cd backend && flyctl deploy --app gallus-cms-backend --remote-only
|
||||||
|
when:
|
||||||
|
- branch: main
|
||||||
|
event: push
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'backend/**'
|
||||||
|
- '.woodpecker.yml'
|
||||||
|
# "[ALL]" in der Commit-Message erzwingt den vollen Durchlauf
|
||||||
|
ignore_message: '[ALL]'
|
||||||
|
|
||||||
deploy_frontend:
|
deploy_frontend:
|
||||||
image: node:20
|
image: node:20
|
||||||
@@ -92,6 +132,19 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'src/**'
|
||||||
|
- 'public/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'astro.config.mjs'
|
||||||
|
- 'tsconfig.json'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'fly.toml'
|
||||||
|
- '.woodpecker.yml'
|
||||||
|
ignore_message: '[ALL]'
|
||||||
|
|
||||||
notify_success:
|
notify_success:
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# Fallback to npm install if no lockfile is present
|
# Fallback to npm install if no lockfile is present
|
||||||
RUN npm ci || npm install
|
RUN npm ci || npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
# Ensure CSS variables are present
|
|
||||||
RUN mkdir -p public/styles
|
|
||||||
RUN cp -r styles/* public/styles/ || true
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm install -g serve
|
RUN npm install -g serve
|
||||||
|
|||||||
@@ -1,57 +1,46 @@
|
|||||||
# Multi-stage build for Gallus CMS Backend
|
# Multi-stage build for Gallus CMS Backend
|
||||||
|
|
||||||
# Stage 1: Builder
|
# Stage 1: Builder
|
||||||
|
# Hier werden die nativen Module EINMAL uebersetzt. Vorher lief npm ci in
|
||||||
|
# beiden Stages, better-sqlite3 wurde also doppelt kompiliert.
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies for native modules (better-sqlite3, sharp)
|
# Nur fuer better-sqlite3 - fuer musl gibt es davon keine Fertigbauten.
|
||||||
RUN apk add --no-cache python3 make g++ vips-dev
|
# sharp ab 0.33 bringt eigene Binaries samt libvips mit, vips-dev wird
|
||||||
|
# dadurch nicht mehr gebraucht.
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Install dependencies
|
# Eigener Layer: bleibt im Cache, solange sich das Lockfile nicht aendert
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
# Use npm ci when lockfile exists, fallback to npm install for local/dev
|
RUN npm ci
|
||||||
RUN npm ci || npm install
|
|
||||||
|
|
||||||
# Copy source
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build TypeScript
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Dev-Abhaengigkeiten entfernen. Was uebrig bleibt, ist fertig uebersetzt
|
||||||
|
# und wandert unveraendert ins Laufzeit-Image.
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
# Stage 2: Production
|
# Stage 2: Production
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies (git for simple-git, sqlite3 CLI tool, vips for sharp)
|
# git fuer simple-git, sqlite fuer die CLI. Keine Build-Werkzeuge mehr -
|
||||||
# Note: python3, make, g++ are needed for native module compilation
|
# die blieben vorher als Layer im Image liegen, auch nach dem apk del.
|
||||||
RUN apk add --no-cache git sqlite vips vips-dev python3 make g++
|
RUN apk add --no-cache git sqlite
|
||||||
|
|
||||||
# Copy package files first
|
|
||||||
COPY --from=builder /app/package*.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
# Install all production dependencies and rebuild sharp for linuxmusl-x64
|
|
||||||
RUN npm ci --omit=dev || npm install --production && \
|
|
||||||
npm rebuild sharp
|
|
||||||
|
|
||||||
# Clean up build dependencies after installation to reduce image size
|
|
||||||
RUN apk del python3 make g++ vips-dev
|
|
||||||
|
|
||||||
# Copy built files from builder
|
|
||||||
# Hinweis: Drizzle-Migrationen werden nicht kopiert - das Schema wird zur
|
|
||||||
# Laufzeit von initDatabase() angelegt, src/db/migrations existiert nicht.
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Copy migration script and migrated images
|
|
||||||
COPY --from=builder /app/migrate-production.js ./migrate-production.js
|
COPY --from=builder /app/migrate-production.js ./migrate-production.js
|
||||||
COPY --from=builder /app/data/images ./data/images
|
COPY --from=builder /app/data/images ./data/images
|
||||||
|
|
||||||
# Create directories
|
# Create directories and ensure proper permissions
|
||||||
RUN mkdir -p /app/workspace /app/data
|
RUN mkdir -p /app/workspace /app/data && chown -R node:node /app
|
||||||
|
|
||||||
# Ensure proper permissions
|
|
||||||
RUN chown -R node:node /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
USER node
|
USER node
|
||||||
|
|||||||
@@ -27,14 +27,16 @@ export function initDatabase() {
|
|||||||
console.log('🔧 Initializing database...');
|
console.log('🔧 Initializing database...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if users table exists (acts as a sentinel for initial setup)
|
// Nur zur Protokollierung - angelegt wird immer, siehe unten
|
||||||
const tableCheck = sqlite
|
const tableCheck = sqlite
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!tableCheck) {
|
console.log(tableCheck ? '📝 Checking database schema...' : '📝 Creating database schema...');
|
||||||
console.log('📝 Creating database schema...');
|
|
||||||
|
|
||||||
|
// Laeuft bei JEDEM Start. Alle Anweisungen sind IF NOT EXISTS, und nur so
|
||||||
|
// bekommen bestehende Datenbanken spaeter ergaenzte Tabellen ueberhaupt.
|
||||||
|
{
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
@@ -84,6 +86,11 @@ export function initDatabase() {
|
|||||||
updated_at INTEGER DEFAULT (unixepoch())
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS managed_assets (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS publish_history (
|
CREATE TABLE IF NOT EXISTS publish_history (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT REFERENCES users(id),
|
user_id TEXT REFERENCES users(id),
|
||||||
@@ -93,9 +100,7 @@ export function initDatabase() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('✅ Database schema created successfully!');
|
console.log('✅ Database schema is up to date.');
|
||||||
} else {
|
|
||||||
console.log('✅ Database already initialized.');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error initializing database:', error);
|
console.error('❌ Error initializing database:', error);
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ export const env = {
|
|||||||
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '5242880', 10),
|
// Bilder werden ohnehin auf 1600px heruntergerechnet, die Grenze muss nur
|
||||||
|
// gross genug fuer ein unbearbeitetes Handyfoto sein.
|
||||||
|
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || String(20 * 1024 * 1024), 10),
|
||||||
|
// PDFs werden unveraendert abgelegt. Eine Getraenkekarte mit Bildern liegt
|
||||||
|
// schnell bei 15-20 MB, deshalb ein eigener, groesserer Wert.
|
||||||
|
MAX_PDF_SIZE: parseInt(process.env.MAX_PDF_SIZE || String(40 * 1024 * 1024), 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate required environment variables
|
// Validate required environment variables
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ export const publishHistory = sqliteTable('publish_history', {
|
|||||||
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
publishedAt: integer('published_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vom CMS selbst angelegte Dateien.
|
||||||
|
//
|
||||||
|
// Frueher wurde am Dateinamen erkannt, ob das CMS eine Datei loeschen darf.
|
||||||
|
// Seit die Namen aus Alt-Text bzw. Original-Dateiname abgeleitet werden,
|
||||||
|
// traegt der Name diese Information nicht mehr - ein hochgeladenes
|
||||||
|
// karaoke-abend.avif sieht aus wie ein handgepflegtes event_karaoke.jpg.
|
||||||
|
// Deshalb wird der Besitz hier festgehalten.
|
||||||
|
export const managedAssets = sqliteTable('managed_assets', {
|
||||||
|
path: text('path').primaryKey(), // z.B. /images/events/karaoke-abend.avif
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
// Banner table (for announcements like holidays, special info)
|
// Banner table (for announcements like holidays, special info)
|
||||||
export const banners = sqliteTable('banners', {
|
export const banners = sqliteTable('banners', {
|
||||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { env, validateEnv } from './config/env.js';
|
|||||||
import { db, initDatabase } from './config/database.js';
|
import { db, initDatabase } from './config/database.js';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
import authRoute from './routes/auth.js';
|
import authRoute from './routes/auth.js';
|
||||||
@@ -17,6 +18,7 @@ import contentRoute from './routes/content.js';
|
|||||||
import settingsRoute from './routes/settings.js';
|
import settingsRoute from './routes/settings.js';
|
||||||
import publishRoute from './routes/publish.js';
|
import publishRoute from './routes/publish.js';
|
||||||
import bannersRoute from './routes/banners.js';
|
import bannersRoute from './routes/banners.js';
|
||||||
|
import pdfRoute from './routes/pdf.js';
|
||||||
|
|
||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
try {
|
try {
|
||||||
@@ -71,20 +73,44 @@ fastify.register(jwt, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Der globale Wert ist die Obergrenze fuer alles. Die Routen setzen ihn per
|
||||||
|
// request.file({ limits }) auf ihren eigenen, engeren Wert herunter.
|
||||||
fastify.register(multipart, {
|
fastify.register(multipart, {
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: env.MAX_FILE_SIZE,
|
fileSize: Math.max(env.MAX_FILE_SIZE, env.MAX_PDF_SIZE),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve static files (uploaded images, etc.) from persistent volume
|
// Serve static files (uploaded images, etc.) from persistent volume
|
||||||
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
const dataDir = env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
||||||
|
// Muss existieren, sonst verweigert @fastify/static die Registrierung
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
fastify.register(fastifyStatic, {
|
fastify.register(fastifyStatic, {
|
||||||
root: dataDir,
|
root: dataDir,
|
||||||
prefix: '/static/',
|
prefix: '/static/',
|
||||||
decorateReply: false
|
decorateReply: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Uploads liegen unter <workspace>/public/images. In der DB stehen sie als
|
||||||
|
// /images/... - das ist die URL auf der publizierten Astro-Seite. Damit die
|
||||||
|
// Admin-Oberflaeche dieselben Pfade verwenden kann, hier ebenso ausliefern.
|
||||||
|
const imagesDir = path.join(dataDir, 'public', 'images');
|
||||||
|
fs.mkdirSync(imagesDir, { recursive: true });
|
||||||
|
fastify.register(fastifyStatic, {
|
||||||
|
root: imagesDir,
|
||||||
|
prefix: '/images/',
|
||||||
|
decorateReply: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dasselbe fuer die hochgeladenen PDFs (Getraenkekarte)
|
||||||
|
const pdfDir = path.join(dataDir, 'public', 'pdf');
|
||||||
|
fs.mkdirSync(pdfDir, { recursive: true });
|
||||||
|
fastify.register(fastifyStatic, {
|
||||||
|
root: pdfDir,
|
||||||
|
prefix: '/pdf/',
|
||||||
|
decorateReply: false
|
||||||
|
});
|
||||||
|
|
||||||
// Decorate fastify with authenticate method
|
// Decorate fastify with authenticate method
|
||||||
fastify.decorate('authenticate', authenticate);
|
fastify.decorate('authenticate', authenticate);
|
||||||
|
|
||||||
@@ -96,6 +122,7 @@ fastify.register(contentRoute, { prefix: '/api' });
|
|||||||
fastify.register(settingsRoute, { prefix: '/api' });
|
fastify.register(settingsRoute, { prefix: '/api' });
|
||||||
fastify.register(publishRoute, { prefix: '/api' });
|
fastify.register(publishRoute, { prefix: '/api' });
|
||||||
fastify.register(bannersRoute, { prefix: '/api' });
|
fastify.register(bannersRoute, { prefix: '/api' });
|
||||||
|
fastify.register(pdfRoute, { prefix: '/api' });
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { z } from 'zod';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { contentSections } from '../db/schema.js';
|
import { contentSections } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { saveUploadedImage } from '../services/upload.service.js';
|
||||||
|
import { dropImageIfUnused, extractImageUrls } from '../services/image-refs.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
// Fastify JSON schema for content section body
|
// Fastify JSON schema for content section body
|
||||||
const contentBodyJsonSchema = {
|
const contentBodyJsonSchema = {
|
||||||
@@ -78,6 +81,20 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bilder, die durch die Aenderung herausgefallen sind, wegraeumen
|
||||||
|
const before = extractImageUrls((existing as any)?.contentJson);
|
||||||
|
const after = extractImageUrls(result.contentJson);
|
||||||
|
for (const url of before) {
|
||||||
|
if (after.has(url)) continue;
|
||||||
|
try {
|
||||||
|
if (await dropImageIfUnused(url)) {
|
||||||
|
fastify.log.info(`Removed unused content image ${url}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.warn({ err }, 'Could not remove unused content image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
section: result.sectionName,
|
section: result.sectionName,
|
||||||
content: result.contentJson,
|
content: result.contentJson,
|
||||||
@@ -85,6 +102,39 @@ const contentRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bild fuer einen Textbereich hochladen (Welcome-Bild, Monatshit, Whiskey)
|
||||||
|
fastify.post('/content/upload', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
|
||||||
|
if (!file) {
|
||||||
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = file.mimetype as string | undefined;
|
||||||
|
if (!mime || !mime.startsWith('image/')) {
|
||||||
|
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredName = (file.fields?.name?.value as string | undefined) || '';
|
||||||
|
|
||||||
|
const saved = await saveUploadedImage(file, 'content', {
|
||||||
|
preferredName,
|
||||||
|
log: fastify.log,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
return reply.code(413).send({ error: err.message });
|
||||||
|
}
|
||||||
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
|
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// List all content sections
|
// List all content sections
|
||||||
fastify.get('/content', {
|
fastify.get('/content', {
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events } from '../db/schema.js';
|
import { events } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
import { dropImageIfUnused } from '../services/image-refs.service.js';
|
||||||
import path from 'path';
|
import { saveUploadedImage } from '../services/upload.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
|
||||||
|
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
|
||||||
|
try {
|
||||||
|
if (await dropImageIfUnused(url)) {
|
||||||
|
fastify.log.info(`Removed ${reason} ${url}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.warn({ err }, `Could not remove ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fastify JSON schema for event body
|
// Fastify JSON schema for event body
|
||||||
const eventBodyJsonSchema = {
|
const eventBodyJsonSchema = {
|
||||||
@@ -71,8 +83,17 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.put('/events/:id', { schema: { body: eventBodyJsonSchema }, preHandler: [fastify.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const data = request.body as any;
|
const data = request.body as any;
|
||||||
|
|
||||||
|
const [previous] = await db.select().from(events).where(eq(events.id, id)).limit(1);
|
||||||
|
|
||||||
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
const [row] = await db.update(events).set({ ...data, updatedAt: new Date() }).where(eq(events.id, id)).returning();
|
||||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
|
||||||
|
// Ausgetauschtes Bild wegraeumen, sonst bleibt es fuer immer liegen
|
||||||
|
if (previous && previous.imageUrl !== row.imageUrl) {
|
||||||
|
await dropUnusedImage(fastify, previous.imageUrl, 'replaced event image');
|
||||||
|
}
|
||||||
|
|
||||||
return { event: row };
|
return { event: row };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +103,7 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
// Expect a single file field named "file"
|
// Expect a single file field named "file"
|
||||||
const file = await (request as any).file();
|
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
@@ -92,52 +113,21 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare directories - use persistent volume for Fly.io
|
// Der Titel kommt als Formularfeld VOR der Datei, sonst ist er hier
|
||||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
// noch nicht geparst
|
||||||
const uploadDir = path.join(dataDir, 'public', 'images', 'events');
|
const preferredName = (file.fields?.title?.value as string | undefined) || '';
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
||||||
|
|
||||||
// Read uploaded stream into buffer
|
const saved = await saveUploadedImage(file, 'events', {
|
||||||
const chunks: Buffer[] = [];
|
preferredName,
|
||||||
for await (const chunk of file.file) {
|
log: fastify.log,
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send({ imageUrl: saved.imageUrl });
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
return reply.code(413).send({ error: err.message });
|
||||||
}
|
}
|
||||||
const inputBuffer = Buffer.concat(chunks);
|
|
||||||
|
|
||||||
// Generate filename
|
|
||||||
const stamp = Date.now().toString(36);
|
|
||||||
const rand = Math.random().toString(36).slice(2, 8);
|
|
||||||
const baseName = `${stamp}-${rand}`;
|
|
||||||
|
|
||||||
// Try to convert to webp and limit size; fallback to original
|
|
||||||
let outBuffer: Buffer | null = null;
|
|
||||||
let outExt = '.webp';
|
|
||||||
try {
|
|
||||||
// Lazy load sharp only when needed
|
|
||||||
const sharp = (await import('sharp')).default;
|
|
||||||
outBuffer = await sharp(inputBuffer)
|
|
||||||
.rotate()
|
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
|
||||||
.webp({ quality: 82 })
|
|
||||||
.toBuffer();
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
|
||||||
outBuffer = inputBuffer;
|
|
||||||
// naive extension from mimetype
|
|
||||||
const extFromMime = mime.split('/')[1] || 'bin';
|
|
||||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = baseName + outExt;
|
|
||||||
const destPath = path.join(uploadDir, filename);
|
|
||||||
fs.writeFileSync(destPath, outBuffer);
|
|
||||||
|
|
||||||
// Public URL (served via /static)
|
|
||||||
const publicUrl = `/images/events/${filename}`;
|
|
||||||
|
|
||||||
return reply.code(201).send({ imageUrl: publicUrl });
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||||
}
|
}
|
||||||
@@ -148,6 +138,10 @@ const eventsRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
const [row] = await db.delete(events).where(eq(events.id, id)).returning();
|
||||||
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
if (!row) return reply.code(404).send({ error: 'Event not found' });
|
||||||
|
|
||||||
|
// Zugehoerige Bilddatei mitnehmen
|
||||||
|
await dropUnusedImage(fastify, row.imageUrl, 'event image');
|
||||||
|
|
||||||
return { message: 'Event deleted successfully' };
|
return { message: 'Event deleted successfully' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,20 @@ import { z } from 'zod';
|
|||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { galleryImages } from '../db/schema.js';
|
import { galleryImages } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
import { dropImageIfUnused } from '../services/image-refs.service.js';
|
||||||
import path from 'path';
|
import { saveUploadedImage } from '../services/upload.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
/** Raeumt eine Bilddatei weg, ohne dass ein Fehler die Antwort kippt. */
|
||||||
|
async function dropUnusedImage(fastify: any, url: string | null | undefined, reason: string) {
|
||||||
|
try {
|
||||||
|
if (await dropImageIfUnused(url)) {
|
||||||
|
fastify.log.info(`Removed ${reason} ${url}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.warn({ err }, `Could not remove ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fastify JSON schema for gallery image body
|
// Fastify JSON schema for gallery image body
|
||||||
const galleryBodyJsonSchema = {
|
const galleryBodyJsonSchema = {
|
||||||
@@ -70,7 +82,7 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
// Expect a single file field named "file"
|
// Expect a single file field named "file"
|
||||||
const file = await (request as any).file();
|
const file = await (request as any).file({ limits: { fileSize: env.MAX_FILE_SIZE } });
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.code(400).send({ error: 'No file uploaded' });
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
@@ -84,60 +96,25 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
return reply.code(400).send({ error: 'Only image uploads are allowed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare directories - use persistent volume for Fly.io
|
const saved = await saveUploadedImage(file, 'gallery', {
|
||||||
const dataDir = process.env.GIT_WORKSPACE_DIR || path.join(process.cwd(), 'data');
|
preferredName: altText,
|
||||||
const uploadDir = path.join(dataDir, 'public', 'images', 'gallery');
|
log: fastify.log,
|
||||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
});
|
||||||
|
|
||||||
// Read uploaded stream into buffer
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
for await (const chunk of file.file) {
|
|
||||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
||||||
}
|
|
||||||
const inputBuffer = Buffer.concat(chunks);
|
|
||||||
|
|
||||||
// Generate filename
|
|
||||||
const stamp = Date.now().toString(36);
|
|
||||||
const rand = Math.random().toString(36).slice(2, 8);
|
|
||||||
const baseName = `${stamp}-${rand}`;
|
|
||||||
|
|
||||||
// Try to convert to webp and limit size; fallback to original
|
|
||||||
let outBuffer: Buffer | null = null;
|
|
||||||
let outExt = '.webp';
|
|
||||||
try {
|
|
||||||
// Lazy load sharp only when needed
|
|
||||||
const sharp = (await import('sharp')).default;
|
|
||||||
outBuffer = await sharp(inputBuffer)
|
|
||||||
.rotate()
|
|
||||||
.resize({ width: 1600, withoutEnlargement: true })
|
|
||||||
.webp({ quality: 82 })
|
|
||||||
.toBuffer();
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.warn({ err }, 'Sharp processing failed, using original image');
|
|
||||||
outBuffer = inputBuffer;
|
|
||||||
// naive extension from mimetype
|
|
||||||
const extFromMime = mime.split('/')[1] || 'bin';
|
|
||||||
outExt = '.' + extFromMime.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = baseName + outExt;
|
|
||||||
const destPath = path.join(uploadDir, filename);
|
|
||||||
fs.writeFileSync(destPath, outBuffer);
|
|
||||||
|
|
||||||
// Public URL (served via /static)
|
|
||||||
const publicUrl = `/images/gallery/${filename}`;
|
|
||||||
|
|
||||||
// Store in DB (optional but useful)
|
// Store in DB (optional but useful)
|
||||||
const [row] = await db.insert(galleryImages).values({
|
const [row] = await db.insert(galleryImages).values({
|
||||||
imageUrl: publicUrl,
|
imageUrl: saved.imageUrl,
|
||||||
altText: altText || filename,
|
altText: altText || saved.filename,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
return reply.code(201).send({ image: row });
|
return reply.code(201).send({ image: row });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
return reply.code(413).send({ error: err.message });
|
||||||
|
}
|
||||||
fastify.log.error({ err }, 'Upload failed');
|
fastify.log.error({ err }, 'Upload failed');
|
||||||
return reply.code(500).send({ error: 'Failed to upload image' });
|
return reply.code(500).send({ error: 'Failed to upload image' });
|
||||||
}
|
}
|
||||||
@@ -153,6 +130,8 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const data = request.body as any;
|
const data = request.body as any;
|
||||||
|
|
||||||
|
const [previous] = await db.select().from(galleryImages).where(eq(galleryImages.id, id)).limit(1);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(galleryImages)
|
.update(galleryImages)
|
||||||
.set(data)
|
.set(data)
|
||||||
@@ -163,6 +142,11 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ausgetauschte Datei wegraeumen
|
||||||
|
if (previous && previous.imageUrl !== updated.imageUrl) {
|
||||||
|
await dropUnusedImage(fastify, previous.imageUrl, 'replaced gallery image');
|
||||||
|
}
|
||||||
|
|
||||||
return { image: updated };
|
return { image: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,6 +165,9 @@ const galleryRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(404).send({ error: 'Image not found' });
|
return reply.code(404).send({ error: 'Image not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zugehoerige Bilddatei mitnehmen
|
||||||
|
await dropUnusedImage(fastify, deleted.imageUrl, 'gallery image');
|
||||||
|
|
||||||
return { message: 'Image deleted successfully' };
|
return { message: 'Image deleted successfully' };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { contentSections } from '../db/schema.js';
|
||||||
|
import { AssetService } from '../services/asset.service.js';
|
||||||
|
import { isManagedAsset, forgetManagedAsset } from '../services/managed-assets.service.js';
|
||||||
|
import { saveUploadedPdf } from '../services/upload.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
const assets = new AssetService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feste PDF-Plaetze. Die URL landet jeweils in einer Content-Section, damit
|
||||||
|
* der Generator sie beim Publish in die Astro-Komponente schreiben kann.
|
||||||
|
*/
|
||||||
|
const PDF_SLOTS: Record<string, { section: string; field: string }> = {
|
||||||
|
drinks: { section: 'drinks', field: 'pdfUrl' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** contentJson kommt je nach Treiber als Objekt oder als String zurueck. */
|
||||||
|
function asObject(value: any): Record<string, any> {
|
||||||
|
if (!value) return {};
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === 'object' ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfRoute: FastifyPluginAsync = async (fastify) => {
|
||||||
|
|
||||||
|
// PDF fuer einen festen Platz hochladen und verlinken
|
||||||
|
fastify.post('/pdf/:slot', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { slot } = request.params as { slot: string };
|
||||||
|
const target = PDF_SLOTS[slot];
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return reply.code(404).send({ error: `Unknown PDF slot "${slot}"` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await (request as any).file({ limits: { fileSize: env.MAX_PDF_SIZE } });
|
||||||
|
if (!file) {
|
||||||
|
return reply.code(400).send({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = file.mimetype as string | undefined;
|
||||||
|
const originalName = (file.filename as string | undefined) || '';
|
||||||
|
if (mime !== 'application/pdf' && !originalName.toLowerCase().endsWith('.pdf')) {
|
||||||
|
return reply.code(400).send({ error: 'Only PDF uploads are allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
if ((file.file as any)?.truncated) {
|
||||||
|
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
|
||||||
|
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inhalt gegenpruefen, damit nicht irgendetwas mit .pdf-Endung landet
|
||||||
|
if (buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
||||||
|
return reply.code(400).send({ error: 'File is not a valid PDF' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfUrl = await saveUploadedPdf(file, buffer);
|
||||||
|
|
||||||
|
// URL in der Content-Section hinterlegen
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(contentSections)
|
||||||
|
.where(eq(contentSections.sectionName, target.section))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const previousContent = asObject((existing as any)?.contentJson);
|
||||||
|
const previousUrl = previousContent[target.field];
|
||||||
|
const content = { ...previousContent, [target.field]: pdfUrl };
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(contentSections)
|
||||||
|
.set({ contentJson: content, updatedAt: new Date() })
|
||||||
|
.where(eq(contentSections.sectionName, target.section));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.insert(contentSections)
|
||||||
|
.values({ sectionName: target.section, contentJson: content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorgaenger wegraeumen - greift nur bei frueher hochgeladenen PDFs,
|
||||||
|
// die mitgelieferte Getraenkekarte aus dem Repo bleibt liegen
|
||||||
|
if (previousUrl && previousUrl !== pdfUrl && (await isManagedAsset(previousUrl))) {
|
||||||
|
try {
|
||||||
|
if (assets.deletePdf(previousUrl)) {
|
||||||
|
fastify.log.info(`Removed replaced PDF ${previousUrl}`);
|
||||||
|
}
|
||||||
|
await forgetManagedAsset(previousUrl);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.warn({ err }, 'Could not remove replaced PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(201).send({ pdfUrl, section: target.section });
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'FST_REQ_FILE_TOO_LARGE') {
|
||||||
|
const limit = Math.round(env.MAX_PDF_SIZE / 1024 / 1024);
|
||||||
|
return reply.code(413).send({ error: `PDF too large. Maximum is ${limit} MB` });
|
||||||
|
}
|
||||||
|
fastify.log.error({ err }, 'PDF upload failed');
|
||||||
|
return reply.code(500).send({ error: 'Failed to upload PDF' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default pdfRoute;
|
||||||
@@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { GitService } from '../services/git.service.js';
|
import { GitService } from '../services/git.service.js';
|
||||||
import { FileGeneratorService } from '../services/file-generator.service.js';
|
import { FileGeneratorService } from '../services/file-generator.service.js';
|
||||||
|
import { sweepOrphanedImages } from '../services/image-refs.service.js';
|
||||||
import { db } from '../config/database.js';
|
import { db } from '../config/database.js';
|
||||||
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
import { events, galleryImages, contentSections, publishHistory } from '../db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -34,6 +35,13 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
fastify.log.info('Git repository initialized');
|
fastify.log.info('Git repository initialized');
|
||||||
|
|
||||||
|
// Verwaiste Uploads entfernen, bevor committet wird
|
||||||
|
const removedImages = await sweepOrphanedImages();
|
||||||
|
|
||||||
|
if (removedImages.length > 0) {
|
||||||
|
fastify.log.info(`Removed ${removedImages.length} orphaned image(s): ${removedImages.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all content from database
|
// Fetch all content from database
|
||||||
const eventsData = await db
|
const eventsData = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -78,16 +86,23 @@ const publishRoute: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
fastify.log.info(`Changes committed: ${commitHash}`);
|
fastify.log.info(`Changes committed: ${commitHash}`);
|
||||||
|
|
||||||
// Record in history
|
// Record in history. Der Push ist an dieser Stelle bereits durch -
|
||||||
await db.insert(publishHistory).values({
|
// ein Fehler im Protokoll darf die Veroeffentlichung nicht als
|
||||||
userId,
|
// gescheitert melden und den Workspace zuruecksetzen.
|
||||||
commitHash,
|
try {
|
||||||
commitMessage,
|
await db.insert(publishHistory).values({
|
||||||
});
|
userId,
|
||||||
|
commitHash,
|
||||||
|
commitMessage,
|
||||||
|
});
|
||||||
|
} catch (historyError) {
|
||||||
|
fastify.log.warn({ err: historyError }, 'Could not record publish history');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
commitHash,
|
commitHash,
|
||||||
|
removedImages: removedImages.length,
|
||||||
message: 'Changes published successfully',
|
message: 'Changes published successfully',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Einmalige Umwandlung des Altbestands nach AVIF.
|
||||||
|
*
|
||||||
|
* Bilder, die vor der Umstellung hochgeladen wurden, liegen unverkleinert
|
||||||
|
* und im Originalformat im Repo - damals war sharp im Container kaputt, es
|
||||||
|
* gab also weder Verkleinerung noch Formatwandlung. Dieses Skript schickt
|
||||||
|
* sie durch dieselbe Verarbeitung wie einen frischen Upload, benennt sie
|
||||||
|
* nach ihrem Inhalt und zieht die Verweise in der Datenbank mit.
|
||||||
|
*
|
||||||
|
* Aufruf im Container:
|
||||||
|
* node dist/scripts/convert-images-to-avif.js # nur anzeigen
|
||||||
|
* node dist/scripts/convert-images-to-avif.js --apply # wirklich tun
|
||||||
|
*
|
||||||
|
* Ohne --apply wird nichts geschrieben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, initDatabase } from '../config/database.js';
|
||||||
|
import { events, galleryImages, contentSections } from '../db/schema.js';
|
||||||
|
import { GitService } from '../services/git.service.js';
|
||||||
|
import { AssetService, MANAGED_IMAGE_DIRS } from '../services/asset.service.js';
|
||||||
|
import { saveImageBuffer, UploadSubdir } from '../services/upload.service.js';
|
||||||
|
import { replaceImageUrls, dropImageIfUnused } from '../services/image-refs.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply');
|
||||||
|
|
||||||
|
const assets = new AssetService();
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
url: string;
|
||||||
|
preferredName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /images/events/foo.jpg -> events */
|
||||||
|
function subdirOf(url: string): UploadSubdir | null {
|
||||||
|
const match = /^\/images\/(events|gallery|content)\//.exec(url);
|
||||||
|
return match ? (match[1] as UploadSubdir) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolutePathOf(url: string): string {
|
||||||
|
return path.join(env.GIT_WORKSPACE_DIR, 'public', url.replace(/^\/+/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprechender Name fuer ein Bild aus einem Textbereich. */
|
||||||
|
function contentImageName(section: string, key: string, content: any): string {
|
||||||
|
if (section === 'welcome' && key === 'imageUrl') return 'willkommen';
|
||||||
|
if (section === 'drinks' && key === 'monthlySpecialImage') {
|
||||||
|
return content?.monthlySpecialName || 'monats-hit';
|
||||||
|
}
|
||||||
|
const whiskey = /^whiskeyImage(\d)$/.exec(key);
|
||||||
|
if (whiskey) return `whiskey-${whiskey[1]}`;
|
||||||
|
return `${section}-bild`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: any): Record<string, any> {
|
||||||
|
if (!value) return {};
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === 'object' ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCandidates(): Promise<Candidate[]> {
|
||||||
|
const byUrl = new Map<string, string>();
|
||||||
|
|
||||||
|
const remember = (url: any, name: string) => {
|
||||||
|
if (typeof url !== 'string' || !url.startsWith('/images/')) return;
|
||||||
|
if (!byUrl.has(url)) byUrl.set(url, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(events)) as any[]) {
|
||||||
|
remember(row.imageUrl, row.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||||
|
remember(row.imageUrl, row.altText);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||||
|
const content = asObject(row.contentJson);
|
||||||
|
for (const [key, value] of Object.entries(content)) {
|
||||||
|
remember(value, contentImageName(row.sectionName, key, content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byUrl.entries()].map(([url, preferredName]) => ({ url, preferredName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(APPLY ? '=== Umwandlung nach AVIF ===' : '=== Vorschau (nichts wird geschrieben) ===\n');
|
||||||
|
|
||||||
|
initDatabase();
|
||||||
|
|
||||||
|
// Workspace auf den Stand des Repos bringen
|
||||||
|
const git = new GitService();
|
||||||
|
await git.initialize();
|
||||||
|
console.log(`Workspace: ${env.GIT_WORKSPACE_DIR}\n`);
|
||||||
|
|
||||||
|
const candidates = await collectCandidates();
|
||||||
|
const mapping = new Map<string, string>();
|
||||||
|
|
||||||
|
let bytesBefore = 0;
|
||||||
|
let bytesAfter = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let missing = 0;
|
||||||
|
|
||||||
|
for (const { url, preferredName } of candidates) {
|
||||||
|
const subdir = subdirOf(url);
|
||||||
|
|
||||||
|
if (!subdir) {
|
||||||
|
console.log(` uebersprungen (ausserhalb der Upload-Ordner): ${url}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.toLowerCase().endsWith('.avif')) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = absolutePathOf(url);
|
||||||
|
if (!assets.resolveInDirs(url, MANAGED_IMAGE_DIRS) || !fs.existsSync(source)) {
|
||||||
|
console.log(` FEHLT auf der Platte, Verweis bleibt: ${url}`);
|
||||||
|
missing++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = fs.readFileSync(source);
|
||||||
|
bytesBefore += input.length;
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log(` ${url} (${(input.length / 1024).toFixed(0)} KB) -> benannt nach "${preferredName}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveImageBuffer(input, subdir, { preferredName });
|
||||||
|
const outSize = fs.statSync(absolutePathOf(saved.imageUrl)).size;
|
||||||
|
bytesAfter += outSize;
|
||||||
|
mapping.set(url, saved.imageUrl);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${url} ${(input.length / 1024).toFixed(0)} KB -> ${saved.imageUrl} ${(outSize / 1024).toFixed(0)} KB`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log(`\n${candidates.length - skipped - missing} Bild(er) wuerden umgewandelt.`);
|
||||||
|
console.log(`Gesamtgroesse aktuell: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
console.log('\nZum Ausfuehren: node dist/scripts/convert-images-to-avif.js --apply');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.size === 0) {
|
||||||
|
console.log('\nNichts umzuwandeln.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verweise in der Datenbank nachziehen
|
||||||
|
for (const row of (await db.select().from(events)) as any[]) {
|
||||||
|
const next = mapping.get(row.imageUrl);
|
||||||
|
if (next) await db.update(events).set({ imageUrl: next }).where(eq(events.id, row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||||
|
const next = mapping.get(row.imageUrl);
|
||||||
|
if (next) await db.update(galleryImages).set({ imageUrl: next }).where(eq(galleryImages.id, row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||||
|
const updated = replaceImageUrls(asObject(row.contentJson), mapping);
|
||||||
|
await db
|
||||||
|
.update(contentSections)
|
||||||
|
.set({ contentJson: updated, updatedAt: new Date() })
|
||||||
|
.where(eq(contentSections.sectionName, row.sectionName));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nVerweise in der Datenbank aktualisiert.');
|
||||||
|
|
||||||
|
// Vorgaenger wegraeumen - dropImageIfUnused loescht nur, was das CMS
|
||||||
|
// selbst angelegt hat. Handgepflegte Dateien wie event_karaoke.jpg
|
||||||
|
// bleiben liegen, obwohl jetzt eine AVIF-Fassung existiert.
|
||||||
|
let removed = 0;
|
||||||
|
let kept = 0;
|
||||||
|
|
||||||
|
for (const oldUrl of mapping.keys()) {
|
||||||
|
if (await dropImageIfUnused(oldUrl)) {
|
||||||
|
removed++;
|
||||||
|
} else if (fs.existsSync(absolutePathOf(oldUrl))) {
|
||||||
|
console.log(` Original behalten (nicht vom CMS angelegt): ${oldUrl}`);
|
||||||
|
kept++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${mapping.size} Bild(er) umgewandelt, ${removed} Original(e) entfernt, ${kept} behalten.`);
|
||||||
|
console.log(
|
||||||
|
`Groesse: ${(bytesBefore / 1024 / 1024).toFixed(2)} MB -> ${(bytesAfter / 1024 / 1024).toFixed(2)} MB` +
|
||||||
|
` (${(100 - (bytesAfter / bytesBefore) * 100).toFixed(1)} % gespart)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const hash = await git.commitAndPush('Bestandsbilder nach AVIF umgewandelt');
|
||||||
|
console.log(`\nCommit: ${hash}`);
|
||||||
|
console.log('Fertig. Woodpecker baut die Seite jetzt neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('\nFehlgeschlagen:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennt die Verzeichnisse, in die das CMS schreibt, und sorgt dafuer, dass
|
||||||
|
* kein Pfad ausserhalb davon angefasst wird.
|
||||||
|
*
|
||||||
|
* Ob eine konkrete Datei geloescht werden DARF, entscheidet diese Klasse
|
||||||
|
* bewusst nicht - das steht in managed-assets.service.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Unterordner unterhalb von public/
|
||||||
|
export const MANAGED_IMAGE_DIRS = ['images/events', 'images/gallery', 'images/content'];
|
||||||
|
export const MANAGED_PDF_DIR = 'pdf';
|
||||||
|
|
||||||
|
export class AssetService {
|
||||||
|
private publicDir: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.publicDir = path.join(env.GIT_WORKSPACE_DIR, 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absoluter Pfad im public-Verzeichnis, oder null wenn ausserhalb. */
|
||||||
|
private resolveInPublic(url: string): string | null {
|
||||||
|
if (!url || typeof url !== 'string' || !url.startsWith('/')) return null;
|
||||||
|
|
||||||
|
const relative = url.replace(/^\/+/, '').split('?')[0].split('#')[0];
|
||||||
|
const absolute = path.resolve(this.publicDir, relative);
|
||||||
|
|
||||||
|
// Traversal-Schutz: muss unterhalb von public/ bleiben
|
||||||
|
if (absolute !== this.publicDir && !absolute.startsWith(this.publicDir + path.sep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absoluter Pfad, sofern die Datei in einem der erlaubten Ordner liegt. */
|
||||||
|
resolveInDirs(url: string, dirs: string[]): string | null {
|
||||||
|
const absolute = this.resolveInPublic(url);
|
||||||
|
if (!absolute) return null;
|
||||||
|
|
||||||
|
const relative = path.relative(this.publicDir, absolute);
|
||||||
|
const dir = path.dirname(relative).split(path.sep).join('/');
|
||||||
|
|
||||||
|
return dirs.includes(dir) ? absolute : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Existiert die Datei bereits? Fuer die Namensvergabe. */
|
||||||
|
exists(url: string, dirs: string[]): boolean {
|
||||||
|
const absolute = this.resolveInDirs(url, dirs);
|
||||||
|
return absolute ? fs.existsSync(absolute) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht eine Bilddatei. Die Besitzfrage muss vorher geklaert sein. */
|
||||||
|
deleteImage(url: string): boolean {
|
||||||
|
return this.unlink(this.resolveInDirs(url, MANAGED_IMAGE_DIRS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht ein PDF. Die Besitzfrage muss vorher geklaert sein. */
|
||||||
|
deletePdf(url: string): boolean {
|
||||||
|
return this.unlink(this.resolveInDirs(url, [MANAGED_PDF_DIR]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private unlink(absolute: string | null): boolean {
|
||||||
|
if (!absolute) return false;
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(absolute);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'ENOENT') return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Bilddateien, die in den verwalteten Ordnern liegen, als URL-Pfade. */
|
||||||
|
listImageFiles(): string[] {
|
||||||
|
const found: string[] = [];
|
||||||
|
|
||||||
|
for (const dir of MANAGED_IMAGE_DIRS) {
|
||||||
|
const absoluteDir = path.join(this.publicDir, dir);
|
||||||
|
if (!fs.existsSync(absoluteDir)) continue;
|
||||||
|
|
||||||
|
for (const name of fs.readdirSync(absoluteDir)) {
|
||||||
|
const absolute = path.join(absoluteDir, name);
|
||||||
|
if (!fs.statSync(absolute).isFile()) continue;
|
||||||
|
found.push(`/${dir}/${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,27 @@ export class FileGeneratorService {
|
|||||||
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
return str.replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texte aus dem Adminbereich landen direkt im Astro-Markup. Ohne Maskierung
|
||||||
|
* reicht ein "<" oder "&" in einem Feld, damit der Build der Seite scheitert
|
||||||
|
* und der Deploy stehen bleibt.
|
||||||
|
*/
|
||||||
|
escapeHtml(value: any): string {
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie escapeHtml, aber mit Rueckfallwert wenn nichts gesetzt ist. */
|
||||||
|
text(value: any, fallback = ''): string {
|
||||||
|
const raw = value === undefined || value === null || value === '' ? fallback : value;
|
||||||
|
return this.escapeHtml(raw);
|
||||||
|
}
|
||||||
|
|
||||||
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
generateIndexAstro(events: Event[], images: GalleryImage[]): string {
|
||||||
const eventsCode = events.map(e => `\t{
|
const eventsCode = events.map(e => `\t{
|
||||||
\t\timage: "${e.imageUrl}",
|
\t\timage: "${e.imageUrl}",
|
||||||
@@ -86,9 +107,9 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
\t\t<div class="hero-content">
|
\t\t<div class="hero-content">
|
||||||
|
|
||||||
\t\t\t<h1>${content.heading || 'Dein Irish Pub'}</h1>
|
\t\t\t<h1>${this.text(content.heading, 'Dein Irish Pub')}</h1>
|
||||||
|
|
||||||
\t\t\t<p>${content.subheading || 'Im Herzen von St.Gallen'}</p>
|
\t\t\t<p>${this.text(content.subheading, 'Im Herzen von St.Gallen')}</p>
|
||||||
|
|
||||||
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
\t\t\t<a href="#" class="button">Aktuelles ↓</a>
|
||||||
\t\t</div>
|
\t\t</div>
|
||||||
@@ -105,7 +126,7 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
generateWelcomeComponent(content: ContentSection): string {
|
generateWelcomeComponent(content: ContentSection): string {
|
||||||
const highlightsList = (content.highlights || []).map((h: any) =>
|
const highlightsList = (content.highlights || []).map((h: any) =>
|
||||||
`\t\t\t<li>\n\t\t\t\t<b>${h.title}:</b> ${h.description}\n\t\t\t</li>`
|
`\t\t\t<li>\n\t\t\t\t<b>${this.escapeHtml(h?.title)}:</b> ${this.escapeHtml(h?.description)}\n\t\t\t</li>`
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
return `---
|
return `---
|
||||||
@@ -119,11 +140,11 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
\t<div class="welcome-text">
|
\t<div class="welcome-text">
|
||||||
|
|
||||||
\t\t<h2>${content.heading1 || 'Herzlich willkommen im'}</h2>
|
\t\t<h2>${this.text(content.heading1, 'Herzlich willkommen im')}</h2>
|
||||||
\t\t<h2>${content.heading2 || 'Gallus Pub!'}</h2>
|
\t\t<h2>${this.text(content.heading2, 'Gallus Pub!')}</h2>
|
||||||
|
|
||||||
\t\t<p>
|
\t\t<p>
|
||||||
\t\t\t${content.introText || ''}
|
\t\t\t${this.text(content.introText)}
|
||||||
\t\t</p>
|
\t\t</p>
|
||||||
|
|
||||||
\t\t<p><b>Unsere Highlights:</b></p>
|
\t\t<p><b>Unsere Highlights:</b></p>
|
||||||
@@ -133,14 +154,14 @@ ${highlightsList}
|
|||||||
\t\t</ul>
|
\t\t</ul>
|
||||||
|
|
||||||
\t\t<p>
|
\t\t<p>
|
||||||
\t\t\t${content.closingText || ''}
|
\t\t\t${this.text(content.closingText)}
|
||||||
\t\t</p>
|
\t\t</p>
|
||||||
|
|
||||||
\t</div>
|
\t</div>
|
||||||
|
|
||||||
|
|
||||||
\t<div class="welcome-image">
|
\t<div class="welcome-image">
|
||||||
\t\t<img src="${content.imageUrl || '/images/Welcome.png'}" alt="Welcome background image" />
|
\t\t<img src="${this.text(content.imageUrl, '/images/Welcome.png')}" alt="Welcome background image" />
|
||||||
\t</div>
|
\t</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -157,36 +178,36 @@ const { id } = Astro.props;
|
|||||||
<h2 class="title">Drinks</h2>
|
<h2 class="title">Drinks</h2>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
${content.introText || 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.'}
|
${this.text(content.introText, 'Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
<a href="${this.text(content.pdfUrl, '/pdf/Getraenke_Gallus_2025.pdf')}" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||||
|
|
||||||
<h3 class="monats-hit">Monats Hit</h3>
|
<h3 class="monats-hit">Monats Hit</h3>
|
||||||
|
|
||||||
<div class="mate-vodka">
|
<div class="mate-vodka">
|
||||||
<div class="circle" title="${content.monthlySpecialName || 'Mate Vodka'}">
|
<div class="circle" title="${this.text(content.monthlySpecialName, 'Mate Vodka')}">
|
||||||
<img src="${content.monthlySpecialImage || '/images/MonthlyHit.png'}" alt="Monats Hit" class="circle-image" />
|
<img src="${this.text(content.monthlySpecialImage, '/images/MonthlyHit.png')}" alt="Monats Hit" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>${content.monthlySpecialName || 'Mate Vodka'}</div>
|
<div>${this.text(content.monthlySpecialName, 'Mate Vodka')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
${content.whiskeyText || 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.'}
|
${this.text(content.whiskeyText, 'Für Whisky-Liebhaber haben wir erlesene Sorten aus Schottland und Irland im Angebot.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="circle-row">
|
<div class="circle-row">
|
||||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
<img src="${content.whiskeyImage1 || '/images/Whiskey1.png'}" alt="Whiskey 1" class="circle-image" />
|
<img src="${this.text(content.whiskeyImage1, '/images/Whiskey1.png')}" alt="Whiskey 1" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
<img src="${content.whiskeyImage2 || '/images/Whiskey2.png'}" alt="Whiskey 2" class="circle-image" />
|
<img src="${this.text(content.whiskeyImage2, '/images/Whiskey2.png')}" alt="Whiskey 2" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
<img src="${content.whiskeyImage3 || '/images/Whiskey3.png'}" alt="Whiskey 3" class="circle-image" />
|
<img src="${this.text(content.whiskeyImage3, '/images/Whiskey3.png')}" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { existsSync } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
// Verzeichnisse, in die das CMS hochlaedt. Die muessen einen Neu-Clone des
|
||||||
|
// Workspace ueberleben, sonst sind Bilder und Getraenkekarte weg.
|
||||||
|
const UPLOAD_DIRS = [
|
||||||
|
path.join('public', 'images'),
|
||||||
|
path.join('public', 'pdf'),
|
||||||
|
];
|
||||||
|
|
||||||
export class GitService {
|
export class GitService {
|
||||||
private git: SimpleGit;
|
private git: SimpleGit;
|
||||||
private workspaceDir: string;
|
private workspaceDir: string;
|
||||||
@@ -51,15 +58,17 @@ export class GitService {
|
|||||||
if (!usable) {
|
if (!usable) {
|
||||||
console.log('Cloning repository...');
|
console.log('Cloning repository...');
|
||||||
|
|
||||||
// Hochgeladene Bilder liegen im Workspace und wuerden beim Loeschen
|
// Hochgeladene Dateien liegen im Workspace und wuerden beim Loeschen
|
||||||
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
|
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
|
||||||
const imagesDir = path.join(this.workspaceDir, 'public', 'images');
|
const backupRoot = path.join(this.parentDir, '.workspace-upload-backup');
|
||||||
const backupDir = path.join(this.parentDir, '.workspace-images-backup');
|
await rm(backupRoot, { recursive: true, force: true });
|
||||||
const hasImages = existsSync(imagesDir);
|
|
||||||
|
|
||||||
await rm(backupDir, { recursive: true, force: true });
|
const saved: string[] = [];
|
||||||
if (hasImages) {
|
for (const relative of UPLOAD_DIRS) {
|
||||||
await cp(imagesDir, backupDir, { recursive: true });
|
const source = path.join(this.workspaceDir, relative);
|
||||||
|
if (!existsSync(source)) continue;
|
||||||
|
await cp(source, path.join(backupRoot, relative), { recursive: true });
|
||||||
|
saved.push(relative);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(this.workspaceDir, { recursive: true, force: true });
|
await rm(this.workspaceDir, { recursive: true, force: true });
|
||||||
@@ -70,15 +79,16 @@ export class GitService {
|
|||||||
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
await this.git.clone(authenticatedUrl, this.workspaceDir);
|
||||||
this.git = simpleGit(this.workspaceDir);
|
this.git = simpleGit(this.workspaceDir);
|
||||||
|
|
||||||
if (hasImages) {
|
for (const relative of saved) {
|
||||||
// force: false -> was schon im Repo liegt, bleibt unangetastet
|
// force: false -> was schon im Repo liegt, bleibt unangetastet
|
||||||
await cp(backupDir, imagesDir, {
|
await cp(path.join(backupRoot, relative), path.join(this.workspaceDir, relative), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
force: false,
|
force: false,
|
||||||
errorOnExist: false,
|
errorOnExist: false,
|
||||||
});
|
});
|
||||||
await rm(backupDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await rm(backupRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure git user
|
// Configure git user
|
||||||
@@ -117,6 +127,7 @@ export class GitService {
|
|||||||
const git = simpleGit(this.workspaceDir);
|
const git = simpleGit(this.workspaceDir);
|
||||||
await git.reset(['--hard', 'HEAD']);
|
await git.reset(['--hard', 'HEAD']);
|
||||||
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
|
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
|
||||||
await git.clean('f', ['-d', '-e', 'public/images']);
|
const excludes = UPLOAD_DIRS.flatMap((dir) => ['-e', dir.split(path.sep).join('/')]);
|
||||||
|
await git.clean('f', ['-d', ...excludes]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { events, galleryImages, contentSections } from '../db/schema.js';
|
||||||
|
import { AssetService } from './asset.service.js';
|
||||||
|
import { isManagedAsset, forgetManagedAsset } from './managed-assets.service.js';
|
||||||
|
|
||||||
|
const assets = new AssetService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loescht eine Bilddatei, sofern das CMS sie selbst angelegt hat und kein
|
||||||
|
* Datensatz sie mehr benutzt. Muss NACH dem Loeschen bzw. Aktualisieren der
|
||||||
|
* Zeile aufgerufen werden.
|
||||||
|
*/
|
||||||
|
export async function dropImageIfUnused(url: string | null | undefined): Promise<boolean> {
|
||||||
|
if (!url) return false;
|
||||||
|
if (!(await isManagedAsset(url))) return false;
|
||||||
|
|
||||||
|
const referenced = await collectReferencedImageUrls();
|
||||||
|
if (referenced.has(url)) return false;
|
||||||
|
|
||||||
|
const deleted = assets.deleteImage(url);
|
||||||
|
await forgetManagedAsset(url);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt alle vom CMS angelegten Bilder, die nirgends mehr referenziert
|
||||||
|
* werden. Die Referenzliste deckt bewusst ALLE Zeilen ab, auch
|
||||||
|
* unveroeffentlichte - sonst verlieren die ihr Bild.
|
||||||
|
*/
|
||||||
|
export async function sweepOrphanedImages(): Promise<string[]> {
|
||||||
|
const referenced = await collectReferencedImageUrls();
|
||||||
|
const removed: string[] = [];
|
||||||
|
|
||||||
|
for (const url of assets.listImageFiles()) {
|
||||||
|
if (referenced.has(url)) continue;
|
||||||
|
if (!(await isManagedAsset(url))) continue;
|
||||||
|
|
||||||
|
if (assets.deleteImage(url)) removed.push(url);
|
||||||
|
await forgetManagedAsset(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt jede Bild-URL, die irgendwo in der Datenbank vorkommt.
|
||||||
|
*
|
||||||
|
* Bewusst ueber ALLE Zeilen, nicht nur die veroeffentlichten - sonst wuerde
|
||||||
|
* ein unveroeffentlichtes Event sein Bild verlieren, sobald jemand publisht.
|
||||||
|
*
|
||||||
|
* Die Content-Sections enthalten beliebiges JSON (Welcome-Bild, Monatshit,
|
||||||
|
* Whiskey-Bilder), deshalb wird es rekursiv nach Bildpfaden durchsucht.
|
||||||
|
*/
|
||||||
|
export async function collectReferencedImageUrls(): Promise<Set<string>> {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(events)) as any[]) {
|
||||||
|
if (row.imageUrl) urls.add(row.imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(galleryImages)) as any[]) {
|
||||||
|
if (row.imageUrl) urls.add(row.imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of (await db.select().from(contentSections)) as any[]) {
|
||||||
|
collectFromJson(row.contentJson, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt Bildpfade in einem beliebigen Content-JSON anhand einer Zuordnung
|
||||||
|
* alt -> neu. Die Struktur bleibt dabei unveraendert.
|
||||||
|
*/
|
||||||
|
export function replaceImageUrls(value: any, mapping: Map<string, string>): any {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return mapping.get(value) ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => replaceImageUrls(entry, mapping));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out: Record<string, any> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
out[key] = replaceImageUrls(entry, mapping);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Bildpfade aus einem beliebigen Content-JSON. */
|
||||||
|
export function extractImageUrls(value: any): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
collectFromJson(value, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFromJson(value: any, out: Set<string>): void {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value.startsWith('/images/')) {
|
||||||
|
out.add(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Je nach Treiber kommt das JSON als String zurueck
|
||||||
|
if (value.startsWith('{') || value.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
collectFromJson(JSON.parse(value), out);
|
||||||
|
} catch {
|
||||||
|
// kein JSON - ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) collectFromJson(entry, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const entry of Object.values(value)) collectFromJson(entry, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { managedAssets } from '../db/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt Buch darueber, welche Dateien das CMS selbst angelegt hat.
|
||||||
|
* Nur diese darf es spaeter wieder loeschen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Altbestand: vor der Umstellung auf sprechende Namen hiessen Uploads
|
||||||
|
// <base36-zeitstempel>-<6 zeichen>.<ext>. Diese Dateien stehen nicht in der
|
||||||
|
// Tabelle, sollen aber weiterhin aufgeraeumt werden koennen. Handgepflegte
|
||||||
|
// Assets wie event_karaoke.jpg oder Gallery1.webp passen nicht auf das Muster.
|
||||||
|
const LEGACY_NAME = /^[a-z0-9]{6,14}-[a-z0-9]{6}\.[a-z0-9]{2,5}$/i;
|
||||||
|
|
||||||
|
function basename(urlPath: string): string {
|
||||||
|
return urlPath.split('/').pop() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merkt sich eine neu angelegte Datei. */
|
||||||
|
export async function registerManagedAsset(urlPath: string): Promise<void> {
|
||||||
|
await db.insert(managedAssets).values({ path: urlPath }).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergisst eine Datei wieder (nach dem Loeschen). */
|
||||||
|
export async function forgetManagedAsset(urlPath: string): Promise<void> {
|
||||||
|
await db.delete(managedAssets).where(eq(managedAssets.path, urlPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darf das CMS diese Datei loeschen? */
|
||||||
|
export async function isManagedAsset(urlPath: string): Promise<boolean> {
|
||||||
|
if (!urlPath) return false;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(managedAssets)
|
||||||
|
.where(eq(managedAssets.path, urlPath))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (row) return true;
|
||||||
|
|
||||||
|
return LEGACY_NAME.test(basename(urlPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle vom CMS angelegten Pfade aus der Tabelle. */
|
||||||
|
export async function listManagedAssets(): Promise<string[]> {
|
||||||
|
const rows = (await db.select().from(managedAssets)) as any[];
|
||||||
|
return rows.map((row) => row.path as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trifft der Altbestands-Namensstil zu? */
|
||||||
|
export function hasLegacyName(urlPath: string): boolean {
|
||||||
|
return LEGACY_NAME.test(basename(urlPath));
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { AssetService, MANAGED_IMAGE_DIRS, MANAGED_PDF_DIR } from './asset.service.js';
|
||||||
|
import { registerManagedAsset } from './managed-assets.service.js';
|
||||||
|
|
||||||
|
export type UploadSubdir = 'events' | 'gallery' | 'content';
|
||||||
|
|
||||||
|
export interface SavedImage {
|
||||||
|
filename: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = new AssetService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus "Karaoke-Abend im Gallus Pub!" -> "karaoke-abend-im-gallus-pub".
|
||||||
|
* Umlaute werden ausgeschrieben, nicht entfernt, damit aus "Getränke" nicht
|
||||||
|
* "getrnke" wird.
|
||||||
|
*/
|
||||||
|
export function slugify(value: string): string {
|
||||||
|
const slug = String(value || '')
|
||||||
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
||||||
|
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
|
||||||
|
.replace(/ß/g, 'ss')
|
||||||
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 60)
|
||||||
|
.replace(/-+$/, '');
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname ohne Endung, wie er vor dem Upload hiess. */
|
||||||
|
function originalBaseName(file: any): string {
|
||||||
|
const name = (file?.filename as string | undefined) || '';
|
||||||
|
return name.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht einen freien Namen. Gibt es <basis>.avif schon, wird -2, -3, ...
|
||||||
|
* angehaengt, damit zwei Events namens "Karaoke" sich nicht ueberschreiben.
|
||||||
|
*/
|
||||||
|
function findFreeName(base: string, ext: string, urlDir: string, dirs: string[]): string {
|
||||||
|
const safeBase = base || `datei-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 500; attempt++) {
|
||||||
|
const candidate = attempt === 1 ? `${safeBase}${ext}` : `${safeBase}-${attempt}${ext}`;
|
||||||
|
if (!assets.exists(`${urlDir}/${candidate}`, dirs)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sollte nie eintreten - lieber ein haesslicher Name als eine Endlosschleife
|
||||||
|
return `${safeBase}-${Date.now().toString(36)}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen Multipart-Upload entgegen, rechnet ihn auf 1600px herunter,
|
||||||
|
* wandelt nach AVIF und legt ihn unter public/images/<subdir> ab.
|
||||||
|
*
|
||||||
|
* Der Name kommt aus preferredName (Event-Titel bzw. Alt-Text) und faellt
|
||||||
|
* sonst auf den urspruenglichen Dateinamen zurueck.
|
||||||
|
*/
|
||||||
|
export async function saveUploadedImage(
|
||||||
|
file: any,
|
||||||
|
subdir: UploadSubdir,
|
||||||
|
options: { preferredName?: string; log?: { warn: (obj: any, msg: string) => void } } = {}
|
||||||
|
): Promise<SavedImage> {
|
||||||
|
const { preferredName, log } = options;
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const inputBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Ohne diese Pruefung landet bei zu grossen Dateien ein abgeschnittenes,
|
||||||
|
// kaputtes Bild auf der Platte
|
||||||
|
if (file.file?.truncated) {
|
||||||
|
const limit = Math.round(env.MAX_FILE_SIZE / 1024 / 1024);
|
||||||
|
const error: any = new Error(`Image too large. Maximum is ${limit} MB`);
|
||||||
|
error.statusCode = 413;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveImageBuffer(inputBuffer, subdir, {
|
||||||
|
preferredName: preferredName || originalBaseName(file),
|
||||||
|
fallbackExtension: '.' + ((file.mimetype || '').split('/')[1] || 'bin')
|
||||||
|
.replace(/[^a-z0-9]/gi, '').toLowerCase(),
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kern der Bildverarbeitung: verkleinern, nach AVIF wandeln, unter einem
|
||||||
|
* sprechenden Namen ablegen und als CMS-eigene Datei vermerken.
|
||||||
|
*
|
||||||
|
* Wird sowohl vom Upload als auch vom Umwandlungsskript fuer den Altbestand
|
||||||
|
* benutzt, damit beide Wege identisch arbeiten.
|
||||||
|
*/
|
||||||
|
export async function saveImageBuffer(
|
||||||
|
inputBuffer: Buffer,
|
||||||
|
subdir: UploadSubdir,
|
||||||
|
options: {
|
||||||
|
preferredName?: string;
|
||||||
|
fallbackExtension?: string;
|
||||||
|
log?: { warn: (obj: any, msg: string) => void };
|
||||||
|
} = {}
|
||||||
|
): Promise<SavedImage> {
|
||||||
|
const { preferredName, fallbackExtension = '.bin', log } = options;
|
||||||
|
|
||||||
|
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'images', subdir);
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
let outBuffer: Buffer;
|
||||||
|
let outExt = '.avif';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sharp erst laden wenn wirklich gebraucht
|
||||||
|
const sharp = (await import('sharp')).default;
|
||||||
|
outBuffer = await sharp(inputBuffer)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: 1600, withoutEnlargement: true })
|
||||||
|
.avif({ quality: 55 })
|
||||||
|
.toBuffer();
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn({ err }, 'Sharp processing failed, using original image');
|
||||||
|
outBuffer = inputBuffer;
|
||||||
|
outExt = fallbackExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlDir = `/images/${subdir}`;
|
||||||
|
const filename = findFreeName(slugify(preferredName || ''), outExt, urlDir, MANAGED_IMAGE_DIRS);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(uploadDir, filename), outBuffer);
|
||||||
|
|
||||||
|
const imageUrl = `${urlDir}/${filename}`;
|
||||||
|
await registerManagedAsset(imageUrl);
|
||||||
|
|
||||||
|
return { filename, imageUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt ein hochgeladenes PDF unter public/pdf ab, benannt nach dem
|
||||||
|
* urspruenglichen Dateinamen.
|
||||||
|
*/
|
||||||
|
export async function saveUploadedPdf(file: any, buffer: Buffer, preferredName?: string): Promise<string> {
|
||||||
|
const uploadDir = path.join(env.GIT_WORKSPACE_DIR, 'public', 'pdf');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
const base = slugify(preferredName || '') || slugify(originalBaseName(file)) || 'dokument';
|
||||||
|
const filename = findFreeName(base, '.pdf', '/pdf', [MANAGED_PDF_DIR]);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
||||||
|
|
||||||
|
const pdfUrl = `/pdf/${filename}`;
|
||||||
|
await registerManagedAsset(pdfUrl);
|
||||||
|
|
||||||
|
return pdfUrl;
|
||||||
|
}
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.12.0"
|
"astro": "^7.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 728 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 1021 KiB |
|
Before Width: | Height: | Size: 1021 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 90 KiB |
@@ -7,19 +7,19 @@ const { id } = Astro.props;
|
|||||||
<h2 class="title">Drinks</h2>
|
<h2 class="title">Drinks</h2>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Ob frisch gezapftes Pint, edler Whisky oder ein gemütliches Glas Wein – bei uns genießt du das Leben in entspannter Atmosphäre. Auch Cocktails dürfen nicht fehlen: Vieles kreieren wir selbst. Sláinte!
|
Ob ein frisch gezapftes Pint, ein edler Tropfen Whiskey oder ein gemütliches Glas Wein – hier kannst du in entspannter Atmosphäre das Leben genießen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="/pdf/Getraenke_Gallus_2025.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
<a href="/pdf/gallus-getraenkekarte-2026.pdf" class="card-link" target="_blank" rel="noopener noreferrer">Getränkekarte</a>
|
||||||
|
|
||||||
<h3 class="monats-hit">Monats Hit</h3>
|
<h3 class="monats-hit">Monats Hit</h3>
|
||||||
|
|
||||||
<div class="mate-vodka">
|
<div class="mate-vodka">
|
||||||
<div class="circle" title="Mate Vodka">
|
<div class="circle" title="mit oder ohne Schuss ;)">
|
||||||
<img src="/images/MonthlyHit.png" alt="Monats Hit" class="circle-image" />
|
<img src="/images/content/mit-oder-ohne-schuss.avif" alt="Monats Hit" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>Mate Vodka</div>
|
<div>mit oder ohne Schuss ;)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="note">
|
<p class="note">
|
||||||
@@ -28,15 +28,15 @@ const { id } = Astro.props;
|
|||||||
|
|
||||||
<div class="circle-row">
|
<div class="circle-row">
|
||||||
<div class="circle whiskey-circle" title="Whiskey 1">
|
<div class="circle whiskey-circle" title="Whiskey 1">
|
||||||
<img src="/images/whiskey/Whiskey1.png" alt="Whiskey 1" class="circle-image" />
|
<img src="/images/content/whiskey-1.avif" alt="Whiskey 1" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 2">
|
<div class="circle whiskey-circle" title="Whiskey 2">
|
||||||
<img src="/images/whiskey/Whiskey2.png" alt="Whiskey 2" class="circle-image" />
|
<img src="/images/content/whiskey-2.avif" alt="Whiskey 2" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="circle whiskey-circle" title="Whiskey 3">
|
<div class="circle whiskey-circle" title="Whiskey 3">
|
||||||
<img src="/images/whiskey/Whiskey3.png" alt="Whiskey 3" class="circle-image" />
|
<img src="/images/content/whiskey-3.avif" alt="Whiskey 3" class="circle-image" />
|
||||||
<span class="circle-label"></span>
|
<span class="circle-label"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||