cms: headless CMS vor Hugo (Supabase + Node-API + React-Admin)

All-in-One docker-compose-Stack (Muster von RAPPORT-SERVER gespiegelt):
db/auth/rest/kong + cms-Service (Node-API + Hugo-Binary 0.161.1 + Admin-SPA).

- DB-backed: posts-Tabelle kanonisch, MD ist generiertes Artefakt
- echte Hugo-Vorschau via draft:true + --buildDrafts → /_preview
- Publish: DB → content/library/<section>/<slug>.md → hugo build → live
- Bild-Upload nach static/images/, Supabase-Auth schützt /api/*
- Proxmox-LXC-Script: legt Container an, generiert Secrets, startet Stack

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 00:21:04 +02:00
parent 7a5be9250a
commit 60e5ef6844
31 changed files with 3616 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# --- Stage 1: Admin-SPA bauen ---
# (Build-Context ist cms/, siehe docker-compose.yml)
FROM node:24-bookworm-slim AS admin
WORKDIR /admin
COPY admin/package.json admin/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY admin/ ./
# Öffentliche Browser-Werte, zur Build-Zeit eingesetzt.
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
RUN npm run build
# --- Stage 2: API + Hugo + serviert Site/Admin ---
# Debian-slim statt Alpine: Hugo "extended" ist glibc-gelinkt.
FROM node:24-bookworm-slim
ARG HUGO_VERSION=0.161.1
ARG TARGETARCH=amd64
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git curl \
&& rm -rf /var/lib/apt/lists/* \
&& case "${TARGETARCH}" in \
arm64) HUGO_ARCH=linux-arm64 ;; \
*) HUGO_ARCH=linux-amd64 ;; \
esac \
&& curl -sSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin hugo \
&& hugo version
WORKDIR /app
COPY api/package.json api/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY api/src ./src
COPY --from=admin /admin/dist ./admin-dist
ENV NODE_ENV=production
ENV ADMIN_DIR=/app/admin-dist
EXPOSE 3000
CMD ["node", "src/index.js"]
+246
View File
@@ -0,0 +1,246 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openbureau-cms-api",
"version": "0.1.0",
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"gray-matter": "^4.0.3",
"hono": "^4.6.14"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.2",
"@supabase/functions-js": "2.106.2",
"@supabase/postgrest-js": "2.106.2",
"@supabase/realtime-js": "2.106.2",
"@supabase/storage-js": "2.106.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"gray-matter": "^4.0.3",
"hono": "^4.6.14"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { supabase } from './supabase.js';
// Verifiziert den Supabase-Access-Token aus dem Authorization-Header gegen den
// Supabase-Auth-Server. Schützt alle /api/* ausser /api/health.
export async function requireAuth(c, next) {
const header = c.req.header('Authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
const { data, error } = await supabase.auth.getUser(token);
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
c.set('user', data.user);
await next();
}
+37
View File
@@ -0,0 +1,37 @@
import { mkdir, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { rowToMarkdown } from './render.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
// Erlaubt nur sichere, einfache Segmente — verhindert Path-Traversal über
// section/slug aus der DB.
function safeSegment(value, label) {
if (!value || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(String(value))) {
throw new Error(`Ungültiger ${label}: ${JSON.stringify(value)}`);
}
return value;
}
// Posts leben unter content/library/<section>/<slug>.md → URL /library/<section>/<slug>/.
const CONTENT_BASE = 'library';
export function postPath(post) {
const section = safeSegment(post.section, 'section');
const slug = safeSegment(post.slug, 'slug');
return path.join(SITE_DIR, 'content', CONTENT_BASE, section, `${slug}.md`);
}
// Schreibt die generierte MD nach content/<section>/<slug>.md.
// draft:true -> Live-Build (ohne --buildDrafts) lässt den Post aus.
// draft:false -> Post ist live.
export async function writePostFile(post, { draft = false } = {}) {
const file = postPath(post);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, rowToMarkdown(post, { draft }), 'utf8');
return file;
}
export async function removePostFile(post) {
await rm(postPath(post), { force: true });
}
+41
View File
@@ -0,0 +1,41 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileP = promisify(execFile);
const SITE_DIR = process.env.SITE_DIR || '/site';
// Baut die Site. dest ist relativ zum Repo-Root (z.B. "public" oder "preview").
// drafts:true => --buildDrafts (für die Vorschau).
export async function hugoBuild({ dest, drafts = false } = {}) {
const args = ['--source', SITE_DIR, '--destination', dest, '--cleanDestinationDir'];
if (drafts) args.push('--buildDrafts');
const { stdout, stderr } = await execFileP('hugo', args, {
cwd: SITE_DIR,
maxBuffer: 10 * 1024 * 1024,
});
return { stdout, stderr };
}
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
// das Publish soll an einem Git-Problem nicht scheitern.
export async function gitCommit(message) {
if (process.env.GIT_PUBLISH !== 'true') return { skipped: true };
const env = {
...process.env,
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
GIT_COMMITTER_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
GIT_COMMITTER_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
};
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { env });
await git('add', 'content');
// Nichts zu committen? Dann ruhig raus.
const status = await git('status', '--porcelain', 'content');
if (!status.stdout.trim()) return { nothing: true };
await git('commit', '-m', message);
await git('push', process.env.GIT_REMOTE || 'origin', process.env.GIT_BRANCH || 'main');
return { committed: true };
}
+50
View File
@@ -0,0 +1,50 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import posts from './routes/posts.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
import upload from './routes/upload.js';
import { requireAuth } from './auth.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
const PORT = Number(process.env.PORT || 3000);
const app = new Hono();
// --- API ---
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// Alles unter /api/* (ausser /health oben) braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth);
app.route('/api/posts', posts);
app.route('/api/preview', preview);
app.route('/api/publish', publish);
app.route('/api/upload', upload);
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
app.use(
'/admin/*',
serveStatic({
root: ADMIN_DIR,
rewriteRequestPath: (p) => p.replace(/^\/admin/, '') || '/',
}),
);
// --- Vorschau (gebaut nach preview/ mit --buildDrafts) ---
app.use(
'/_preview/*',
serveStatic({
root: `${SITE_DIR}/preview`,
rewriteRequestPath: (p) => p.replace(/^\/_preview/, ''),
}),
);
// --- Live-Site (gebaut nach public/) ---
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
});
+30
View File
@@ -0,0 +1,30 @@
import matter from 'gray-matter';
// Eine posts-Zeile aus Supabase -> Hugo-Markdown (Frontmatter + Body).
// Mappt exakt die Felder, die OPENBUREAU im content/ nutzt. Nur gesetzte
// Felder landen im Frontmatter, damit die MD sauber bleibt.
export function rowToMarkdown(post, { draft = false } = {}) {
const fm = {
title: post.title,
// date als reines YYYY-MM-DD ausgeben (wie in den bestehenden Posts).
date: toDateOnly(post.date),
};
if (post.weight != null) fm.weight = post.weight;
if (Array.isArray(post.tags) && post.tags.length) fm.tags = post.tags;
if (post.summary) fm.summary = post.summary;
if (post.cover_image) fm.cover_image = post.cover_image;
if (post.layout) fm.layout = post.layout;
if (post.external) fm.external = post.external;
if (post.color) fm.color = post.color;
if (draft) fm.draft = true;
return matter.stringify(post.body || '', fm);
}
function toDateOnly(d) {
if (!d) return undefined;
// Akzeptiert Date, ISO-String oder "YYYY-MM-DD".
const s = typeof d === 'string' ? d : new Date(d).toISOString();
return s.slice(0, 10);
}
+50
View File
@@ -0,0 +1,50 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
// Minimales CRUD, damit der Publish/Preview-Flow ohne UI testbar ist.
// (Auth-Middleware kommt im nächsten Meilenstein.)
const posts = new Hono();
posts.get('/', async (c) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('date', { ascending: false });
if (error) return c.json({ error: error.message }, 500);
return c.json(data);
});
posts.get('/:id', async (c) => {
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('id', c.req.param('id'))
.single();
if (error) return c.json({ error: error.message }, 404);
return c.json(data);
});
posts.post('/', async (c) => {
const body = await c.req.json();
const { data, error } = await supabase
.from('posts')
.insert({ ...body, status: 'draft' })
.select()
.single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data, 201);
});
posts.put('/:id', async (c) => {
const body = await c.req.json();
const { data, error } = await supabase
.from('posts')
.update({ ...body, updated_at: new Date().toISOString() })
.eq('id', c.req.param('id'))
.select()
.single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data);
});
export default posts;
+27
View File
@@ -0,0 +1,27 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { writePostFile } from '../content.js';
import { hugoBuild } from '../hugo.js';
// Echte Hugo-Vorschau: Post als draft:true in content/ schreiben und mit
// --buildDrafts nach preview/ bauen. Der Live-Build (public/) lässt den
// Draft weiterhin aus.
const preview = new Hono();
preview.post('/:id', async (c) => {
const id = c.req.param('id');
const { data: post, error } = await supabase
.from('posts').select('*').eq('id', id).single();
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
try {
await writePostFile(post, { draft: true });
const build = await hugoBuild({ dest: 'preview', drafts: true });
const url = `/_preview/library/${post.section}/${post.slug}/`;
return c.json({ ok: true, url, hugo: build.stdout });
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default preview;
+41
View File
@@ -0,0 +1,41 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { writePostFile } from '../content.js';
import { hugoBuild, gitCommit } from '../hugo.js';
// Publizieren: Post als live (draft:false) nach content/ schreiben, public/
// neu bauen, Status setzen und optional nach Gitea committen.
const publish = new Hono();
publish.post('/:id', async (c) => {
const id = c.req.param('id');
const { data: post, error } = await supabase
.from('posts').select('*').eq('id', id).single();
if (error || !post) return c.json({ error: 'Post nicht gefunden' }, 404);
try {
const file = await writePostFile(post, { draft: false });
const build = await hugoBuild({ dest: 'public', drafts: false });
const { error: upErr } = await supabase
.from('posts')
.update({ status: 'published', published_at: new Date().toISOString() })
.eq('id', id);
if (upErr) return c.json({ error: upErr.message }, 500);
const git = await gitCommit(`cms: publish ${post.section}/${post.slug}`)
.catch((e) => ({ error: String(e.message || e) }));
return c.json({
ok: true,
path: file.replace(process.env.SITE_DIR || '/site', '').replace(/^\//, ''),
url: `/library/${post.section}/${post.slug}/`,
git,
hugo: build.stdout,
});
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default publish;
+34
View File
@@ -0,0 +1,34 @@
import { Hono } from 'hono';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const SITE_DIR = process.env.SITE_DIR || '/site';
// Bild-Upload → static/images/<name>. Hugo kopiert das beim Build nach
// public/images/, cover_image referenziert es als /images/<name>.
const upload = new Hono();
upload.post('/', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
const name = safeName(file.name);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, name), Buffer.from(await file.arrayBuffer()));
return c.json({ url: `/images/${name}` });
});
// Sicherer Dateiname: nur basename, kleingeschrieben, ohne Pfad/Sonderzeichen.
function safeName(raw) {
const base = path.basename(String(raw || 'bild'));
const cleaned = base
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned || 'bild';
}
export default upload;
+14
View File
@@ -0,0 +1,14 @@
import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY;
if (!url || !key) {
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
process.exit(1);
}
// Service-Role-Key: server-seitig, umgeht RLS. Niemals ins Frontend geben.
export const supabase = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});