security: Härtung der CMS-API + Deployment

App-Level:
- Security-Header (secureHeaders) global; /images/* mit strikter CSP+sandbox
  → bösartiges SVG kann kein JS im Origin ausführen
- Body-Limit 256 KB auf /api/*; Login-Rate-Limit (10/5min) gegen Brute-Force
- Upload: 8-MB-Limit + Format-Verifikation (sharp-Metadaten, SVG/GIF-Signatur)
- Comment-Längenlimit (10k) gegen DB-Bloat
- DB-Fehler nicht mehr roh ausliefern (serverError-Helper)
- Profil-PUT koalesziert Hugo-Builds (kein Build-Sturm)

Infra:
- Container läuft non-root (USER node, uid 1000) + Proxmox-Repo-chown
- Ports binden per Default auf 127.0.0.1 (BIND_ADDR-Escape-Hatch)
- Kong-CORS auf SITE_URL beschränkt statt "*"
- README: Härtungs- + Migrationshinweise

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:05:57 +02:00
parent 6d20be036a
commit 2650913050
13 changed files with 222 additions and 22 deletions
+7 -1
View File
@@ -20,7 +20,13 @@ API_EXTERNAL_URL=http://localhost:8000
# unter `authors` steht.
ADMIN_EMAILS=karim@gabrielevarano.ch
# ═══ Optional: Ports ═══
# ═══ Optional: Ports & Binding ═══
# Auf welcher Host-Adresse lauschen die veröffentlichten Ports?
# 127.0.0.1 (Standard) = nur lokal / hinter Reverse-Proxy mit TLS (empfohlen).
# 0.0.0.0 = direkt im LAN erreichbar (ohne Proxy).
# Bei 127.0.0.1 muss SITE_URL/API_EXTERNAL_URL über den Proxy laufen, sonst
# erreicht der Browser :8000/:8080 nicht.
BIND_ADDR=127.0.0.1
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
KONG_HTTP_PORT=8000 # Supabase-API-Gateway
KONG_HTTPS_PORT=8443
+37 -2
View File
@@ -72,8 +72,11 @@ Fragt interaktiv nur Storage/Bridge/IP ab (Enter = Default). Kein Token nötig.
2. `POSTGRES_PASSWORD` + `JWT_SECRET` setzen: je `openssl rand -hex 32`
3. Keys ableiten: `node scripts/generate-keys.mjs``ANON_KEY` + `SERVICE_ROLE_KEY` in `.env`
4. `SITE_URL` + `API_EXTERNAL_URL` auf die LAN-/Domain-Adresse setzen
5. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations)
6. Login-User anlegen (Self-Signup ist aus):
5. `kong.yml`: Platzhalter `__CORS_ORIGIN__` durch `SITE_URL` (Browser-Origin) ersetzen
6. `BIND_ADDR` in `.env`: `127.0.0.1` hinter Reverse-Proxy (Standard), `0.0.0.0` für LAN-Direktzugriff
7. Repo dem Container-User (uid 1000) übereignen: `chown -R 1000:1000 <repo-root>`
8. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations)
9. Login-User anlegen (Self-Signup ist aus):
```
source .env
curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \
@@ -89,6 +92,38 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
`/_preview` an den laufenden Container auf :8080).
## Sicherheit / Härtung
Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass):
- **Sicherheits-Header** auf allen Antworten (X-Frame-Options, nosniff,
Referrer-Policy, HSTS); Uploads unter `/images/*` mit strikter CSP +
`sandbox` → ein bösartiges SVG kann kein JavaScript im Origin ausführen.
- **Rate-Limit** auf `/api/auth/login` (10 Versuche/IP pro 5 Min) gegen Brute-Force.
- **Body-Limit** 256 KB auf JSON-`/api/*`, Bild-Upload max. 8 MB mit
Format-Verifikation (sharp-Metadaten bzw. SVG/GIF-Signatur).
- **Comment-Limits** (Body ≤ 10 000 Zeichen) gegen DB-Bloat.
- **Kein Info-Leak**: rohe DB-Fehler werden serverseitig geloggt, nach außen
nur generische Meldungen.
- **Non-root**: der CMS-Container läuft als `node` (uid 1000).
- **Port-Binding** über `BIND_ADDR` (Standard `127.0.0.1`), DB nur auf localhost.
- **CORS** am Kong-Gateway auf die eigene `SITE_URL`-Origin beschränkt (kein `*`).
### Migration eines bestehenden Containers
Bei `git pull` auf einer schon laufenden Instanz greifen drei Änderungen, die
sonst einen frischen Deploy voraussetzen — **vor** dem nächsten
`docker compose up -d --build` von Hand nachziehen:
1. **Non-root:** `chown -R 1000:1000 <repo-root>` — sonst kann Hugo `public/`
nicht mehr bauen (Permission denied).
2. **CORS:** `kong.yml` enthält jetzt `__CORS_ORIGIN__`; auf einem bereits
initialisierten Container ersetzt das Proxmox-Script den Platzhalter nicht.
Manuell auf die `SITE_URL` setzen, sonst werden alle Browser-API-Calls
(inkl. Login) per CORS geblockt.
3. **BIND_ADDR:** Key in `.env` ergänzen. Default `127.0.0.1` ist hinter einem
TLS-Proxy korrekt; für LAN-Direktzugriff `0.0.0.0` setzen.
## API
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
+7
View File
@@ -40,5 +40,12 @@ COPY --from=admin /admin/dist ./admin-dist
ENV NODE_ENV=production
ENV ADMIN_DIR=/app/admin-dist
# Als non-root laufen (das node-Image bringt den User `node`, uid/gid 1000 mit).
# /app gehört dem Build (root, read-only zur Laufzeit — reicht zum Servieren).
# Das gemountete Repo unter /site muss uid 1000 gehören (siehe Proxmox-Script:
# chown -R 1000:1000), damit Hugo dort public/ bauen und content/ schreiben kann.
USER node
EXPOSE 3000
CMD ["sh", "/app/entrypoint.sh"]
+29 -1
View File
@@ -1,7 +1,10 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers';
import { bodyLimit } from 'hono/body-limit';
import { rateLimit } from './ratelimit.js';
import content from './routes/content.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
@@ -21,7 +24,31 @@ const PORT = Number(process.env.PORT || 3000);
const app = new Hono();
// --- Sicherheits-Header (auf allem) ---
// CSP bewusst zurückhaltend: Site + Admin-SPA + Dialog-Widget laufen same-origin.
app.use('*', secureHeaders({
xFrameOptions: 'SAMEORIGIN',
xContentTypeOptions: 'nosniff',
referrerPolicy: 'strict-origin-when-cross-origin',
crossOriginOpenerPolicy: 'same-origin',
// HSTS nur sinnvoll hinter TLS-Proxy; schadet via HTTP nicht (Browser ignoriert).
strictTransportSecurity: 'max-age=31536000; includeSubDomains',
}));
// Hochgeladene Bilder strikt isolieren: ein bösartiges SVG kann so kein
// JavaScript im Origin ausführen (sandbox + keine Skript-Quellen).
app.use('/images/*', secureHeaders({
contentSecurityPolicy: { defaultSrc: ["'none'"], imgSrc: ["'self'"], styleSrc: ["'unsafe-inline'"], sandbox: [] },
xContentTypeOptions: 'nosniff',
}));
// --- API ---
// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad
// ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit.
const jsonBodyLimit = bodyLimit({ maxSize: 256 * 1024, onError: (c) => c.json({ error: 'Anfrage zu groß' }, 413) });
app.use('/api/*', (c, next) =>
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
app.get('/api/comments', listComments);
@@ -29,7 +56,8 @@ app.get('/api/forums', listForums);
app.get('/api/forums/:slug', showForum);
app.get('/api/recent', recent);
app.get('/api/thread', threadInfo);
app.post('/api/auth/login', login);
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth);
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
+34
View File
@@ -0,0 +1,34 @@
// Einfacher In-Memory-Rate-Limiter (ein Container, eine Instanz → genügt).
// Fixed-Window pro Schlüssel (Standard: Client-IP). Bei Überschreitung 429.
// Hinter einem Reverse-Proxy liefert X-Forwarded-For die echte IP.
const buckets = new Map(); // key -> { count, reset }
function clientIp(c) {
const xff = c.req.header('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown';
}
// max Anfragen je windowMs. keyFn erlaubt eigene Schlüssel (z.B. IP+E-Mail).
export function rateLimit({ max = 10, windowMs = 60_000, keyFn = clientIp } = {}) {
return async (c, next) => {
const key = keyFn(c);
const now = Date.now();
let b = buckets.get(key);
if (!b || now > b.reset) { b = { count: 0, reset: now + windowMs }; buckets.set(key, b); }
b.count += 1;
if (b.count > max) {
const retry = Math.ceil((b.reset - now) / 1000);
c.header('Retry-After', String(retry));
return c.json({ error: 'Zu viele Anfragen — bitte später erneut.' }, 429);
}
await next();
};
}
// Speicher sauber halten: abgelaufene Buckets periodisch wegräumen.
setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k);
}, 5 * 60_000).unref?.();
+8 -3
View File
@@ -1,10 +1,13 @@
import { supabase, supabaseAuth } from '../supabase.js';
import { roleOf } from '../auth.js';
import { profileFor, threadLocked } from '../dialog-store.js';
import { serverError } from '../util.js';
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
const COLS = 'id,thread,parent_id,author_name,author_avatar,author_role,body,created_at,deleted';
const MAX_BODY = 10_000; // Zeichen je Wortmeldung
const MAX_THREAD = 512; // Thread-Key-Länge
// ÖFFENTLICH: Wortmeldungen eines Threads lesen.
export async function listComments(c) {
@@ -12,7 +15,7 @@ export async function listComments(c) {
if (!thread) return c.json({ error: 'thread fehlt' }, 400);
const { data, error } = await supabase
.from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true });
if (error) return c.json({ error: error.message }, 500);
if (error) return serverError(c, 'listComments', error);
const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r));
return c.json(out);
}
@@ -23,6 +26,8 @@ export async function createComment(c) {
const email = c.get('email');
const { thread, body, parent_id } = await c.req.json();
if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400);
if (typeof thread !== 'string' || thread.length > MAX_THREAD) return c.json({ error: 'Ungültiger Thread' }, 400);
if (typeof body !== 'string' || body.length > MAX_BODY) return c.json({ error: `Text zu lang (max. ${MAX_BODY} Zeichen)` }, 400);
if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403);
const prof = await profileFor(email);
@@ -36,7 +41,7 @@ export async function createComment(c) {
body: body.trim(),
};
const { data, error } = await supabase.from('comments').insert(row).select(COLS).single();
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'createComment', error, 400);
return c.json(data, 201);
}
@@ -49,7 +54,7 @@ export async function deleteComment(c) {
if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'deleteComment', error, 400);
return c.json({ ok: true });
}
+7 -6
View File
@@ -1,6 +1,7 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin, requireModerator } from '../auth.js';
import { serverError } from '../util.js';
import {
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
} from '../dialog-store.js';
@@ -56,7 +57,7 @@ mod.post('/thread-lock', async (c) => {
const { key, locked } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
// Thread ausblenden (löschen).
@@ -64,7 +65,7 @@ mod.post('/thread-delete', async (c) => {
const { key } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
@@ -73,7 +74,7 @@ export const adminForums = new Hono();
adminForums.use('*', requireAdmin);
adminForums.get('/', async (c) => {
const { data, error } = await supabase.from('forums').select('*').order('sort');
if (error) return c.json({ error: error.message }, 500);
if (error) return serverError(c, 'dialog', error, 500);
return c.json(data || []);
});
adminForums.post('/', async (c) => {
@@ -82,7 +83,7 @@ adminForums.post('/', async (c) => {
const row = { slug: String(slug).trim(), name: String(name).trim(),
description: description || '', color: color || null, sort: Number(sort) || 0 };
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'dialog', error, 400);
return c.json(data, 201);
});
adminForums.put('/:id', async (c) => {
@@ -90,7 +91,7 @@ adminForums.put('/:id', async (c) => {
const allowed = {};
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single();
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'dialog', error, 400);
return c.json(data);
});
adminForums.delete('/:id', async (c) => {
@@ -98,6 +99,6 @@ adminForums.delete('/:id', async (c) => {
const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single();
if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400);
const { error } = await supabase.from('forums').delete().eq('id', id);
if (error) return c.json({ error: error.message }, 400);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
+16 -2
View File
@@ -19,6 +19,20 @@ function slugify(s) {
}
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
// Hugo-Build koaleszieren: nie zwei parallel, und schnelle Folge-Speicherungen
// lösen nur EINEN nachgelagerten Build aus (verhindert Build-Sturm/DoS).
let building = false, rerun = false;
async function rebuildSite() {
if (building) { rerun = true; return; }
building = true;
try {
do {
rerun = false;
await hugoBuild({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
} while (rerun);
} finally { building = false; }
}
const profile = new Hono();
profile.get('/', async (c) => {
@@ -46,8 +60,8 @@ profile.put('/', async (c) => {
}
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
// Live bauen, damit die Seite + Byline-Links sofort funktionieren.
await hugoBuild({ dest: 'public', drafts: false }).catch(() => {});
// Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
await rebuildSite();
}
return c.json({ ok: true, slug });
+33 -4
View File
@@ -6,8 +6,14 @@ import sharp from 'sharp';
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
// SVG/GIF bleiben unangetastet (Vektor/Animation erhalten).
//
// Sicherheit: hartes Größenlimit (DoS / Decompression-Bombs), Raster wird über
// sharp-Metadaten als echtes Bild verifiziert, SVG nur wenn es wie SVG aussieht.
// Hochgeladene Dateien werden zudem mit strikter CSP (sandbox) ausgeliefert
// (siehe index.js, /images/*) → ein bösartiges SVG kann kein JS im Origin starten.
const SITE_DIR = process.env.SITE_DIR || '/site';
const PASSTHROUGH = new Set(['.svg', '.gif']);
const MAX_UPLOAD = 8 * 1024 * 1024; // 8 MB Rohdatei
const ALLOWED_RASTER = new Set(['jpeg', 'png', 'webp', 'avif', 'tiff']);
const upload = new Hono();
@@ -15,19 +21,42 @@ 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);
if (typeof file.size === 'number' && file.size > MAX_UPLOAD) {
return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
}
const buffer = Buffer.from(await file.arrayBuffer());
if (buffer.length > MAX_UPLOAD) return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
if (!buffer.length) return c.json({ error: 'Leere Datei' }, 400);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const ext = path.extname(file.name || '').toLowerCase();
const base = `${safeBase(file.name)}-${uid()}`;
let outName, outBuf;
if (PASSTHROUGH.has(ext)) {
outName = `${base}${ext}`;
if (ext === '.svg') {
// Muss wie SVG aussehen (Magie/Marker), sonst ablehnen.
const head = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase();
if (!head.startsWith('<?xml') && !head.startsWith('<svg')) {
return c.json({ error: 'Keine gültige SVG-Datei' }, 400);
}
outName = `${base}.svg`;
outBuf = buffer;
} else if (ext === '.gif') {
// GIF-Magie prüfen (kann kein Skript ausführen → Passthrough ok).
const sig = buffer.subarray(0, 6).toString('latin1');
if (sig !== 'GIF87a' && sig !== 'GIF89a') return c.json({ error: 'Keine gültige GIF-Datei' }, 400);
outName = `${base}.gif`;
outBuf = buffer;
} else {
// Raster: über sharp als echtes Bild verifizieren, dann zu WebP.
let meta;
try { meta = await sharp(buffer).metadata(); } catch { meta = null; }
if (!meta || !ALLOWED_RASTER.has(meta.format)) {
return c.json({ error: 'Kein unterstütztes Bildformat' }, 400);
}
outName = `${base}.webp`;
outBuf = await sharp(buffer)
.rotate()
+6
View File
@@ -0,0 +1,6 @@
// Serverfehler protokollieren, aber dem Client nur eine generische Meldung
// geben — keine DB-/Stack-Interna nach außen (Info-Leak vermeiden).
export function serverError(c, where, err, status = 500) {
console.error(`[${where}]`, err?.message || err);
return c.json({ error: 'Serverfehler' }, status);
}
+9 -3
View File
@@ -6,7 +6,10 @@
# 2. JWT_SECRET + POSTGRES_PASSWORD setzen (openssl rand -hex 32)
# 3. node scripts/generate-keys.mjs → ANON_KEY + SERVICE_ROLE_KEY in .env
# 4. SITE_URL + API_EXTERNAL_URL auf die LAN-/Domain-Adresse setzen
# 5. kong.yml: __CORS_ORIGIN__ durch SITE_URL ersetzen (Browser-Origin)
# 6. BIND_ADDR: 127.0.0.1 hinter Reverse-Proxy, 0.0.0.0 für LAN-Direkt
#
# (Das Proxmox-Script erledigt 16 automatisch.)
# Dann: docker compose up -d --build
#
# Abweichung von RAPPORT: realtime + storage weggelassen (nutzt das CMS nicht).
@@ -129,8 +132,10 @@ services:
volumes:
- ./kong.yml:/var/lib/kong/kong.yml:ro
ports:
- "${KONG_HTTP_PORT:-8000}:8000"
- "${KONG_HTTPS_PORT:-8443}:8443"
# Standard 127.0.0.1: nur lokal/Reverse-Proxy erreichbar. Für LAN-Direkt-
# zugriff ohne Proxy BIND_ADDR=0.0.0.0 in .env setzen.
- "${BIND_ADDR:-127.0.0.1}:${KONG_HTTP_PORT:-8000}:8000"
- "${BIND_ADDR:-127.0.0.1}:${KONG_HTTPS_PORT:-8443}:8443"
# ════════════════════════════════════════════════════════════════════════
# CMS — Node-API + Hugo-Binary + Admin-SPA, serviert die Site
@@ -168,7 +173,8 @@ services:
# Repo-Root: api schreibt content/ und baut public/ + preview/.
- ..:/site
ports:
- "${APP_PORT:-8080}:3000"
# Wie Kong: standardmäßig nur 127.0.0.1 (hinter Reverse-Proxy).
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-8080}:3000"
volumes:
postgres-data:
+20
View File
@@ -15,6 +15,16 @@ services:
- /auth/v1/
plugins:
- name: cors
config:
# Nur die eigene Browser-Origin erlauben (nicht „*"). __CORS_ORIGIN__
# wird beim Provisionieren auf SITE_URL gesetzt (siehe Proxmox-Script);
# bei Domain/HTTPS-Wechsel hier bzw. in .env mitziehen.
origins:
- __CORS_ORIGIN__
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Accept, Authorization, Content-Type, apikey, x-client-info, x-supabase-api-version]
credentials: false
max_age: 3600
- name: rest-v1
url: http://rest:3000/
@@ -25,3 +35,13 @@ services:
- /rest/v1/
plugins:
- name: cors
config:
# Nur die eigene Browser-Origin erlauben (nicht „*"). __CORS_ORIGIN__
# wird beim Provisionieren auf SITE_URL gesetzt (siehe Proxmox-Script);
# bei Domain/HTTPS-Wechsel hier bzw. in .env mitziehen.
origins:
- __CORS_ORIGIN__
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Accept, Authorization, Content-Type, apikey, x-client-info, x-supabase-api-version]
credentials: false
max_age: 3600
+9
View File
@@ -147,9 +147,18 @@ pct exec "$CTID" -- bash -euo pipefail -c "
sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env
sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env
# Out-of-box LAN-Direktzugriff (kein Reverse-Proxy) → auf allen Interfaces
# lauschen. Für Domain/HTTPS hinter Proxy: BIND_ADDR=127.0.0.1 setzen.
sed -i \"s|^BIND_ADDR=.*|BIND_ADDR=0.0.0.0|\" .env
# CORS auf die Browser-Origin (= SITE_URL) festnageln statt „*\".
sed -i \"s|__CORS_ORIGIN__|http://\${HOSTIP}:8080|g\" kong.yml
echo 'OK: .env generiert.'
fi
# Der CMS-Container läuft als non-root (uid 1000). Das gemountete Repo muss
# ihm gehören, damit Hugo public/ bauen und content/ schreiben kann.
chown -R 1000:1000 '${APP_DIR}'
if [ '${COMPOSE_UP}' = 'true' ]; then
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
docker compose up -d --build