Compare commits
57 Commits
f82214719a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 51bf175450 | |||
| d6c5b2edb4 | |||
| 42f2823ff0 | |||
| 1709dc093c | |||
| 46ee91279e | |||
| 827a04cfe8 | |||
| 98955bc097 | |||
| 886c090a8a | |||
| f8c82ad4a2 | |||
| 04bb79bcfa | |||
| 30fc688622 | |||
| 234ed52fa8 | |||
| fc422c78d0 | |||
| 6e4bb06f5f | |||
| d5f35bb9f8 | |||
| ef024921ab | |||
| 42de32888c | |||
| fbf8ee1ebf | |||
| 790141bafe | |||
| 54f03270d0 | |||
| b4fdc8c200 | |||
| 0f80378a7c | |||
| 3dd8d5edd4 | |||
| 0f574bf8a7 | |||
| 9c9b7e03bd | |||
| d9ba2f7bbe | |||
| 37fdc9019c | |||
| 1fb4556ac1 | |||
| 9163f5c90d | |||
| 50cec7a965 | |||
| f434885c28 | |||
| b67b24a53c | |||
| b72f744963 | |||
| fcbe91c0da | |||
| 1196f9adf6 | |||
| e62b4c3704 | |||
| fce6c9eabc | |||
| 6aa88a07a6 | |||
| f2aef5c89a | |||
| 0cc90ac295 | |||
| 22c9b9ff61 | |||
| 0ce2c73004 | |||
| c6f5beaa7b | |||
| a4ca05c88f | |||
| 656da26347 | |||
| c3c8c9639f | |||
| 9f9071d23f | |||
| 272d30357f | |||
| f97999c3c0 | |||
| 8404165f5c | |||
| d0b5c6f670 | |||
| 2650913050 | |||
| 6d20be036a | |||
| 7b25f644a2 | |||
| 4f4eccd475 | |||
| af587af851 | |||
| 309d12f8a2 |
@@ -11,6 +11,8 @@ hugo_stats.json
|
|||||||
|
|
||||||
# Node (CMS)
|
# Node (CMS)
|
||||||
node_modules/
|
node_modules/
|
||||||
|
# Admin-SPA Build-Output (wird im Container gebaut)
|
||||||
|
cms/admin/dist/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -22,3 +24,6 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
hugo.local.yaml
|
hugo.local.yaml
|
||||||
|
|
||||||
|
# DB-Backups (Dialog-Daten-Dumps)
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
title: "{{ replace .File.ContentBaseName `-` ` ` | title }}"
|
||||||
|
group: "Allgemein"
|
||||||
|
summary: ""
|
||||||
|
toc: false
|
||||||
|
---
|
||||||
+429
-93
@@ -82,7 +82,9 @@
|
|||||||
/* Scrollbalken-Platz auf der Wurzel reservieren UND horizontalen Überlauf hier
|
/* Scrollbalken-Platz auf der Wurzel reservieren UND horizontalen Überlauf hier
|
||||||
kappen → Inhaltsbreite identisch auf ALLEN Seiten (auch Home ohne Scrollbar),
|
kappen → Inhaltsbreite identisch auf ALLEN Seiten (auch Home ohne Scrollbar),
|
||||||
damit das zentrierte Logo/Menü nirgends springt. */
|
damit das zentrierte Logo/Menü nirgends springt. */
|
||||||
html { margin: 0; overflow-x: hidden; scrollbar-gutter: stable; }
|
/* Kein Seiten-Scroll, kein Scrollbalken-Gutter → Header/Footer reichen bis ganz
|
||||||
|
an den rechten Rand (schwarz bis zur Kante). Gescrollt wird nur main intern. */
|
||||||
|
html { margin: 0; overflow: hidden; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-family-serif);
|
font-family: var(--font-family-serif);
|
||||||
@@ -90,28 +92,44 @@ body {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
/* App-Rahmen auf ALLEN Seiten: feste Höhe, Seite scrollt nicht — Header oben,
|
||||||
/* overflow-x + scrollbar-gutter liegen jetzt auf html (Wurzel) → Body füllt
|
Footer unten, der Inhalt (main) scrollt INTERN mit verstecktem Scrollbalken. */
|
||||||
konsistent die gleiche Breite auf allen Seiten. */
|
height: 100dvh;
|
||||||
/* nur Zeilen-Abstand (Header/Main/Footer); KEIN Spalten-Gap, sonst entstehen
|
overflow: hidden;
|
||||||
ungleiche Ränder an der Inhaltsspalte (der „weiße Spalt"). */
|
display: flex;
|
||||||
row-gap: var(--spacing-sm);
|
flex-direction: column;
|
||||||
column-gap: 0;
|
gap: 0; /* Theme-main.css setzt body{gap:spacing-lg} → erzeugte Balken zwischen Header/main/Footer */
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr auto;
|
|
||||||
justify-content: stretch;
|
|
||||||
/* Boxed content column = 72ch with 1.75rem gutters, side columns absorb the rest */
|
|
||||||
grid-template-columns:
|
|
||||||
1fr
|
|
||||||
min(var(--container-width), 100% - 3.5rem)
|
|
||||||
1fr;
|
|
||||||
}
|
}
|
||||||
/* Default: every direct body child sits in the content column */
|
|
||||||
body > * { grid-column: 2; }
|
|
||||||
/* Opt-in: full-bleed children break out edge-to-edge */
|
|
||||||
body > .full-bleed,
|
|
||||||
body > header.site-header,
|
body > header.site-header,
|
||||||
body > footer { grid-column: 1 / -1; }
|
body > footer { flex: none; }
|
||||||
|
body > main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none; /* Scrollbalken aus (kein weißer Streifen) */
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
body > main::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||||
|
/* Kurzer Inhalt vertikal zentriert zwischen Header und Footer; langer Inhalt
|
||||||
|
scrollt normal von oben (safe center fällt bei Überlauf auf start zurück). */
|
||||||
|
body:not(.is-home) > main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start; /* Inhalt startet direkt unter dem Header (keine Zentrier-Bänder) */
|
||||||
|
}
|
||||||
|
/* Inhalt 72ch-zentriert in der vollbreiten Scroll-Fläche (Home = Vollbreite). */
|
||||||
|
body:not(.is-home) > main > * {
|
||||||
|
max-width: calc(var(--container-width) + 3.5rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 1.75rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
/* Full-Bleed-Hero füllt die ganze Breite (main ist schon viewportbreit). */
|
||||||
|
body:not(.is-home) > main > .single-hero-image {
|
||||||
|
max-width: none;
|
||||||
|
margin-inline: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
p { margin: var(--spacing-sm) 0; }
|
p { margin: var(--spacing-sm) 0; }
|
||||||
@@ -142,8 +160,8 @@ a:hover {
|
|||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-dark-panel);
|
background: var(--color-dark-panel);
|
||||||
color: var(--color-dark-panel-text);
|
color: var(--color-dark-panel-text);
|
||||||
/* oben etwas Luft über dem Logo, unten nur 1–2px unter dem Menü */
|
/* etwas Luft über dem Logo und unter dem Menü */
|
||||||
padding: 0.4rem 0 2px;
|
padding: 0.7rem 0 0.35rem;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
/* inner 3-col grid matches body so wordmark/nav align with content column */
|
/* inner 3-col grid matches body so wordmark/nav align with content column */
|
||||||
@@ -174,7 +192,7 @@ a:hover {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
display: block;
|
display: block;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
width: clamp(140px, 18vw, 200px);
|
width: clamp(154px, 19.8vw, 220px);
|
||||||
aspect-ratio: 1412 / 231;
|
aspect-ratio: 1412 / 231;
|
||||||
height: auto;
|
height: auto;
|
||||||
background-image: url("/logo/logo.svg");
|
background-image: url("/logo/logo.svg");
|
||||||
@@ -193,8 +211,8 @@ a:hover {
|
|||||||
|
|
||||||
.site-header .site-nav {
|
.site-header .site-nav {
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
/* etwas Luft zwischen Logo und Menü. */
|
/* Luft zwischen Logo und Menü. */
|
||||||
margin-top: 0.1em;
|
margin-top: 0.32em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark-link:focus-visible { outline: 2px dotted var(--color-text-muted); outline-offset: 4px; }
|
.wordmark-link:focus-visible { outline: 2px dotted var(--color-text-muted); outline-offset: 4px; }
|
||||||
@@ -379,11 +397,13 @@ body.is-home .journal-list::-webkit-scrollbar { width: 0; height: 0; display: no
|
|||||||
body.is-home .journal-entry { flex: 0 0 auto; }
|
body.is-home .journal-entry { flex: 0 0 auto; }
|
||||||
body.is-home .more { flex: none; padding: 0.4rem 10px; margin: 0; }
|
body.is-home .more { flex: none; padding: 0.4rem 10px; margin: 0; }
|
||||||
/* Footer kompakt (~1/3): kein großer Außenabstand, knappes Padding. */
|
/* Footer kompakt (~1/3): kein großer Außenabstand, knappes Padding. */
|
||||||
body.is-home > footer { margin-top: 0; padding: 0.55rem 0; }
|
body.is-home > footer { margin-top: 0; padding: 0.55rem 1.5rem; }
|
||||||
body.is-home > footer .footer-grid { row-gap: 0.2rem; }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
/* Mobil: kein Full-Height-Zwang — normal scrollen, eine Spalte. */
|
/* Mobil: kein Full-Height-Rahmen — normale Seite scrollt, kein interner Scroll. */
|
||||||
|
html { overflow: visible; }
|
||||||
|
body { height: auto; min-height: 100dvh; overflow: visible; }
|
||||||
|
body > main { overflow: visible; }
|
||||||
body.is-home { height: auto; overflow: visible; }
|
body.is-home { height: auto; overflow: visible; }
|
||||||
body.is-home > main { display: block; overflow: visible; }
|
body.is-home > main { display: block; overflow: visible; }
|
||||||
body.is-home .journal { display: block; }
|
body.is-home .journal { display: block; }
|
||||||
@@ -534,23 +554,28 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
|
|
||||||
/* ── Dialog ───────────────────────────────────────────────────────────────── */
|
/* ── Dialog ───────────────────────────────────────────────────────────────── */
|
||||||
/* Link am Ende des Beitrags (der Beitrag selbst bleibt sauber) */
|
/* Link am Ende des Beitrags (der Beitrag selbst bleibt sauber) */
|
||||||
.dialog-link {
|
/* Dialog-Pill: Akzent-Outline + Pfeil — sticht neben den gefüllten Tag-Pills
|
||||||
|
hervor (wie der frühere Dialog-Link). */
|
||||||
|
.prov-dialog {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: var(--spacing-md);
|
|
||||||
font-family: var(--font-family-display);
|
font-family: var(--font-family-display);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.28em 0.85em;
|
padding: 0.28em 0.85em;
|
||||||
}
|
}
|
||||||
.dialog-link:hover { background: var(--accent); color: #fff; }
|
.prov-dialog:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
/* Eigene Dialog-Seite (/dialog/?thread=…) */
|
/* Eigene Dialog-Seite (/dialog/?thread=…) */
|
||||||
/* Füllt die normale Inhaltsspalte (kein eigenes max-width/Seiten-Padding → gleiche Breite wie andere Seiten) */
|
/* Füllt die normale Inhaltsspalte (kein eigenes max-width/Seiten-Padding → gleiche Breite wie andere Seiten) */
|
||||||
.dialog-page { padding: var(--spacing-sm) 0 var(--spacing-xl); }
|
/* width:100% → füllt immer die ganze Inhaltsspalte (sonst schrumpft .dialog-page
|
||||||
|
als Flex-Item mit margin-inline:auto bei schmalem Inhalt, z.B. Forum-Ansicht). */
|
||||||
|
.dialog-page { width: 100%; padding: var(--spacing-sm) 0 var(--spacing-xl); }
|
||||||
.dialog-overview { display: flex; flex-direction: column; gap: 0.6em; }
|
.dialog-overview { display: flex; flex-direction: column; gap: 0.6em; }
|
||||||
.dialog-overview-item {
|
.dialog-overview-item {
|
||||||
display: flex; justify-content: space-between; align-items: baseline; gap: 1em;
|
display: flex; justify-content: space-between; align-items: baseline; gap: 1em;
|
||||||
@@ -564,23 +589,33 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
.dialog-back:empty { margin: 0; }
|
.dialog-back:empty { margin: 0; }
|
||||||
.dialog-back a { color: var(--color-text-muted); text-decoration: none; }
|
.dialog-back a { color: var(--color-text-muted); text-decoration: none; }
|
||||||
.dialog-back a:hover { color: var(--accent); }
|
.dialog-back a:hover { color: var(--accent); }
|
||||||
|
/* Dialog-Navigation oben: Breadcrumb (Dialoge › Forum). */
|
||||||
|
.dialog-crumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45em; font-size: var(--font-size-small); }
|
||||||
|
.dialog-crumb a { color: var(--color-text-muted); text-decoration: none; }
|
||||||
|
.dialog-crumb a:hover { color: var(--accent); }
|
||||||
|
.dialog-crumb-sep { color: var(--color-text-muted); }
|
||||||
|
|
||||||
.dialog-title {
|
.dialog-title {
|
||||||
font-family: var(--font-family-serif);
|
font-family: var(--font-family-serif);
|
||||||
margin: 0 0 var(--spacing-md);
|
margin: 0 0 var(--spacing-md);
|
||||||
}
|
}
|
||||||
.dialog-list { display: flex; flex-direction: column; gap: var(--spacing-md); margin-bottom: var(--spacing-lg); }
|
.dialog-list { display: flex; flex-direction: column; margin-bottom: var(--spacing-lg); }
|
||||||
.dialog-empty { color: var(--color-text-muted); font-style: italic; }
|
.dialog-empty { color: var(--color-text-muted); font-style: italic; }
|
||||||
.dialog-card { border: 1px solid var(--color-border); border-radius: 12px; padding: var(--spacing-md); background: var(--color-bg-secondary); }
|
/* Nüchterne Wortmeldung: keine Box — nur feine Trennlinie + Abstand. */
|
||||||
.dialog-card-head { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.6em; }
|
.dialog-post { padding: 1.15em 0; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.dialog-post:first-child { padding-top: 0.2em; }
|
||||||
|
.dialog-post:last-child { border-bottom: none; }
|
||||||
|
.dialog-post-head { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.5em; }
|
||||||
.dialog-avatar {
|
.dialog-avatar {
|
||||||
width: 40px; height: 40px; border-radius: 50%; flex: none;
|
width: 40px; height: 40px; border-radius: 50%; flex: none;
|
||||||
background: var(--color-border) center/cover no-repeat;
|
background: var(--color-border) center/cover no-repeat;
|
||||||
display: grid; place-items: center; font-weight: 600; color: var(--color-text-muted);
|
display: grid; place-items: center; font-weight: 600; color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.dialog-meta { display: flex; flex-direction: column; line-height: 1.3; }
|
.dialog-ident { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
|
||||||
|
.dialog-nameline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.5em; }
|
||||||
.dialog-name { font-weight: 600; }
|
.dialog-name { font-weight: 600; }
|
||||||
.dialog-time { font-size: var(--font-size-small); color: var(--color-text-muted); }
|
.dialog-pos { font-size: var(--font-size-small); color: var(--color-text-muted); }
|
||||||
|
.dialog-time { font-size: var(--font-size-small); color: var(--color-text-muted); margin-top: 0.1em; }
|
||||||
.dialog-replyto { font-size: var(--font-size-small); color: var(--accent); }
|
.dialog-replyto { font-size: var(--font-size-small); color: var(--accent); }
|
||||||
.dialog-body { font-family: var(--font-family-serif); line-height: 1.6; white-space: pre-wrap; }
|
.dialog-body { font-family: var(--font-family-serif); line-height: 1.6; white-space: pre-wrap; }
|
||||||
.dialog-actions { display: flex; gap: 0.8em; margin-top: 0.6em; }
|
.dialog-actions { display: flex; gap: 0.8em; margin-top: 0.6em; }
|
||||||
@@ -669,6 +704,77 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
.dialog-logout { font: inherit; cursor: pointer; padding: 0.55em 1.1em; border-radius: 999px; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
.dialog-logout { font: inherit; cursor: pointer; padding: 0.55em 1.1em; border-radius: 999px; background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
||||||
.dialog-replychip { align-self: flex-start; font-size: var(--font-size-small); cursor: pointer; padding: 0.25em 0.8em; border-radius: 999px; border: 1px solid var(--accent); color: var(--accent); background: none; }
|
.dialog-replychip { align-self: flex-start; font-size: var(--font-size-small); cursor: pointer; padding: 0.25em 0.8em; border-radius: 999px; border: 1px solid var(--accent); color: var(--accent); background: none; }
|
||||||
|
|
||||||
|
/* ── Dialog: Lade-Skelett, Links im Text, Composer-Hinweis ── */
|
||||||
|
.dialog-skel { display: flex; flex-direction: column; gap: 1.1em; padding: 0.7em 0; }
|
||||||
|
.dialog-skel-line {
|
||||||
|
height: 1.05em; width: 100%; border-radius: 6px;
|
||||||
|
background: linear-gradient(90deg, var(--color-border) 25%, var(--color-bg-secondary) 37%, var(--color-border) 63%);
|
||||||
|
background-size: 400% 100%; animation: dialog-shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
.dialog-skel-line:nth-child(3n) { width: 68%; }
|
||||||
|
.dialog-skel-line:nth-child(3n+1) { width: 92%; }
|
||||||
|
@keyframes dialog-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .dialog-skel-line { animation: none; } }
|
||||||
|
.dialog-body .dialog-link { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; word-break: break-word; }
|
||||||
|
.dialog-hint { font-size: var(--font-size-small); color: var(--color-text-muted); align-self: center; opacity: 0.7; }
|
||||||
|
.dialog-spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ── Library: gleiches Gerüst wie Archiv (Übersicht = .atlas, Eintrag = .single).
|
||||||
|
Eintrags-Fuss: Gruppe + weitere Einträge + bearbeiten-Link. ── */
|
||||||
|
.entry-foot { margin-top: var(--spacing-lg); padding-top: var(--spacing-sm); border-top: 1px solid var(--color-border);
|
||||||
|
display: flex; gap: 0.6em 1.2em; flex-wrap: wrap; align-items: baseline; font-size: var(--font-size-small); color: var(--color-text-muted); }
|
||||||
|
.entry-foot .entry-more { flex: 1; min-width: 14em; }
|
||||||
|
.entry-foot .entry-more a { color: var(--accent); text-decoration: none; }
|
||||||
|
.entry-foot .entry-more a:hover { text-decoration: underline; text-underline-offset: 0.2em; }
|
||||||
|
.entry-foot a { color: var(--color-text-muted); text-decoration: none; }
|
||||||
|
.entry-foot a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Wiki-Links in Library-Einträgen */
|
||||||
|
.wikilink { color: var(--section-color, var(--accent)); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 0.2em; }
|
||||||
|
.wikilink:hover { text-decoration-style: solid; }
|
||||||
|
.wikilink-missing { color: var(--color-text-muted); border-bottom: 1px dashed currentColor; }
|
||||||
|
|
||||||
|
/* Querverweise: „Siehe auch" + „Erwähnt in" */
|
||||||
|
.entry-links { margin-top: var(--spacing-md); padding: 0.7em 1em;
|
||||||
|
background: color-mix(in oklab, var(--section-color, var(--accent)) 8%, transparent);
|
||||||
|
border-left: 3px solid var(--section-color, var(--accent)); border-radius: 0 8px 8px 0; }
|
||||||
|
.entry-links + .entry-links { margin-top: 0.5em; }
|
||||||
|
.entry-links ul { list-style: none; margin: 0.3em 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.25em 0.8em; }
|
||||||
|
.entry-links li { font-size: var(--font-size-small); }
|
||||||
|
.entry-links a { color: var(--section-color, var(--accent)); text-decoration: none; }
|
||||||
|
.entry-links a:hover { text-decoration: underline; text-underline-offset: 0.2em; }
|
||||||
|
.entry-links-label { font-size: var(--font-size-small); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
|
||||||
|
/* Library-Übersicht: Suchfeld + Kategorie-Pills */
|
||||||
|
.lib-filter { margin-bottom: var(--spacing-sm); display: flex; flex-direction: column; gap: 0.5em; }
|
||||||
|
.lib-search { width: 100%; padding: 0.45em 0.8em; border: 1px solid var(--color-border); border-radius: 6px;
|
||||||
|
background: var(--color-bg-secondary); color: var(--color-text-primary); font-size: var(--font-size-base); font-family: inherit; }
|
||||||
|
.lib-search:focus { outline: none; border-color: var(--section-color, var(--accent)); }
|
||||||
|
.lib-pills { display: flex; flex-wrap: wrap; gap: 0.3em; }
|
||||||
|
.lib-pill { font-family: var(--font-family-display); font-size: var(--font-size-small); cursor: pointer;
|
||||||
|
padding: 0.28em 0.85em; border-radius: 999px;
|
||||||
|
background: none; border: 1px solid var(--color-border); color: var(--color-text-muted); }
|
||||||
|
.lib-pill:hover { border-color: var(--section-color, var(--accent)); color: var(--color-text-primary); }
|
||||||
|
.lib-pill.active { background: var(--section-color, var(--accent)); border-color: var(--section-color, var(--accent)); color: white; }
|
||||||
|
|
||||||
|
/* Library-Atlas: zwei Kategorien nebeneinander */
|
||||||
|
.atlas--grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); align-items: start; }
|
||||||
|
@media (max-width: 600px) { .atlas--grid2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ── Software-Landing: Werkzeuge getrennt von Texten ── */
|
||||||
|
.software-h { font-family: var(--font-family-serif); margin: var(--spacing-md) 0 var(--spacing-sm); }
|
||||||
|
.software-tools { margin-bottom: var(--spacing-lg); }
|
||||||
|
.tool-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.8em; }
|
||||||
|
.tool-item { display: flex; align-items: flex-start; gap: 0.5em; background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border); border-left: 4px solid var(--section-color, var(--accent)); border-radius: 12px; padding: 0.9em 1em; }
|
||||||
|
.tool-item:hover { border-color: var(--section-color, var(--accent)); }
|
||||||
|
.tool-main { flex: 1; text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: 0.2em; min-width: 0; }
|
||||||
|
.tool-name { font-family: var(--font-family-serif); font-weight: 600; font-size: 1.05rem; }
|
||||||
|
.tool-item:hover .tool-name { color: var(--accent); }
|
||||||
|
.tool-sum { font-size: var(--font-size-small); }
|
||||||
|
.tool-ext { flex: none; color: var(--color-text-muted); text-decoration: none; font-size: 1.15em; line-height: 1; }
|
||||||
|
.tool-ext:hover { color: var(--accent); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------
|
/* ------------------------------------------------------------------------
|
||||||
Journal entries — three Republik-style layouts (set in front matter
|
Journal entries — three Republik-style layouts (set in front matter
|
||||||
via `layout: image|icon|text`). Every entry is a full-bleed coloured
|
via `layout: image|icon|text`). Every entry is a full-bleed coloured
|
||||||
@@ -963,8 +1069,8 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
.atlas {
|
.atlas {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-md);
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
.atlas-section h2,
|
.atlas-section h2,
|
||||||
.atlas-tags h2 {
|
.atlas-tags h2 {
|
||||||
@@ -974,7 +1080,7 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
letter-spacing: -0.018em;
|
letter-spacing: -0.018em;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-bottom: 3px solid var(--section-color, var(--accent));
|
border-bottom: 3px solid var(--section-color, var(--accent));
|
||||||
padding-bottom: var(--spacing-xs);
|
padding-bottom: 0.03em;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
.atlas-section h2 a,
|
.atlas-section h2 a,
|
||||||
@@ -1029,10 +1135,10 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
}
|
}
|
||||||
.section-title {
|
.section-title {
|
||||||
font-family: var(--font-family-serif);
|
font-family: var(--font-family-serif);
|
||||||
font-size: clamp(1.9rem, 4vw, 2.6rem);
|
font-size: clamp(1.5rem, 2.6vw, 1.9rem);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.026em;
|
letter-spacing: -0.012em;
|
||||||
line-height: 1.05;
|
line-height: 1.1;
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.4rem;
|
||||||
}
|
}
|
||||||
.section-description {
|
.section-description {
|
||||||
@@ -1042,9 +1148,46 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
max-width: 60ch;
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Übersicht (Archiv/Library): wie eine Artikelseite — Inhalt in der Lesespalte
|
||||||
|
(≈55ch, zentriert), gleicher Oberabstand wie .single, Titel mit Linie darunter
|
||||||
|
in der Sektionsfarbe (Archiv grün, Library rot). */
|
||||||
|
.collection { margin-top: var(--spacing-sm); }
|
||||||
|
.collection-title,
|
||||||
|
.collection-inner { max-width: 48.5rem; margin-inline: auto; }
|
||||||
|
.collection .section-rubric { max-width: 48.5rem; margin-inline: auto; margin-top: var(--spacing-sm); }
|
||||||
|
.collection-title {
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-family-serif);
|
||||||
|
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
line-height: 1.12;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-bottom: 3px solid var(--section-color, var(--accent));
|
||||||
|
padding-bottom: 0.03em;
|
||||||
|
margin: 0 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Archiv-Umschalter (Kategorie ↔ Jahr) */
|
||||||
|
.archiv-toggle { display: inline-flex; gap: 0.3em; margin: 0 0 0.5em; }
|
||||||
|
.archiv-toggle button {
|
||||||
|
font-family: var(--font-family-display); font-size: var(--font-size-small); cursor: pointer;
|
||||||
|
padding: 0.3em 0.95em; border-radius: 999px;
|
||||||
|
background: none; border: 1px solid var(--color-border); color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.archiv-toggle button:hover { border-color: var(--section-color, var(--accent)); color: var(--color-text-primary); }
|
||||||
|
.archiv-toggle button.is-active {
|
||||||
|
background: var(--section-color, var(--accent));
|
||||||
|
border-color: var(--section-color, var(--accent));
|
||||||
|
color: white; font-weight: 600;
|
||||||
|
}
|
||||||
|
.archiv-view[hidden] { display: none; }
|
||||||
|
|
||||||
.time-list ul {
|
.time-list ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
.list-item {
|
.list-item {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
@@ -1077,6 +1220,9 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
/* Im Grid: Datum unter dem Titel, keine Trennlinie zwischen Cards */
|
||||||
|
.time-list .list-title-row { flex-direction: column; align-items: flex-start; gap: 0.2em; }
|
||||||
|
.time-list .list-item, .time-list .list-item:last-child { border-top: none; border-bottom: none; }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------
|
/* ------------------------------------------------------------------------
|
||||||
Software showcase
|
Software showcase
|
||||||
@@ -1123,24 +1269,17 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
/* ------------------------------------------------------------------------
|
/* ------------------------------------------------------------------------
|
||||||
Single page (article)
|
Single page (article)
|
||||||
------------------------------------------------------------------------ */
|
------------------------------------------------------------------------ */
|
||||||
.single { margin-top: var(--spacing-md); }
|
.single { margin-top: var(--spacing-sm); }
|
||||||
.single-header { margin-bottom: var(--spacing-md); }
|
.single-header { margin-bottom: var(--spacing-md); }
|
||||||
/* Single article — full-bleed cover image directly under the masthead.
|
/* Cover-Bild füllt die volle Breite des Scroll-Rahmens, bündig unter dem Header. */
|
||||||
width:100vw breaks the boxed column horizontally; negative top margin
|
|
||||||
cancels the body grid gap + header margin so the image sits flush. */
|
|
||||||
.single-hero-image {
|
.single-hero-image {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
margin-left: calc(50% - 50vw);
|
margin: 0 0 var(--spacing-sm);
|
||||||
margin-right: calc(50% - 50vw);
|
|
||||||
/* bündig direkt unter die Masthead (kein Überlappen) — gleicht nur den
|
|
||||||
Body-Grid-Gap aus, nicht mehr (sonst rutscht das Bild über den Header). */
|
|
||||||
margin-top: calc(-1 * var(--spacing-sm));
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
.single-hero-image:hover { filter: none; }
|
.single-hero-image:hover { filter: none; }
|
||||||
@@ -1150,22 +1289,22 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
|
|
||||||
.single-header {
|
.single-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: var(--spacing-md);
|
margin-top: 0;
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: var(--spacing-md);
|
||||||
}
|
}
|
||||||
.single-header h1 {
|
.single-header h1 {
|
||||||
font-family: var(--font-family-serif);
|
font-family: var(--font-family-serif);
|
||||||
font-size: clamp(2.1rem, 4.6vw, 3rem);
|
font-size: clamp(1.7rem, 3vw, 2.2rem);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
letter-spacing: -0.028em;
|
letter-spacing: -0.015em;
|
||||||
line-height: 1.05;
|
line-height: 1.12;
|
||||||
margin: 0 0 var(--spacing-sm);
|
margin: 0 0 var(--spacing-sm);
|
||||||
}
|
}
|
||||||
.single-summary {
|
.single-summary {
|
||||||
font-family: var(--font-family-serif);
|
font-family: var(--font-family-serif);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 1.4rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1.4;
|
line-height: 1.45;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin: 0 0 var(--spacing-md); /* breathing room before byline */
|
margin: 0 0 var(--spacing-md); /* breathing room before byline */
|
||||||
max-width: 55ch;
|
max-width: 55ch;
|
||||||
@@ -1317,9 +1456,7 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
Page foot breadcrumb (moved from top → bottom)
|
Page foot breadcrumb (moved from top → bottom)
|
||||||
------------------------------------------------------------------------ */
|
------------------------------------------------------------------------ */
|
||||||
.page-foot-nav {
|
.page-foot-nav {
|
||||||
margin-top: var(--spacing-lg);
|
margin-top: var(--spacing-md);
|
||||||
padding-top: var(--spacing-sm);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -1343,32 +1480,24 @@ a.byline-author:hover, a.journal-author:hover { color: var(--accent); }
|
|||||||
footer {
|
footer {
|
||||||
background: var(--color-dark-panel);
|
background: var(--color-dark-panel);
|
||||||
color: var(--color-dark-panel-text); /* hell & lesbar auf Schwarz */
|
color: var(--color-dark-panel-text); /* hell & lesbar auf Schwarz */
|
||||||
margin-top: 0; /* kein Ablöse-Abstand → klebt flush unten (sticky via Body-Grid) */
|
margin-top: 0;
|
||||||
padding: var(--spacing-md) 0;
|
/* voll-breit; horizontal bündig zum Journal-Karten-Inhalt (1.5rem) */
|
||||||
|
padding: 0.55rem 1.5rem;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
/* inner grid aligns with content column, same trick as header */
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns:
|
|
||||||
1fr
|
|
||||||
min(var(--container-width), 100% - 3.5rem)
|
|
||||||
1fr;
|
|
||||||
}
|
}
|
||||||
footer > * { grid-column: 2; }
|
|
||||||
footer a, footer a:hover, footer a:focus { border: none; border-bottom: none; text-decoration: none; }
|
footer a, footer a:hover, footer a:focus { border: none; border-bottom: none; text-decoration: none; }
|
||||||
footer a { color: var(--color-dark-panel-text); }
|
footer a { color: var(--color-dark-panel-text); }
|
||||||
footer a:hover { color: var(--accent-soft); }
|
footer a:hover { color: var(--accent-soft); }
|
||||||
footer p { margin: 0; }
|
footer p { margin: 0; }
|
||||||
|
|
||||||
/* Zwei Zeilen: oben Inhalts-Absatz (links) | Links (rechts),
|
/* Lizenzen ganz links (linksbündig), Footer-Menü ganz rechts. */
|
||||||
unten Lizenz/Copyright (links). */
|
|
||||||
.footer-grid {
|
.footer-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr auto;
|
justify-content: space-between;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
column-gap: var(--spacing-lg);
|
column-gap: var(--spacing-lg);
|
||||||
row-gap: var(--spacing-sm);
|
|
||||||
}
|
}
|
||||||
.footer-legal { grid-row: 1; grid-column: 1; }
|
.footer-legal { text-align: left; }
|
||||||
.footer-licenses {
|
.footer-licenses {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -1378,14 +1507,13 @@ footer p { margin: 0; }
|
|||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-dark-panel-muted);
|
color: var(--color-dark-panel-muted);
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-links {
|
.footer-links {
|
||||||
grid-row: 1; grid-column: 2;
|
|
||||||
justify-self: end;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.4rem 1.3rem;
|
gap: 0.4rem 1.3rem;
|
||||||
font-family: var(--font-family-display);
|
font-family: var(--font-family-display);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -1397,14 +1525,12 @@ footer p { margin: 0; }
|
|||||||
|
|
||||||
/* Mobile: alles linksbündig stapeln */
|
/* Mobile: alles linksbündig stapeln */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.footer-grid { grid-template-columns: 1fr; }
|
.footer-grid {
|
||||||
.footer-legal,
|
flex-direction: column;
|
||||||
.footer-links {
|
align-items: flex-start;
|
||||||
grid-column: 1;
|
row-gap: 0.5rem;
|
||||||
justify-self: start;
|
|
||||||
}
|
}
|
||||||
.footer-legal { grid-row: 1; }
|
.footer-links { justify-content: flex-start; }
|
||||||
.footer-links { grid-row: 2; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------------
|
/* ------------------------------------------------------------------------
|
||||||
@@ -1418,3 +1544,213 @@ img {
|
|||||||
margin: var(--spacing-sm) 0;
|
margin: var(--spacing-sm) 0;
|
||||||
}
|
}
|
||||||
img:hover { filter: grayscale(0%); }
|
img:hover { filter: grayscale(0%); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Fußnoten / Quellen — Goldmark rendert [^1] als hochgestellte Verweise +
|
||||||
|
eine .footnotes-Sektion am Textende. Wir geben ihr eine „Quellen"-Über-
|
||||||
|
schrift (statt des <hr>) und ein ruhigeres, kleineres Schriftbild.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
/* Hochgestellte Verweis-Nummer im Fließtext: klein + dezent unterstrichen. */
|
||||||
|
.single-content sup { font-size: 0.62em; line-height: 0; }
|
||||||
|
.single-content a.footnote-ref {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
text-decoration-thickness: 0.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-content .footnotes {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
/* Theme vererbt eine feste Zeilenhöhe (~26px) → bei kleiner Schrift viel zu
|
||||||
|
luftig. Unitless überschreiben, damit die Quellen kompakt sitzen. */
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
/* Goldmark setzt ein <hr> an den Anfang — wir ersetzen es durch die Überschrift. */
|
||||||
|
.single-content .footnotes > hr { display: none; }
|
||||||
|
.single-content .footnotes::before {
|
||||||
|
content: "Quellen";
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-family-serif);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
.single-content .footnotes ol { margin: 0; padding-left: 1.4em; }
|
||||||
|
.single-content .footnotes li { margin: 0.2em 0; }
|
||||||
|
.single-content .footnotes li p { margin: 0; line-height: 1.5; }
|
||||||
|
.single-content .footnote-backref { text-decoration: none; margin-left: 0.3em; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Herkunft / Zitieren — „lebendes Dokument": Version (→ Commit), Verlauf,
|
||||||
|
Zitieren-Knopf. Dezent unter dem Beitrag.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
Artikel-Fuß: zitieren (Link + dezente Angabe), Aktionsreihe, Versionen.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
/* Versionen: Pill wie Dialog (Akzent-Outline) mit Uhr-Icon. */
|
||||||
|
.cite { margin-top: var(--spacing-sm); }
|
||||||
|
.versions-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-family: var(--font-family-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28em 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.versions-toggle:hover,
|
||||||
|
.versions-toggle[aria-expanded="true"] { background: var(--accent); color: #fff; }
|
||||||
|
.versions-toggle .pill-icon { width: 1em; height: 1em; flex: none; stroke: var(--accent); }
|
||||||
|
.versions-toggle:hover .pill-icon,
|
||||||
|
.versions-toggle[aria-expanded="true"] .pill-icon { stroke: #fff; }
|
||||||
|
|
||||||
|
/* zitieren: kein Pill — Link in der Pill-Schrift, mit ↗ am Ende. */
|
||||||
|
.cite-toggle {
|
||||||
|
font-family: var(--font-family-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--accent);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cite-toggle:hover { text-decoration: underline; text-underline-offset: 0.2em; }
|
||||||
|
.cite-arrow { font-size: 0.9em; }
|
||||||
|
|
||||||
|
/* „kopieren" bleibt ein schlichter Link in der Quellenangabe. */
|
||||||
|
.cite-copy {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
.cite-copy:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.cite-box { margin-top: 0.7em; }
|
||||||
|
.cite-text {
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
font-family: var(--font-family-serif);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
user-select: all; /* ein Klick markiert die ganze Angabe */
|
||||||
|
}
|
||||||
|
.cite-actions { display: flex; align-items: center; gap: 0.9em; font-size: var(--font-size-small); }
|
||||||
|
.cite-fmt {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cite-fmt:hover { color: var(--accent); }
|
||||||
|
.cite-fmt.is-active { color: var(--accent); font-weight: 600; }
|
||||||
|
.cite-status { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* Aktionsreihe: Dialog links, Tags ganz rechts; darüber eine Trennlinie.
|
||||||
|
margin-top = spacing-sm, damit der Abstand „zitieren → Trennlinie" gleich
|
||||||
|
gross ist wie „obere Trennlinie → Quellen" (footnotes padding-top) — der
|
||||||
|
Quellen-Block sitzt so symmetrisch zwischen den beiden Linien. */
|
||||||
|
.article-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8em 1em;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
padding-top: var(--spacing-sm);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.article-actions .tag-pills { margin: 0; }
|
||||||
|
|
||||||
|
/* Versionen: eine Zeile darunter, öffnet die Liste auf der Seite. */
|
||||||
|
.article-versions { margin-top: var(--spacing-sm); }
|
||||||
|
|
||||||
|
/* Versionsliste: volle Inhaltsbreite, normale Schrift/Größe wie der Text. */
|
||||||
|
.version-panel { margin-top: 0.8rem; }
|
||||||
|
.version-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.version-list button {
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-family-serif);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: 0.7em 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.version-list li:first-child button { border-top: 1px solid var(--color-border); }
|
||||||
|
.version-list button:hover { color: var(--accent); }
|
||||||
|
.version-list .v-date { white-space: nowrap; min-width: 6em; }
|
||||||
|
.version-list .v-subject { flex: 1; color: var(--color-text-muted); }
|
||||||
|
.version-list .v-hash { white-space: nowrap; color: var(--color-text-muted); font-family: var(--font-family-mono); font-size: 0.85em; }
|
||||||
|
.version-empty { margin: 0.6rem 0; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.version-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding: 0.5em 0.8em;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.version-back,
|
||||||
|
.version-toggle {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.version-loading { color: var(--color-text-muted); font-style: italic; }
|
||||||
|
|
||||||
|
/* Diff-Ansicht (rot/grün, wie auf GitHub) — direkt auf der Seite. */
|
||||||
|
.diff {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.diff-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 0.05em 0.7em;
|
||||||
|
}
|
||||||
|
.diff-line.d-add { background: color-mix(in oklab, #2ea043 20%, transparent); }
|
||||||
|
.diff-line.d-del { background: color-mix(in oklab, #f85149 20%, transparent); }
|
||||||
|
.diff-line.d-hunk { color: var(--color-text-muted); background: var(--color-bg-secondary); }
|
||||||
|
.diff-line.d-ctx { color: var(--color-text-primary); }
|
||||||
|
|||||||
+7
-1
@@ -20,7 +20,13 @@ API_EXTERNAL_URL=http://localhost:8000
|
|||||||
# unter `authors` steht.
|
# unter `authors` steht.
|
||||||
ADMIN_EMAILS=karim@gabrielevarano.ch
|
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
|
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
|
||||||
KONG_HTTP_PORT=8000 # Supabase-API-Gateway
|
KONG_HTTP_PORT=8000 # Supabase-API-Gateway
|
||||||
KONG_HTTPS_PORT=8443
|
KONG_HTTPS_PORT=8443
|
||||||
|
|||||||
+98
-2
@@ -66,14 +66,37 @@ bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/cms/proxm
|
|||||||
Fragt interaktiv nur Storage/Bridge/IP ab (Enter = Default). Kein Token nötig.
|
Fragt interaktiv nur Storage/Bridge/IP ab (Enter = Default). Kein Token nötig.
|
||||||
`GIT_TOKEN` nur setzen, wenn das CMS per `GIT_PUBLISH` nach Gitea zurückschreiben soll.
|
`GIT_TOKEN` nur setzen, wenn das CMS per `GIT_PUBLISH` nach Gitea zurückschreiben soll.
|
||||||
|
|
||||||
|
Alle CONFIG-Werte sind auch per **Umgebungsvariable** überschreibbar (für
|
||||||
|
non-interaktiv/SSH) — `ROOTFS_STORAGE` (z.B. `local-zfs`), `HOSTNAME`, `CTID`,
|
||||||
|
`IP`, `GATEWAY`, `DISK_GB`, `RAM_MB`, `CORES`, `BRIDGE`. Mit **`SITE_DOMAIN`**
|
||||||
|
wird der Stack direkt für eine öffentliche HTTPS-Domain hinter einem
|
||||||
|
Reverse-Proxy konfiguriert (Same-Origin, Pfad-Routing der Supabase-API).
|
||||||
|
Beispiel + Caddy-Block: siehe [`proxmox/README.md`](../proxmox/README.md).
|
||||||
|
|
||||||
|
### Updaten (bestehender LXC)
|
||||||
|
|
||||||
|
Nicht `git pull` von Hand — das vergisst CORS-Origin (kong.yml), Dateirechte
|
||||||
|
(non-root) und den Neustart. Stattdessen im Container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/openbureau/cms/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das macht: `git pull` → CORS-Origin aus `SITE_URL` in `kong.yml` rendern →
|
||||||
|
`chown -R 1000:1000` → `docker compose up -d --build` + kong neu laden →
|
||||||
|
Healthcheck. (Beim allerersten Mal das Skript per einmaligem `git pull` holen.)
|
||||||
|
|
||||||
### Manuell (oder im Container)
|
### Manuell (oder im Container)
|
||||||
|
|
||||||
1. `cp .env.example .env`
|
1. `cp .env.example .env`
|
||||||
2. `POSTGRES_PASSWORD` + `JWT_SECRET` setzen: je `openssl rand -hex 32`
|
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`
|
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
|
4. `SITE_URL` + `API_EXTERNAL_URL` auf die LAN-/Domain-Adresse setzen
|
||||||
5. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations)
|
5. `kong.yml`: Platzhalter `__CORS_ORIGIN__` durch `SITE_URL` (Browser-Origin) ersetzen
|
||||||
6. Login-User anlegen (Self-Signup ist aus):
|
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
|
source .env
|
||||||
curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \
|
curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \
|
||||||
@@ -89,6 +112,79 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/
|
|||||||
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
|
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
|
||||||
`/_preview` an den laufenden Container auf :8080).
|
`/_preview` an den laufenden Container auf :8080).
|
||||||
|
|
||||||
|
Tests der API (ohne DB/Container, reine Logik): `cd api && npm test`
|
||||||
|
(`node --test` — Pfad-Sicherheit, Rollen/Auth, Rate-Limit, Build-Coalescing).
|
||||||
|
|
||||||
|
### Demo-Inhalt fürs Forum (optional)
|
||||||
|
|
||||||
|
`db/seed-demo.sql` füllt die Forum-Kategorien mit ein paar Beispiel-Threads und
|
||||||
|
-Wortmeldungen — bewusst **getrennt** von der Migration, damit die Produktion
|
||||||
|
leer startet. Bei Bedarf manuell einspielen (idempotent, mehrfach lauffähig):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db psql -U supabase_admin -d postgres < db/seed-demo.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Wieder entfernen: DELETE-Block am Ende der Datei (auskommentiert).
|
||||||
|
|
||||||
|
### Backup der Dialog-Daten
|
||||||
|
|
||||||
|
⚠️ Foren, Threads und Wortmeldungen liegen **nur in Postgres** — anders als
|
||||||
|
`content/*.md` (in Git) sind sie sonst nirgends gesichert. Das Proxmox-Script
|
||||||
|
richtet ein **tägliches** Backup ein (`/etc/cron.d/openbureau-backup`, 3:15 Uhr).
|
||||||
|
Manuell/sonst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup-db.sh # → backups/openbureau-<TS>.sql.gz (rotiert, 14 Stk.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Wiederherstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c backups/openbureau-<TS>.sql.gz \
|
||||||
|
| docker compose exec -T db psql -U supabase_admin -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 `*`).
|
||||||
|
- **Reverse-Proxy nur `/auth/*`**: bei einem Domain-Deploy gehört nur das Login
|
||||||
|
(GoTrue) public — `/rest`, `/storage`, `/realtime` nicht durchreichen (PostgREST
|
||||||
|
`/rest/v1/` würde sonst die DB-Schema-Beschreibung preisgeben). Siehe Caddy-Block
|
||||||
|
in [`../proxmox/README.md`](../proxmox/README.md).
|
||||||
|
- **Login-Rate-Limit** an GoTrue (`GOTRUE_RATE_LIMIT_TOKEN_REFRESH`), weil das
|
||||||
|
öffentliche Login direkt aufs `/token` geht (nicht übers Node-Limit).
|
||||||
|
- **Keine Tabellenrechte für `anon`/`authenticated`** (`revoke` in `db/schema.sql`):
|
||||||
|
RLS bleibt so auch bei künftigen Policies dicht; nur `service_role` (Node) liest.
|
||||||
|
|
||||||
|
### 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
|
## API
|
||||||
|
|
||||||
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
|
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
|
||||||
|
|||||||
+110
-30
@@ -22,13 +22,13 @@ const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transpa
|
|||||||
|
|
||||||
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
||||||
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
||||||
const KIND_LABEL = { beitrag: 'Beiträge', seite: 'Seiten', rubrik: 'Rubriken' };
|
const KIND_LABEL = { beitrag: 'Beiträge', biblio: 'Library', seite: 'Seiten', rubrik: 'Rubriken' };
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
||||||
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
||||||
color: '', layout: 'text', tags: '', summary: '', description: '',
|
color: '', layout: 'text', tags: '', summary: '', description: '',
|
||||||
cover_image: '', external: '', authors: '', toc: false, draft: true, body: '',
|
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -89,7 +89,7 @@ function Dashboard({ email }) {
|
|||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||||
const groups = { beitrag: [], seite: [], rubrik: [] };
|
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
|
||||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,6 +98,7 @@ function Dashboard({ email }) {
|
|||||||
<span className="logo">OPENBUREAU</span>
|
<span className="logo">OPENBUREAU</span>
|
||||||
<span className="logo-sub">Redaktion</span>
|
<span className="logo-sub">Redaktion</span>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
|
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||||
@@ -110,7 +111,9 @@ function Dashboard({ email }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
{view === 'profile' ? (
|
{view === 'overview' ? (
|
||||||
|
<Overview onMsg={setMsg} go={setView} />
|
||||||
|
) : view === 'profile' ? (
|
||||||
<Profile onMsg={setMsg} />
|
<Profile onMsg={setMsg} />
|
||||||
) : view === 'users' ? (
|
) : view === 'users' ? (
|
||||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||||
@@ -123,7 +126,7 @@ function Dashboard({ email }) {
|
|||||||
<aside>
|
<aside>
|
||||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
||||||
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||||
{['beitrag', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
||||||
<div className="group" key={kind}>
|
<div className="group" key={kind}>
|
||||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
@@ -166,6 +169,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const coverIn = useRef(null);
|
const coverIn = useRef(null);
|
||||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||||
|
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
|
||||||
|
|
||||||
async function pickCover(ev) {
|
async function pickCover(ev) {
|
||||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||||
@@ -194,7 +198,9 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
if (!data.isNew) return data.path;
|
if (!data.isNew) return data.path;
|
||||||
const slug = (data.slug || '').trim();
|
const slug = (data.slug || '').trim();
|
||||||
if (!slug) return '';
|
if (!slug) return '';
|
||||||
return data.type === 'beitrag' ? `library/${data.section}/${slug}.md` : `${slug}.md`;
|
if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`;
|
||||||
|
if (data.type === 'biblio') return `library/${slug}.md`;
|
||||||
|
return `${slug}.md`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
||||||
@@ -250,6 +256,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<label className="sm">Typ
|
<label className="sm">Typ
|
||||||
<select value={f.type} onChange={set('type')}>
|
<select value={f.type} onChange={set('type')}>
|
||||||
<option value="beitrag">Beitrag</option>
|
<option value="beitrag">Beitrag</option>
|
||||||
|
<option value="biblio">Library-Seite</option>
|
||||||
<option value="seite">Seite</option>
|
<option value="seite">Seite</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -265,6 +272,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
||||||
|
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
|
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
|
||||||
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
|
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
|
||||||
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
||||||
<label className="sm">Farbe
|
<label className="sm">Farbe
|
||||||
@@ -398,12 +406,55 @@ function Profile({ onMsg }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
|
||||||
|
function Overview({ onMsg, go }) {
|
||||||
|
const [s, setS] = useState(null);
|
||||||
|
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||||||
|
if (!s) return <div className="empty">…</div>;
|
||||||
|
const Card = ({ label, value, hint, to }) => (
|
||||||
|
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||||||
|
<span className="stat-value">{value}</span>
|
||||||
|
<span className="stat-label">{label}</span>
|
||||||
|
<span className="stat-hint">{hint || ' '}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="overview">
|
||||||
|
<h2>Übersicht</h2>
|
||||||
|
<div className="stat-grid">
|
||||||
|
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
|
||||||
|
<Card label="Library-Seiten" value={s.content.library} to="content" />
|
||||||
|
<Card label="Seiten" value={s.content.seiten} />
|
||||||
|
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
|
||||||
|
<Card label="Foren" value={s.dialog.forums} to="forums" />
|
||||||
|
<Card label="Threads" value={s.dialog.threads} to="moderation" />
|
||||||
|
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
|
||||||
|
</div>
|
||||||
|
<div className="overview-actions">
|
||||||
|
<h3>Schnellzugriff</h3>
|
||||||
|
<div className="quick">
|
||||||
|
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||||||
|
<button onClick={() => go('forums')}>Foren verwalten</button>
|
||||||
|
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
||||||
|
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
||||||
|
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog ↗</a>
|
||||||
|
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||||
function Users({ onMsg, currentEmail }) {
|
function Users({ onMsg, currentEmail }) {
|
||||||
const [list, setList] = useState(null);
|
const [list, setList] = useState(null);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [role, setRole] = useState('user');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [pwFor, setPwFor] = useState(null);
|
||||||
|
const [newPw, setNewPw] = useState('');
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try { setList(await api.listUsers()); }
|
try { setList(await api.listUsers()); }
|
||||||
@@ -413,59 +464,85 @@ function Users({ onMsg, currentEmail }) {
|
|||||||
|
|
||||||
async function create(e) {
|
async function create(e) {
|
||||||
e.preventDefault(); setBusy(true);
|
e.preventDefault(); setBusy(true);
|
||||||
try { await api.createUser(email, password); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
|
try {
|
||||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
await api.createUser(email, password, role);
|
||||||
|
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
|
||||||
|
setEmail(''); setPassword(''); setRole('user'); refresh();
|
||||||
|
} catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
async function remove(u) {
|
async function remove(u) {
|
||||||
if (!confirm(`${u.email} löschen?`)) return;
|
if (!confirm(`${u.email} wirklich löschen?`)) return;
|
||||||
try { await api.deleteUser(u.id); refresh(); }
|
try { await api.deleteUser(u.id); refresh(); }
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
async function reset(u) {
|
async function savePw(u) {
|
||||||
const pw = prompt(`Neues Passwort für ${u.email}:`);
|
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||||
if (!pw) return;
|
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
|
||||||
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
|
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
async function changeRole(u, role) {
|
async function changeRole(u, r) {
|
||||||
try { await api.setRole(u.id, role); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[role]}` }); refresh(); }
|
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!list) return <div className="empty">…</div>;
|
if (!list) return <div className="empty">…</div>;
|
||||||
|
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
|
||||||
|
const RoleSelect = ({ u }) => (
|
||||||
|
<select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||||
|
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<div className="profile-card">
|
<div className="profile-card wide">
|
||||||
<h2>Autor:innen & Rollen</h2>
|
<h2>Autor:innen & Rollen <span className="count-pill">{list.length}</span></h2>
|
||||||
<form className="userform" onSubmit={create}>
|
<form className="userform" onSubmit={create}>
|
||||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||||
|
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
<button className="primary" disabled={busy}>Anlegen</button>
|
<button className="primary" disabled={busy}>Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
|
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
|
||||||
<ul className="userlist">
|
<ul className="userlist">
|
||||||
{list.map((u) => (
|
{filtered.map((u) => (
|
||||||
<li key={u.id}>
|
<li key={u.id}>
|
||||||
<span className="t">{u.email}</span>
|
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
|
||||||
{u.fixedAdmin
|
<span className="t ucol">
|
||||||
? <span className="status live">Admin (.env)</span>
|
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
|
||||||
: <select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
<span className="umeta">
|
||||||
<option value="user">User</option>
|
angelegt {fmtDate(u.created_at)}
|
||||||
<option value="editor">Redakteur</option>
|
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
|
||||||
<option value="admin">Admin</option>
|
</span>
|
||||||
</select>}
|
</span>
|
||||||
<button onClick={() => reset(u)}>Passwort</button>
|
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
|
||||||
|
{pwFor === u.id ? (
|
||||||
|
<span className="pwinline">
|
||||||
|
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
|
||||||
|
<button onClick={() => savePw(u)}>OK</button>
|
||||||
|
<button onClick={() => { setPwFor(null); setNewPw(''); }}>✕</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
|
||||||
|
)}
|
||||||
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<p className="muted who-mail"><b>User</b> schreiben nur im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||||||
|
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
|
||||||
|
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||||
|
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
|
||||||
|
|
||||||
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||||||
function Forums({ onMsg }) {
|
function Forums({ onMsg }) {
|
||||||
@@ -603,15 +680,17 @@ function slugify(s) {
|
|||||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
||||||
function fromRead(r) {
|
function fromRead(r) {
|
||||||
const fm = r.frontmatter || {};
|
const fm = r.frontmatter || {};
|
||||||
|
const p = r.path || '';
|
||||||
|
const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite';
|
||||||
return {
|
return {
|
||||||
isNew: false, path: r.path, type: 'beitrag', section: '', slug: '',
|
isNew: false, path: r.path, type, section: '', slug: '',
|
||||||
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
||||||
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
||||||
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
||||||
summary: fm.summary || '', description: fm.description || '',
|
summary: fm.summary || '', description: fm.description || '',
|
||||||
cover_image: fm.cover_image || '', external: fm.external || '',
|
cover_image: fm.cover_image || '', external: fm.external || '',
|
||||||
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
||||||
toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function buildFrontmatter(f) {
|
function buildFrontmatter(f) {
|
||||||
@@ -628,6 +707,7 @@ function buildFrontmatter(f) {
|
|||||||
if (f.color) fm.color = f.color;
|
if (f.color) fm.color = f.color;
|
||||||
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||||
if (authors.length) fm.authors = authors;
|
if (authors.length) fm.authors = authors;
|
||||||
|
if (f.group) fm.group = f.group;
|
||||||
if (f.toc) fm.toc = true;
|
if (f.toc) fm.toc = true;
|
||||||
if (f.draft) fm.draft = true;
|
if (f.draft) fm.draft = true;
|
||||||
return fm;
|
return fm;
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export const api = {
|
|||||||
getProfile: () => call('/profile'),
|
getProfile: () => call('/profile'),
|
||||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||||
getMe: () => call('/me'),
|
getMe: () => call('/me'),
|
||||||
|
stats: () => call('/stats'),
|
||||||
listUsers: () => call('/users'),
|
listUsers: () => call('/users'),
|
||||||
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
|
||||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||||
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -184,6 +184,37 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
|||||||
.mod-actions a { color: var(--muted); }
|
.mod-actions a { color: var(--muted); }
|
||||||
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ── Übersicht / Dashboard ── */
|
||||||
|
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
|
||||||
|
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||||
|
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
|
||||||
|
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
|
||||||
|
.stat-card:disabled { opacity: 1; cursor: default; }
|
||||||
|
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
|
||||||
|
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
|
||||||
|
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
|
||||||
|
.overview-actions { margin-top: 30px; }
|
||||||
|
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
|
||||||
|
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
|
||||||
|
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Nutzerliste (aufgewertet) ── */
|
||||||
|
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
|
||||||
|
.userfilter { margin: 4px 0 2px; height: 34px; }
|
||||||
|
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
|
||||||
|
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
|
||||||
|
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||||
|
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
|
||||||
|
.umeta { font-size: 11.5px; color: var(--muted); }
|
||||||
|
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
|
||||||
|
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
|
||||||
|
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
|
||||||
|
.pwinline input { width: 150px; height: 30px; }
|
||||||
|
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
|
||||||
|
|
||||||
/* ── Toast ── */
|
/* ── Toast ── */
|
||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||||
.toast.ok { background: var(--ok); }
|
.toast.ok { background: var(--ok); }
|
||||||
|
|||||||
@@ -40,5 +40,12 @@ COPY --from=admin /admin/dist ./admin-dist
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV ADMIN_DIR=/app/admin-dist
|
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
|
EXPOSE 3000
|
||||||
CMD ["sh", "/app/entrypoint.sh"]
|
CMD ["sh", "/app/entrypoint.sh"]
|
||||||
|
|||||||
Generated
+13
@@ -12,6 +12,7 @@
|
|||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
|
"marked": "^14.1.4",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -672,6 +673,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "14.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
|
||||||
|
"integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/section-matter": {
|
"node_modules/section-matter": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||||
|
|||||||
@@ -6,13 +6,15 @@
|
|||||||
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "node --watch src/index.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
|
"marked": "^14.1.4",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-5
@@ -1,5 +1,27 @@
|
|||||||
|
import { verify } from 'hono/jwt';
|
||||||
import { supabaseAuth } from './supabase.js';
|
import { supabaseAuth } from './supabase.js';
|
||||||
|
|
||||||
|
// Supabase-Tokens sind HS256-signiert. Mit dem JWT_SECRET verifizieren wir sie
|
||||||
|
// lokal (Signatur + Ablauf) — das spart pro Request den Roundtrip zu GoTrue.
|
||||||
|
// Ohne JWT_SECRET (z.B. Alt-Deploy) fällt requireAuth auf die Remote-Prüfung
|
||||||
|
// zurück. Tokens sind kurzlebig (1h) und Self-Signup ist aus → kein
|
||||||
|
// Sperr-Check nötig.
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || '';
|
||||||
|
|
||||||
|
// Liefert ein User-Objekt {id,email,app_metadata} oder null.
|
||||||
|
async function verifyToken(token) {
|
||||||
|
if (JWT_SECRET) {
|
||||||
|
try {
|
||||||
|
const p = await verify(token, JWT_SECRET, 'HS256');
|
||||||
|
if (!p?.sub) return null;
|
||||||
|
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||||
|
if (error || !data?.user) return null;
|
||||||
|
return data.user;
|
||||||
|
}
|
||||||
|
|
||||||
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
|
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
|
||||||
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
||||||
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
|
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
|
||||||
@@ -24,12 +46,12 @@ export async function requireAuth(c, next) {
|
|||||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
||||||
|
|
||||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
const user = await verifyToken(token);
|
||||||
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
|
if (!user) return c.json({ error: 'Ungültiges Token' }, 401);
|
||||||
|
|
||||||
const email = (data.user.email || '').toLowerCase();
|
const email = (user.email || '').toLowerCase();
|
||||||
const role = roleOf(data.user);
|
const role = roleOf(user);
|
||||||
c.set('user', data.user);
|
c.set('user', user);
|
||||||
c.set('email', email);
|
c.set('email', email);
|
||||||
c.set('role', role);
|
c.set('role', role);
|
||||||
c.set('isAdmin', role === 'admin');
|
c.set('isAdmin', role === 'admin');
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Serialisiert asynchrone Aufgaben je `key` und koalesziert Wartende:
|
||||||
|
// - Es läuft nie mehr als eine Aufgabe pro Key gleichzeitig.
|
||||||
|
// - Kommen während eines Laufs weitere Aufrufe rein, wird GENAU EIN weiterer
|
||||||
|
// Durchlauf nachgelagert (egal wie viele warten) — sie teilen sich dessen
|
||||||
|
// Ergebnis. So sehen alle den jüngsten Stand, ohne einen Lauf-Sturm.
|
||||||
|
//
|
||||||
|
// Einsatz: teure, idempotente Vorgänge wie der Hugo-Build (siehe hugo.js).
|
||||||
|
const state = new Map();
|
||||||
|
|
||||||
|
export function coalesce(key, fn) {
|
||||||
|
let s = state.get(key);
|
||||||
|
if (!s) { s = { running: false, rerun: false, fn, waiters: [] }; state.set(key, s); }
|
||||||
|
s.fn = fn; // jüngste Variante gewinnt für den nächsten Lauf
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
s.waiters.push({ resolve, reject });
|
||||||
|
if (!s.running) drain(key);
|
||||||
|
else s.rerun = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(key) {
|
||||||
|
const s = state.get(key);
|
||||||
|
s.running = true;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
s.rerun = false;
|
||||||
|
const waiters = s.waiters;
|
||||||
|
s.waiters = [];
|
||||||
|
try {
|
||||||
|
const r = await s.fn();
|
||||||
|
waiters.forEach((w) => w.resolve(r));
|
||||||
|
} catch (e) {
|
||||||
|
waiters.forEach((w) => w.reject(e));
|
||||||
|
}
|
||||||
|
} while (s.rerun);
|
||||||
|
} finally {
|
||||||
|
s.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,15 @@ export async function profileFor(email) {
|
|||||||
|
|
||||||
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
||||||
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
||||||
export async function syncLibrary() {
|
//
|
||||||
|
// Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche
|
||||||
|
// Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms.
|
||||||
|
// `force: true` (z.B. nach Publish) überspringt die Drosselung.
|
||||||
|
const SYNC_TTL = 60_000;
|
||||||
|
let lastSync = 0;
|
||||||
|
export async function syncLibrary({ force = false } = {}) {
|
||||||
|
if (!force && Date.now() - lastSync < SYNC_TTL) return;
|
||||||
|
lastSync = Date.now();
|
||||||
const { data: forum } = await supabase
|
const { data: forum } = await supabase
|
||||||
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
||||||
if (!forum) return;
|
if (!forum) return;
|
||||||
@@ -34,15 +42,11 @@ export async function syncLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
||||||
|
// Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden.
|
||||||
async function commentStats() {
|
async function commentStats() {
|
||||||
const { data } = await supabase.from('comments').select('thread,created_at,deleted');
|
const { data } = await supabase.from('comment_stats').select('thread,count,last');
|
||||||
const map = {};
|
const map = {};
|
||||||
for (const r of data || []) {
|
for (const r of data || []) map[r.thread] = { count: r.count, last: r.last };
|
||||||
if (r.deleted) continue;
|
|
||||||
const t = map[r.thread] || (map[r.thread] = { count: 0, last: r.created_at });
|
|
||||||
t.count += 1;
|
|
||||||
if (r.created_at > t.last) t.last = r.created_at;
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ async function walk(dir) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beitrag (library/<section>/<slug>.md) | Rubrik (_index.md) | Seite (sonst).
|
// Beitrag (archiv/<section>/<slug>.md) | Library-Seite (library/<slug>.md)
|
||||||
|
// | Rubrik (_index.md) | Seite (sonst).
|
||||||
function classify(rel) {
|
function classify(rel) {
|
||||||
const base = path.basename(rel);
|
const base = path.basename(rel);
|
||||||
const parts = rel.split('/');
|
const parts = rel.split('/');
|
||||||
@@ -34,9 +35,12 @@ function classify(rel) {
|
|||||||
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
|
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
|
||||||
return { kind: 'rubrik', section };
|
return { kind: 'rubrik', section };
|
||||||
}
|
}
|
||||||
if (parts[0] === 'library' && parts.length === 3) {
|
if (parts[0] === 'archiv' && parts.length === 3) {
|
||||||
return { kind: 'beitrag', section: parts[1] };
|
return { kind: 'beitrag', section: parts[1] };
|
||||||
}
|
}
|
||||||
|
if (parts[0] === 'library') {
|
||||||
|
return { kind: 'biblio', section: 'library' };
|
||||||
|
}
|
||||||
return { kind: 'seite', section: null };
|
return { kind: 'seite', section: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +86,8 @@ export async function listEntries() {
|
|||||||
url: urlFor(rel),
|
url: urlFor(rel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel.
|
// Beiträge zuerst, dann Library, Seiten, Rubriken; je nach Datum/Titel.
|
||||||
const order = { beitrag: 0, seite: 1, rubrik: 2 };
|
const order = { beitrag: 0, biblio: 1, seite: 2, rubrik: 3 };
|
||||||
items.sort((a, b) =>
|
items.sort((a, b) =>
|
||||||
(order[a.kind] - order[b.kind]) ||
|
(order[a.kind] - order[b.kind]) ||
|
||||||
(b.date || '').localeCompare(a.date || '') ||
|
(b.date || '').localeCompare(a.date || '') ||
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import { coalesce } from './coalesce.js';
|
||||||
|
|
||||||
const execFileP = promisify(execFile);
|
const execFileP = promisify(execFile);
|
||||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
@@ -16,6 +17,13 @@ export async function hugoBuild({ dest, drafts = false } = {}) {
|
|||||||
return { stdout, stderr };
|
return { stdout, stderr };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Koaleszierter Build je Ziel: nie zwei `hugo`-Prozesse für dasselbe dest
|
||||||
|
// parallel; schnelle Folge-Aufrufe lösen nur einen nachgelagerten Build aus.
|
||||||
|
// Publish (public), Preview (preview) und Profil teilen sich diesen Weg.
|
||||||
|
export function buildSite({ dest, drafts = false } = {}) {
|
||||||
|
return coalesce(`build:${dest}:${drafts ? 'd' : 'p'}`, () => hugoBuild({ dest, drafts }));
|
||||||
|
}
|
||||||
|
|
||||||
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
|
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
|
||||||
// das Publish soll an einem Git-Problem nicht scheitern.
|
// das Publish soll an einem Git-Problem nicht scheitern.
|
||||||
export async function gitCommit(message) {
|
export async function gitCommit(message) {
|
||||||
|
|||||||
+50
-1
@@ -1,14 +1,19 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
import { serveStatic } from '@hono/node-server/serve-static';
|
||||||
import { Hono } from 'hono';
|
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 content from './routes/content.js';
|
||||||
import preview from './routes/preview.js';
|
import preview from './routes/preview.js';
|
||||||
import publish from './routes/publish.js';
|
import publish from './routes/publish.js';
|
||||||
import upload from './routes/upload.js';
|
import upload from './routes/upload.js';
|
||||||
import profile from './routes/profile.js';
|
import profile from './routes/profile.js';
|
||||||
import users from './routes/users.js';
|
import users from './routes/users.js';
|
||||||
|
import stats from './routes/stats.js';
|
||||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||||
|
import history from './routes/history.js';
|
||||||
import {
|
import {
|
||||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||||
} from './routes/dialog.js';
|
} from './routes/dialog.js';
|
||||||
@@ -21,7 +26,40 @@ const PORT = Number(process.env.PORT || 3000);
|
|||||||
|
|
||||||
const app = new Hono();
|
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',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Statische Assets cachen: Hugo fingerprintet CSS/JS, Uploads haben stabile,
|
||||||
|
// eindeutige Namen. HTML bleibt ungecacht (Antwort ohne Header → immer frisch).
|
||||||
|
app.use('*', async (c, next) => {
|
||||||
|
await next();
|
||||||
|
if (c.req.method === 'GET' && /\.(css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|webp|avif|gif|ico)$/i.test(c.req.path)) {
|
||||||
|
c.header('Cache-Control', 'public, max-age=604800'); // 1 Woche
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- API ---
|
// --- 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' }));
|
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.
|
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
||||||
app.get('/api/comments', listComments);
|
app.get('/api/comments', listComments);
|
||||||
@@ -29,9 +67,19 @@ app.get('/api/forums', listForums);
|
|||||||
app.get('/api/forums/:slug', showForum);
|
app.get('/api/forums/:slug', showForum);
|
||||||
app.get('/api/recent', recent);
|
app.get('/api/recent', recent);
|
||||||
app.get('/api/thread', threadInfo);
|
app.get('/api/thread', threadInfo);
|
||||||
app.post('/api/auth/login', login);
|
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||||
|
app.route('/api/history', history);
|
||||||
|
// 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.
|
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||||
app.use('/api/*', requireAuth);
|
app.use('/api/*', requireAuth);
|
||||||
|
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||||
|
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
||||||
|
const mutateLimit = rateLimit({
|
||||||
|
max: 60, windowMs: 60_000,
|
||||||
|
keyFn: (c) => 'u:' + (c.get('user')?.id || c.req.header('x-forwarded-for') || 'anon'),
|
||||||
|
});
|
||||||
|
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
|
||||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||||
app.post('/api/comments', createComment);
|
app.post('/api/comments', createComment);
|
||||||
app.delete('/api/comments/:id', deleteComment);
|
app.delete('/api/comments/:id', deleteComment);
|
||||||
@@ -44,6 +92,7 @@ app.route('/api/publish', publish);
|
|||||||
app.route('/api/upload', upload);
|
app.route('/api/upload', upload);
|
||||||
app.route('/api/profile', profile);
|
app.route('/api/profile', profile);
|
||||||
app.route('/api/users', users);
|
app.route('/api/users', users);
|
||||||
|
app.route('/api/stats', stats);
|
||||||
|
|
||||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||||
|
|||||||
@@ -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?.();
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { supabase, supabaseAuth } from '../supabase.js';
|
import { supabase, supabaseAuth } from '../supabase.js';
|
||||||
import { roleOf } from '../auth.js';
|
import { roleOf } from '../auth.js';
|
||||||
import { profileFor, threadLocked } from '../dialog-store.js';
|
import { profileFor, threadLocked } from '../dialog-store.js';
|
||||||
|
import { serverError } from '../util.js';
|
||||||
|
|
||||||
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
||||||
|
|
||||||
const COLS = 'id,thread,parent_id,author_name,author_avatar,body,created_at,deleted';
|
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.
|
// ÖFFENTLICH: Wortmeldungen eines Threads lesen.
|
||||||
export async function listComments(c) {
|
export async function listComments(c) {
|
||||||
@@ -12,7 +15,7 @@ export async function listComments(c) {
|
|||||||
if (!thread) return c.json({ error: 'thread fehlt' }, 400);
|
if (!thread) return c.json({ error: 'thread fehlt' }, 400);
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true });
|
.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));
|
const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r));
|
||||||
return c.json(out);
|
return c.json(out);
|
||||||
}
|
}
|
||||||
@@ -23,6 +26,8 @@ export async function createComment(c) {
|
|||||||
const email = c.get('email');
|
const email = c.get('email');
|
||||||
const { thread, body, parent_id } = await c.req.json();
|
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 (!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);
|
if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403);
|
||||||
|
|
||||||
const prof = await profileFor(email);
|
const prof = await profileFor(email);
|
||||||
@@ -32,10 +37,11 @@ export async function createComment(c) {
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
author_name: prof?.name || email.split('@')[0],
|
author_name: prof?.name || email.split('@')[0],
|
||||||
author_avatar: prof?.avatar || null,
|
author_avatar: prof?.avatar || null,
|
||||||
|
author_role: prof?.title || null, // „Position bei OPENBUREAU" (aus data/authors.json)
|
||||||
body: body.trim(),
|
body: body.trim(),
|
||||||
};
|
};
|
||||||
const { data, error } = await supabase.from('comments').insert(row).select(COLS).single();
|
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);
|
return c.json(data, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +54,7 @@ export async function deleteComment(c) {
|
|||||||
if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
|
if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
|
||||||
if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
|
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);
|
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 });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../supabase.js';
|
||||||
import { requireAdmin, requireModerator } from '../auth.js';
|
import { requireAdmin, requireModerator } from '../auth.js';
|
||||||
|
import { serverError } from '../util.js';
|
||||||
import {
|
import {
|
||||||
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
||||||
} from '../dialog-store.js';
|
} from '../dialog-store.js';
|
||||||
@@ -56,7 +57,7 @@ mod.post('/thread-lock', async (c) => {
|
|||||||
const { key, locked } = await c.req.json();
|
const { key, locked } = await c.req.json();
|
||||||
if (!key) return c.json({ error: 'key nötig' }, 400);
|
if (!key) return c.json({ error: 'key nötig' }, 400);
|
||||||
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
// Thread ausblenden (löschen).
|
// Thread ausblenden (löschen).
|
||||||
@@ -64,7 +65,7 @@ mod.post('/thread-delete', async (c) => {
|
|||||||
const { key } = await c.req.json();
|
const { key } = await c.req.json();
|
||||||
if (!key) return c.json({ error: 'key nötig' }, 400);
|
if (!key) return c.json({ error: 'key nötig' }, 400);
|
||||||
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export const adminForums = new Hono();
|
|||||||
adminForums.use('*', requireAdmin);
|
adminForums.use('*', requireAdmin);
|
||||||
adminForums.get('/', async (c) => {
|
adminForums.get('/', async (c) => {
|
||||||
const { data, error } = await supabase.from('forums').select('*').order('sort');
|
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 || []);
|
return c.json(data || []);
|
||||||
});
|
});
|
||||||
adminForums.post('/', async (c) => {
|
adminForums.post('/', async (c) => {
|
||||||
@@ -82,7 +83,7 @@ adminForums.post('/', async (c) => {
|
|||||||
const row = { slug: String(slug).trim(), name: String(name).trim(),
|
const row = { slug: String(slug).trim(), name: String(name).trim(),
|
||||||
description: description || '', color: color || null, sort: Number(sort) || 0 };
|
description: description || '', color: color || null, sort: Number(sort) || 0 };
|
||||||
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
|
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);
|
return c.json(data, 201);
|
||||||
});
|
});
|
||||||
adminForums.put('/:id', async (c) => {
|
adminForums.put('/:id', async (c) => {
|
||||||
@@ -90,7 +91,7 @@ adminForums.put('/:id', async (c) => {
|
|||||||
const allowed = {};
|
const allowed = {};
|
||||||
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
|
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();
|
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);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
adminForums.delete('/:id', async (c) => {
|
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();
|
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);
|
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);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { safeRel } from '../files.js';
|
||||||
|
|
||||||
|
// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History.
|
||||||
|
// Der Container hat das Repo unter /site gemountet + git installiert. Wir
|
||||||
|
// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site.
|
||||||
|
const execFileP = promisify(execFile);
|
||||||
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
|
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 });
|
||||||
|
const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor.
|
||||||
|
|
||||||
|
const history = new Hono();
|
||||||
|
|
||||||
|
// Liste der Versionen: neueste zuerst.
|
||||||
|
history.get('/', async (c) => {
|
||||||
|
let rel;
|
||||||
|
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||||
|
try {
|
||||||
|
const { stdout } = await git(
|
||||||
|
'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`);
|
||||||
|
const versions = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
||||||
|
const [rev, short, date, author, subject] = line.split(US);
|
||||||
|
return { rev, short, date, author, subject };
|
||||||
|
});
|
||||||
|
return c.json(versions);
|
||||||
|
} catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite.
|
||||||
|
history.get('/version', async (c) => {
|
||||||
|
let rel;
|
||||||
|
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||||
|
const rev = c.req.query('rev') || '';
|
||||||
|
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
|
||||||
|
try {
|
||||||
|
const { stdout } = await git('show', `${rev}:content/${rel}`);
|
||||||
|
const { data, content } = matter(stdout);
|
||||||
|
return c.json({
|
||||||
|
rev,
|
||||||
|
title: data.title || '',
|
||||||
|
date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null,
|
||||||
|
html: renderMarkdown(content),
|
||||||
|
});
|
||||||
|
} catch { return c.json({ error: 'Version nicht gefunden' }, 404); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unified-Diff einer Fassung (was dieser Commit an der Datei geändert hat) —
|
||||||
|
// fürs rot/grün-Diff auf der Seite. Roh-Diff; das Frontend färbt +/- ein.
|
||||||
|
history.get('/diff', async (c) => {
|
||||||
|
let rel;
|
||||||
|
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||||
|
const rev = c.req.query('rev') || '';
|
||||||
|
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
|
||||||
|
try {
|
||||||
|
const { stdout } = await git('show', '--format=', '--no-color', rev, '--', `content/${rel}`);
|
||||||
|
return c.json({ rev, diff: stdout });
|
||||||
|
} catch { return c.json({ error: 'Diff nicht verfügbar' }, 404); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher
|
||||||
|
// vorab: Definitionen einsammeln, Verweise zu <sup>-Nummern, „Quellen" anhängen
|
||||||
|
// (greift dieselbe .footnotes-CSS wie die Live-Seite).
|
||||||
|
function renderMarkdown(md) {
|
||||||
|
const defs = {}; const order = [];
|
||||||
|
md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; });
|
||||||
|
md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => {
|
||||||
|
if (!order.includes(id)) order.push(id);
|
||||||
|
return `<sup class="footnote-ref">${order.indexOf(id) + 1}</sup>`;
|
||||||
|
});
|
||||||
|
let html = marked.parse(md);
|
||||||
|
if (order.length) {
|
||||||
|
html += '<div class="footnotes"><ol>'
|
||||||
|
+ order.map((id) => `<li>${marked.parseInline(defs[id] || '')}</li>`).join('')
|
||||||
|
+ '</ol></div>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default history;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { urlFor, safeRel } from '../files.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { hugoBuild } from '../hugo.js';
|
import { buildSite } from '../hugo.js';
|
||||||
|
|
||||||
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
||||||
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
||||||
@@ -10,7 +10,7 @@ preview.post('/', async (c) => {
|
|||||||
const { path: rel } = await c.req.json();
|
const { path: rel } = await c.req.json();
|
||||||
try {
|
try {
|
||||||
const safe = safeRel(rel);
|
const safe = safeRel(rel);
|
||||||
const build = await hugoBuild({ dest: 'preview', drafts: true });
|
const build = await buildSite({ dest: 'preview', drafts: true });
|
||||||
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json({ error: String(e.message || e) }, 500);
|
return c.json({ error: String(e.message || e) }, 500);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { hugoBuild } from '../hugo.js';
|
import { buildSite } from '../hugo.js';
|
||||||
|
|
||||||
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
|
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
|
||||||
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
|
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
|
||||||
@@ -46,8 +46,8 @@ profile.put('/', async (c) => {
|
|||||||
}
|
}
|
||||||
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
|
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
|
||||||
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
|
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
|
||||||
// Live bauen, damit die Seite + Byline-Links sofort funktionieren.
|
// Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
|
||||||
await hugoBuild({ dest: 'public', drafts: false }).catch(() => {});
|
await buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ ok: true, slug });
|
return c.json({ ok: true, slug });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { urlFor, safeRel } from '../files.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { hugoBuild, gitCommit } from '../hugo.js';
|
import { buildSite, gitCommit } from '../hugo.js';
|
||||||
|
import { syncLibrary } from '../dialog-store.js';
|
||||||
|
|
||||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||||
const publish = new Hono();
|
const publish = new Hono();
|
||||||
@@ -9,7 +10,9 @@ publish.post('/', async (c) => {
|
|||||||
const { path: rel } = await c.req.json();
|
const { path: rel } = await c.req.json();
|
||||||
try {
|
try {
|
||||||
const safe = safeRel(rel);
|
const safe = safeRel(rel);
|
||||||
const build = await hugoBuild({ dest: 'public', drafts: false });
|
const build = await buildSite({ dest: 'public', drafts: false });
|
||||||
|
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
|
||||||
|
await syncLibrary({ force: true }).catch(() => {});
|
||||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { supabase } from '../supabase.js';
|
||||||
|
import { listEntries } from '../files.js';
|
||||||
|
import { requireAdmin, roleOf } from '../auth.js';
|
||||||
|
|
||||||
|
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||||
|
const stats = new Hono();
|
||||||
|
stats.use('*', requireAdmin);
|
||||||
|
|
||||||
|
stats.get('/', async (c) => {
|
||||||
|
// Inhalte aus dem Dateisystem zählen.
|
||||||
|
const content = { beitraege: 0, entwuerfe: 0, library: 0, seiten: 0, rubriken: 0 };
|
||||||
|
try {
|
||||||
|
for (const e of await listEntries()) {
|
||||||
|
if (e.kind === 'beitrag') { content.beitraege++; if (e.draft) content.entwuerfe++; }
|
||||||
|
else if (e.kind === 'biblio') content.library++;
|
||||||
|
else if (e.kind === 'rubrik') content.rubriken++;
|
||||||
|
else content.seiten++;
|
||||||
|
}
|
||||||
|
} catch { /* Filesystem nicht lesbar → 0 */ }
|
||||||
|
|
||||||
|
// Nutzer nach Rolle.
|
||||||
|
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||||
|
try {
|
||||||
|
const { data } = await supabase.auth.admin.listUsers();
|
||||||
|
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||||
|
} catch { /* GoTrue nicht erreichbar */ }
|
||||||
|
|
||||||
|
// Dialog-Zähler (effizient: head + count, keine Zeilen laden).
|
||||||
|
const count = async (table, filter) => {
|
||||||
|
try {
|
||||||
|
let q = supabase.from(table).select('*', { count: 'exact', head: true });
|
||||||
|
if (filter) q = filter(q);
|
||||||
|
const { count: n } = await q;
|
||||||
|
return n || 0;
|
||||||
|
} catch { return 0; }
|
||||||
|
};
|
||||||
|
const [forums, threads, comments] = await Promise.all([
|
||||||
|
count('forums'),
|
||||||
|
count('threads', (q) => q.eq('deleted', false)),
|
||||||
|
count('comments', (q) => q.eq('deleted', false)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({ content, users, dialog: { forums, threads, comments } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default stats;
|
||||||
@@ -6,8 +6,14 @@ import sharp from 'sharp';
|
|||||||
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
|
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
|
||||||
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
|
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
|
||||||
// SVG/GIF bleiben unangetastet (Vektor/Animation erhalten).
|
// 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 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();
|
const upload = new Hono();
|
||||||
|
|
||||||
@@ -15,19 +21,42 @@ upload.post('/', async (c) => {
|
|||||||
const body = await c.req.parseBody();
|
const body = await c.req.parseBody();
|
||||||
const file = body['file'];
|
const file = body['file'];
|
||||||
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
|
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');
|
const dir = path.join(SITE_DIR, 'static', 'images');
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
const ext = path.extname(file.name || '').toLowerCase();
|
const ext = path.extname(file.name || '').toLowerCase();
|
||||||
const base = `${safeBase(file.name)}-${uid()}`;
|
const base = `${safeBase(file.name)}-${uid()}`;
|
||||||
|
|
||||||
let outName, outBuf;
|
let outName, outBuf;
|
||||||
if (PASSTHROUGH.has(ext)) {
|
if (ext === '.svg') {
|
||||||
outName = `${base}${ext}`;
|
// 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;
|
outBuf = buffer;
|
||||||
} else {
|
} 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`;
|
outName = `${base}.webp`;
|
||||||
outBuf = await sharp(buffer)
|
outBuf = await sharp(buffer)
|
||||||
.rotate()
|
.rotate()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ users.get('/', async (c) => {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
|
last_sign_in_at: u.last_sign_in_at || null,
|
||||||
role,
|
role,
|
||||||
isAdmin: role === 'admin',
|
isAdmin: role === 'admin',
|
||||||
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||||
@@ -28,9 +29,12 @@ users.get('/', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
users.post('/', async (c) => {
|
users.post('/', async (c) => {
|
||||||
const { email, password } = await c.req.json();
|
const { email, password, role } = await c.req.json();
|
||||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||||
const { data, error } = await supabase.auth.admin.createUser({ email, password, email_confirm: true });
|
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||||
|
const payload = { email, password, email_confirm: true };
|
||||||
|
if (role && role !== 'user') payload.app_metadata = { role };
|
||||||
|
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true, id: data.user.id });
|
return c.json({ ok: true, id: data.user.id });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
// Env vor dem Import setzen: supabase.js bricht ohne URL/Key ab, ADMIN_EMAILS
|
||||||
|
// und JWT_SECRET werden beim Modul-Load gelesen.
|
||||||
|
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||||
|
process.env.SUPABASE_SERVICE_KEY ||= 'dummy';
|
||||||
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
|
process.env.ADMIN_EMAILS = 'boss@x.ch';
|
||||||
|
|
||||||
|
const { roleOf, requireAuth } = await import('../src/auth.js');
|
||||||
|
const { sign } = await import('hono/jwt');
|
||||||
|
|
||||||
|
test('roleOf: Admin aus ADMIN_EMAILS', () => {
|
||||||
|
assert.equal(roleOf({ email: 'boss@x.ch' }), 'admin');
|
||||||
|
assert.equal(roleOf({ email: 'BOSS@X.CH' }), 'admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roleOf: Rolle aus app_metadata', () => {
|
||||||
|
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'admin' } }), 'admin');
|
||||||
|
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'editor' } }), 'editor');
|
||||||
|
assert.equal(roleOf({ email: 'a@x.ch' }), 'user');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minimaler Hono-Kontext-Stub.
|
||||||
|
function fakeCtx(authHeader) {
|
||||||
|
const store = {};
|
||||||
|
return {
|
||||||
|
req: { header: (h) => (h === 'Authorization' ? authHeader : undefined) },
|
||||||
|
set: (k, v) => { store[k] = v; },
|
||||||
|
get: (k) => store[k],
|
||||||
|
json: (body, status = 200) => ({ __status: status, body }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('requireAuth: gültiges Token wird lokal verifiziert', async () => {
|
||||||
|
const token = await sign(
|
||||||
|
{ sub: 'u1', email: 'A@x.ch', app_metadata: { role: 'editor' }, exp: Math.floor(Date.now() / 1000) + 60 },
|
||||||
|
'test-secret', 'HS256');
|
||||||
|
let passed = false;
|
||||||
|
const c = fakeCtx('Bearer ' + token);
|
||||||
|
await requireAuth(c, async () => { passed = true; });
|
||||||
|
assert.equal(passed, true);
|
||||||
|
assert.equal(c.get('email'), 'a@x.ch'); // kleingeschrieben
|
||||||
|
assert.equal(c.get('role'), 'editor');
|
||||||
|
assert.equal(c.get('canModerate'), true);
|
||||||
|
assert.equal(c.get('isAdmin'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requireAuth: fehlendes Token → 401', async () => {
|
||||||
|
const c = fakeCtx('');
|
||||||
|
const r = await requireAuth(c, async () => { throw new Error('darf nicht laufen'); });
|
||||||
|
assert.equal(r.__status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requireAuth: kaputtes/falsch signiertes Token → 401', async () => {
|
||||||
|
const bad = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }, 'falsches-secret', 'HS256');
|
||||||
|
for (const t of ['Bearer garbage', 'Bearer ' + bad]) {
|
||||||
|
const r = await requireAuth(fakeCtx(t), async () => { throw new Error('darf nicht laufen'); });
|
||||||
|
assert.equal(r.__status, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requireAuth: abgelaufenes Token → 401', async () => {
|
||||||
|
const expired = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) - 10 }, 'test-secret', 'HS256');
|
||||||
|
const r = await requireAuth(fakeCtx('Bearer ' + expired), async () => { throw new Error('darf nicht laufen'); });
|
||||||
|
assert.equal(r.__status, 401);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const { coalesce } = await import('../src/coalesce.js');
|
||||||
|
|
||||||
|
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
test('coalesce: nie mehr als ein Lauf gleichzeitig pro Key', async () => {
|
||||||
|
let active = 0, maxActive = 0, runs = 0;
|
||||||
|
const fn = async () => { active++; maxActive = Math.max(maxActive, active); await tick(10); runs++; active--; return runs; };
|
||||||
|
|
||||||
|
// 5 gleichzeitige Aufrufe.
|
||||||
|
await Promise.all(Array.from({ length: 5 }, () => coalesce('k1', fn)));
|
||||||
|
assert.equal(maxActive, 1, 'parallele Läufe');
|
||||||
|
// Erster Lauf bedient den ersten Aufruf; die 4 während des Laufs eingetroffenen
|
||||||
|
// teilen sich GENAU EINEN nachgelagerten Lauf → insgesamt 2.
|
||||||
|
assert.equal(runs, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesce: Wartende teilen sich das Ergebnis des nachgelagerten Laufs', async () => {
|
||||||
|
let n = 0;
|
||||||
|
const fn = async () => { await tick(10); return ++n; };
|
||||||
|
const first = coalesce('k2', fn); // startet sofort → Ergebnis 1
|
||||||
|
await tick(2); // sicherstellen, dass er läuft
|
||||||
|
const a = coalesce('k2', fn); // wartet → nachgelagerter Lauf
|
||||||
|
const b = coalesce('k2', fn); // wartet → selber Lauf wie a
|
||||||
|
assert.equal(await first, 1);
|
||||||
|
const [ra, rb] = await Promise.all([a, b]);
|
||||||
|
assert.equal(ra, 2);
|
||||||
|
assert.equal(rb, 2); // a und b teilen sich Lauf 2
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesce: Fehler wird an die Wartenden propagiert, Key bleibt nutzbar', async () => {
|
||||||
|
let fail = true;
|
||||||
|
const fn = async () => { await tick(5); if (fail) throw new Error('boom'); return 'ok'; };
|
||||||
|
await assert.rejects(() => coalesce('k3', fn), /boom/);
|
||||||
|
fail = false;
|
||||||
|
assert.equal(await coalesce('k3', fn), 'ok'); // danach wieder verwendbar
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesce: verschiedene Keys laufen unabhängig', async () => {
|
||||||
|
const fn = async () => { await tick(5); return 'done'; };
|
||||||
|
const [x, y] = await Promise.all([coalesce('kA', fn), coalesce('kB', fn)]);
|
||||||
|
assert.equal(x, 'done');
|
||||||
|
assert.equal(y, 'done');
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const { safeRel, normAuthors, hasAccess, urlFor } = await import('../src/files.js');
|
||||||
|
|
||||||
|
test('safeRel: gültiger relativer .md-Pfad bleibt erhalten', () => {
|
||||||
|
assert.equal(safeRel('library/software/stack.md'), 'library/software/stack.md');
|
||||||
|
assert.equal(safeRel('a/./b.md'), 'a/b.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safeRel: Path-Traversal wird abgelehnt', () => {
|
||||||
|
assert.throws(() => safeRel('../etc/passwd.md'));
|
||||||
|
assert.throws(() => safeRel('a/../../b.md'));
|
||||||
|
assert.throws(() => safeRel('/absolut.md'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safeRel: nur .md erlaubt, leer/falsch wirft', () => {
|
||||||
|
assert.throws(() => safeRel('note.txt'));
|
||||||
|
assert.throws(() => safeRel(''));
|
||||||
|
assert.throws(() => safeRel(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normAuthors: String/Array/Leer normalisieren', () => {
|
||||||
|
assert.deepEqual(normAuthors('a@x.ch'), ['a@x.ch']);
|
||||||
|
assert.deepEqual(normAuthors(['a@x.ch', 'b@y.ch']), ['a@x.ch', 'b@y.ch']);
|
||||||
|
assert.deepEqual(normAuthors(null), []);
|
||||||
|
assert.deepEqual(normAuthors([]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAccess: case-insensitive Mitgliedschaft', () => {
|
||||||
|
assert.equal(hasAccess(['Karim@x.ch'], 'karim@x.ch'), true);
|
||||||
|
assert.equal(hasAccess(['a@x.ch'], 'b@y.ch'), false);
|
||||||
|
assert.equal(hasAccess([], 'a@x.ch'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('urlFor: Hugo-URLs aus relativem Pfad', () => {
|
||||||
|
assert.equal(urlFor('_index.md'), '/');
|
||||||
|
assert.equal(urlFor('manifest.md'), '/manifest/');
|
||||||
|
assert.equal(urlFor('library/software/stack.md'), '/library/software/stack/');
|
||||||
|
assert.equal(urlFor('software/_index.md'), '/software/');
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const { rateLimit } = await import('../src/ratelimit.js');
|
||||||
|
|
||||||
|
function fakeCtx() {
|
||||||
|
const headers = {};
|
||||||
|
return {
|
||||||
|
req: { header: () => undefined },
|
||||||
|
header: (k, v) => { headers[k] = v; },
|
||||||
|
json: (body, status = 200) => ({ __status: status, body }),
|
||||||
|
_headers: headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rateLimit: blockt nach Überschreiten mit 429 + Retry-After', async () => {
|
||||||
|
const mw = rateLimit({ max: 2, windowMs: 10_000, keyFn: () => 'fix' });
|
||||||
|
let calls = 0;
|
||||||
|
const run = async () => { const c = fakeCtx(); const r = await mw(c, async () => { calls++; }); return { c, r }; };
|
||||||
|
|
||||||
|
assert.equal((await run()).r, undefined); // 1 → durch (next, kein Return)
|
||||||
|
assert.equal((await run()).r, undefined); // 2 → durch
|
||||||
|
const { c, r } = await run(); // 3 → blockiert
|
||||||
|
assert.equal(r.__status, 429);
|
||||||
|
assert.ok(c._headers['Retry-After']);
|
||||||
|
assert.equal(calls, 2); // next nur zweimal aufgerufen
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rateLimit: getrennte Schlüssel zählen getrennt', async () => {
|
||||||
|
let key = 'a';
|
||||||
|
const mw = rateLimit({ max: 1, windowMs: 10_000, keyFn: () => key });
|
||||||
|
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // a:1 ok
|
||||||
|
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); // a:2 blockiert
|
||||||
|
key = 'b';
|
||||||
|
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // b:1 ok
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rateLimit: Fenster läuft ab → wieder frei', async () => {
|
||||||
|
const mw = rateLimit({ max: 1, windowMs: 30, keyFn: () => 'win' });
|
||||||
|
assert.equal((await mw(fakeCtx(), async () => {})), undefined);
|
||||||
|
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429);
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // Fenster neu
|
||||||
|
});
|
||||||
+22
-3
@@ -31,6 +31,7 @@ create index if not exists posts_section_idx on public.posts (section);
|
|||||||
-- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das
|
-- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das
|
||||||
-- Frontend später direkt liest, hier gezielte Policies ergänzen.
|
-- Frontend später direkt liest, hier gezielte Policies ergänzen.
|
||||||
alter table public.posts enable row level security;
|
alter table public.posts enable row level security;
|
||||||
|
revoke all on public.posts from anon, authenticated;
|
||||||
|
|
||||||
-- ── Dialog / Diskussionen ───────────────────────────────────────────────
|
-- ── Dialog / Diskussionen ───────────────────────────────────────────────
|
||||||
-- Thread = Pfad des Beitrags (z.B. /library/software/stack/). Flache Wortmeldungen
|
-- Thread = Pfad des Beitrags (z.B. /library/software/stack/). Flache Wortmeldungen
|
||||||
@@ -46,9 +47,25 @@ create table if not exists public.comments (
|
|||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
deleted boolean not null default false
|
deleted boolean not null default false
|
||||||
);
|
);
|
||||||
|
-- Position/Rolle bei OPENBUREAU (optional, neben dem Namen angezeigt).
|
||||||
|
alter table public.comments add column if not exists author_role text;
|
||||||
create index if not exists comments_thread_idx on public.comments (thread, created_at);
|
create index if not exists comments_thread_idx on public.comments (thread, created_at);
|
||||||
alter table public.comments enable row level security;
|
alter table public.comments enable row level security;
|
||||||
grant all on public.comments to anon, authenticated, service_role;
|
-- Nur die Node-API (service_role) greift auf die Tabellen zu; der Browser geht
|
||||||
|
-- ausschliesslich über /api/*. anon/authenticated bekommen KEINE Tabellenrechte,
|
||||||
|
-- damit das öffentlich erreichbare /rest/v1 auch bei künftigen RLS-Policies dicht
|
||||||
|
-- bleibt (Defense-in-Depth, nicht nur "RLS ohne Policy").
|
||||||
|
grant all on public.comments to service_role;
|
||||||
|
revoke all on public.comments from anon, authenticated;
|
||||||
|
|
||||||
|
-- Aggregat je Thread (Anzahl + letzte Aktivität). Spart der API den Full-Table-
|
||||||
|
-- Scan + JS-Aggregation bei jedem Forum-Aufruf; Postgres zählt direkt.
|
||||||
|
create or replace view public.comment_stats as
|
||||||
|
select thread, count(*)::int as count, max(created_at) as last
|
||||||
|
from public.comments
|
||||||
|
where not deleted
|
||||||
|
group by thread;
|
||||||
|
grant select on public.comment_stats to service_role;
|
||||||
|
|
||||||
-- ── Foren / Subforen ────────────────────────────────────────────────────
|
-- ── Foren / Subforen ────────────────────────────────────────────────────
|
||||||
-- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die
|
-- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die
|
||||||
@@ -64,7 +81,8 @@ create table if not exists public.forums (
|
|||||||
created_at timestamptz not null default now()
|
created_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
alter table public.forums enable row level security;
|
alter table public.forums enable row level security;
|
||||||
grant all on public.forums to anon, authenticated, service_role;
|
grant all on public.forums to service_role;
|
||||||
|
revoke all on public.forums from anon, authenticated;
|
||||||
|
|
||||||
-- ── Threads (Diskussionen) ──────────────────────────────────────────────
|
-- ── Threads (Diskussionen) ──────────────────────────────────────────────
|
||||||
-- key = stabiler Bezeichner, den comments.thread referenziert:
|
-- key = stabiler Bezeichner, den comments.thread referenziert:
|
||||||
@@ -85,7 +103,8 @@ create table if not exists public.threads (
|
|||||||
);
|
);
|
||||||
create index if not exists threads_forum_idx on public.threads (forum_id);
|
create index if not exists threads_forum_idx on public.threads (forum_id);
|
||||||
alter table public.threads enable row level security;
|
alter table public.threads enable row level security;
|
||||||
grant all on public.threads to anon, authenticated, service_role;
|
grant all on public.threads to service_role;
|
||||||
|
revoke all on public.threads from anon, authenticated;
|
||||||
|
|
||||||
-- Seed-Kategorien (idempotent; im Admin umbenenn-/erweiterbar).
|
-- Seed-Kategorien (idempotent; im Admin umbenenn-/erweiterbar).
|
||||||
insert into public.forums (slug, name, sort, kind) values
|
insert into public.forums (slug, name, sort, kind) values
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
-- OPENBUREAU — OPTIONALER Demo-Inhalt fürs Forum (Dialog).
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
-- NICHT Teil der Migration: bewusst getrennt von schema.sql, damit die
|
||||||
|
-- Produktion sauber bleibt. Nur bei Bedarf manuell einspielen, z.B.:
|
||||||
|
--
|
||||||
|
-- docker compose exec -T db \
|
||||||
|
-- psql -U supabase_admin -d postgres < db/seed-demo.sql
|
||||||
|
--
|
||||||
|
-- Idempotent: feste UUIDs + ON CONFLICT DO NOTHING → mehrfaches Einspielen
|
||||||
|
-- erzeugt keine Duplikate. Demo-Wortmeldungen haben user_id = NULL (keine
|
||||||
|
-- echten Konten); author_name/author_role sind nur Anzeigetext.
|
||||||
|
--
|
||||||
|
-- Wieder entfernen: siehe DELETE-Block ganz unten (auskommentiert).
|
||||||
|
-- ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- ── Threads ───────────────────────────────────────────────────────────────
|
||||||
|
insert into public.threads (id, forum_id, key, title, url, kind, author_name, created_at) values
|
||||||
|
('a1111111-1111-1111-1111-111111111101',
|
||||||
|
(select id from public.forums where slug = 'allgemein'),
|
||||||
|
't/a1111111-1111-1111-1111-111111111101',
|
||||||
|
'Willkommen im OPENBUREAU-Dialog',
|
||||||
|
'/dialog/?thread=t%2Fa1111111-1111-1111-1111-111111111101',
|
||||||
|
'forum', 'Karim', now() - interval '9 days'),
|
||||||
|
|
||||||
|
('a2222222-2222-2222-2222-222222222202',
|
||||||
|
(select id from public.forums where slug = 'projekte'),
|
||||||
|
't/a2222222-2222-2222-2222-222222222202',
|
||||||
|
'Altbausanierung Zürich — Materialwahl Innendämmung',
|
||||||
|
'/dialog/?thread=t%2Fa2222222-2222-2222-2222-222222222202',
|
||||||
|
'forum', 'Karim', now() - interval '6 days'),
|
||||||
|
|
||||||
|
('a3333333-3333-3333-3333-333333333303',
|
||||||
|
(select id from public.forums where slug = 'technik'),
|
||||||
|
't/a3333333-3333-3333-3333-333333333303',
|
||||||
|
'Hugo-Build-Zeiten bei großen Bildmengen',
|
||||||
|
'/dialog/?thread=t%2Fa3333333-3333-3333-3333-333333333303',
|
||||||
|
'forum', 'Mara', now() - interval '3 days'),
|
||||||
|
|
||||||
|
('a4444444-4444-4444-4444-444444444404',
|
||||||
|
(select id from public.forums where slug = 'off-topic'),
|
||||||
|
't/a4444444-4444-4444-4444-444444444404',
|
||||||
|
'Welches Architekturbuch hat euch geprägt?',
|
||||||
|
'/dialog/?thread=t%2Fa4444444-4444-4444-4444-444444444404',
|
||||||
|
'forum', 'Jonas', now() - interval '2 days')
|
||||||
|
on conflict (key) do nothing;
|
||||||
|
|
||||||
|
-- ── Wortmeldungen ─────────────────────────────────────────────────────────
|
||||||
|
-- parent_id verweist auf eine andere Wortmeldung (Antwort-Bezug).
|
||||||
|
insert into public.comments (id, thread, parent_id, author_name, author_role, body, created_at) values
|
||||||
|
-- Thread 1: Willkommen
|
||||||
|
('c1111111-0000-0000-0000-000000000001',
|
||||||
|
't/a1111111-1111-1111-1111-111111111101', null, 'Karim', 'Gründer',
|
||||||
|
'Hallo zusammen — dieser Bereich ist für den offenen Austausch rund ums Büro: Projekte, Methoden, Werkzeuge. Lest euch ein, schreibt mit.',
|
||||||
|
now() - interval '9 days'),
|
||||||
|
('c1111111-0000-0000-0000-000000000002',
|
||||||
|
't/a1111111-1111-1111-1111-111111111101',
|
||||||
|
'c1111111-0000-0000-0000-000000000001', 'Mara', 'Projektarchitektin',
|
||||||
|
'Schön, dass es losgeht. Gibt es eine Empfehlung, wie wir Projektdiskussionen von allgemeinem Plausch trennen?',
|
||||||
|
now() - interval '8 days'),
|
||||||
|
('c1111111-0000-0000-0000-000000000003',
|
||||||
|
't/a1111111-1111-1111-1111-111111111101',
|
||||||
|
'c1111111-0000-0000-0000-000000000002', 'Karim', 'Gründer',
|
||||||
|
'Genau dafür gibt es die Kategorie „Projekte". „Off-Topic" ist für alles andere.',
|
||||||
|
now() - interval '8 days'),
|
||||||
|
|
||||||
|
-- Thread 2: Innendämmung
|
||||||
|
('c2222222-0000-0000-0000-000000000001',
|
||||||
|
't/a2222222-2222-2222-2222-222222222202', null, 'Karim', 'Gründer',
|
||||||
|
'Beim Altbau an der Seestrasse steht die Innendämmung an. Kalziumsilikat oder mineralischer Dämmputz? Erfahrungen mit Feuchteverhalten?',
|
||||||
|
now() - interval '6 days'),
|
||||||
|
('c2222222-0000-0000-0000-000000000002',
|
||||||
|
't/a2222222-2222-2222-2222-222222222202',
|
||||||
|
'c2222222-0000-0000-0000-000000000001', 'Mara', 'Projektarchitektin',
|
||||||
|
'Kalziumsilikat ist diffusionsoffen und kapillaraktiv — bei den Bestandswänden dort würde ich das vorziehen. Wichtig ist die Detailausbildung an den Holzbalkenköpfen.',
|
||||||
|
now() - interval '5 days'),
|
||||||
|
('c2222222-0000-0000-0000-000000000003',
|
||||||
|
't/a2222222-2222-2222-2222-222222222202',
|
||||||
|
'c2222222-0000-0000-0000-000000000002', 'Jonas', 'Bauleiter',
|
||||||
|
'Plus eins für Kalziumsilikat. Ich hänge nächste Woche die hygrothermische Simulation an, dann sehen wir die Tauwasserbilanz.',
|
||||||
|
now() - interval '4 days'),
|
||||||
|
|
||||||
|
-- Thread 3: Hugo-Builds
|
||||||
|
('c3333333-0000-0000-0000-000000000001',
|
||||||
|
't/a3333333-3333-3333-3333-333333333303', null, 'Mara', 'Projektarchitektin',
|
||||||
|
'Seit die Projektgalerien dazugekommen sind, dauert der Build spürbar länger. Hat jemand die Bildverarbeitung schon optimiert?',
|
||||||
|
now() - interval '3 days'),
|
||||||
|
('c3333333-0000-0000-0000-000000000002',
|
||||||
|
't/a3333333-3333-3333-3333-333333333303',
|
||||||
|
'c3333333-0000-0000-0000-000000000001', 'Karim', 'Gründer',
|
||||||
|
'Hugo cached die Image-Resizes unter resources/. Solange der Ordner erhalten bleibt, werden nur neue Bilder neu gerechnet — das war bei uns der größte Hebel.',
|
||||||
|
now() - interval '2 days'),
|
||||||
|
|
||||||
|
-- Thread 4: Architekturbuch
|
||||||
|
('c4444444-0000-0000-0000-000000000001',
|
||||||
|
't/a4444444-4444-4444-4444-444444444404', null, 'Jonas', 'Bauleiter',
|
||||||
|
'Bei mir war es „Atmosphären" von Peter Zumthor — schmal, aber prägend. Was hat euch geformt?',
|
||||||
|
now() - interval '2 days'),
|
||||||
|
('c4444444-0000-0000-0000-000000000002',
|
||||||
|
't/a4444444-4444-4444-4444-444444444404',
|
||||||
|
'c4444444-0000-0000-0000-000000000001', 'Mara', 'Projektarchitektin',
|
||||||
|
'„Complexity and Contradiction" von Venturi — hat mein Verständnis von Fassaden komplett verschoben.',
|
||||||
|
now() - interval '1 day')
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
-- ── Wortmeldungen auf den Musterbeiträgen (Dialog am Artikel) ─────────────
|
||||||
|
-- thread = Beitrags-URL (Library-Beiträge werden als Threads gespiegelt).
|
||||||
|
insert into public.comments (id, thread, parent_id, author_name, author_role, body, created_at) values
|
||||||
|
('d1111111-0000-0000-0000-000000000001',
|
||||||
|
'/library/theorie/muster-typologie-fussnoten/', null, 'Mara', 'Projektarchitektin',
|
||||||
|
'Schöne Verdichtung. Würdest du Léon Krier hier dazustellen, oder ist das eine andere Debatte?',
|
||||||
|
now() - interval '4 days'),
|
||||||
|
('d1111111-0000-0000-0000-000000000002',
|
||||||
|
'/library/theorie/muster-typologie-fussnoten/',
|
||||||
|
'd1111111-0000-0000-0000-000000000001', 'Karim', 'Gründer',
|
||||||
|
'Eigene Debatte — Krier zielt auf die Wiederherstellung der Stadt. Hier ging es mir nur um Typus vs. Modell.',
|
||||||
|
now() - interval '3 days'),
|
||||||
|
|
||||||
|
('d2222222-0000-0000-0000-000000000001',
|
||||||
|
'/library/buerofuehrung/muster-offen-arbeiten/', null, 'Jonas', 'Bauleiter',
|
||||||
|
'Die Lizenzfrage unterschätzen viele. Gut, das einmal klar getrennt zu sehen.',
|
||||||
|
now() - interval '2 days'),
|
||||||
|
|
||||||
|
('d3333333-0000-0000-0000-000000000001',
|
||||||
|
'/library/software/muster-werkzeugkette/', null, 'Mara', 'Projektarchitektin',
|
||||||
|
'Ein Befehl ist Gold wert. Läuft der Build bei euch auch im CMS-Container?',
|
||||||
|
now() - interval '1 day'),
|
||||||
|
('d3333333-0000-0000-0000-000000000002',
|
||||||
|
'/library/software/muster-werkzeugkette/',
|
||||||
|
'd3333333-0000-0000-0000-000000000001', 'Karim', 'Gründer',
|
||||||
|
'Genau — der Container hat das Hugo-Binary, Publish baut public/ direkt.',
|
||||||
|
now() - interval '12 hours')
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
|
||||||
|
-- ── Demo-Inhalt wieder entfernen (bei Bedarf auskommentieren) ──────────────
|
||||||
|
-- delete from public.comments where id::text like 'c_______-0000-0000-0000-%';
|
||||||
|
-- delete from public.comments where id::text like 'd_______-0000-0000-0000-%';
|
||||||
|
-- delete from public.threads where key in (
|
||||||
|
-- 't/a1111111-1111-1111-1111-111111111101',
|
||||||
|
-- 't/a2222222-2222-2222-2222-222222222202',
|
||||||
|
-- 't/a3333333-3333-3333-3333-333333333303',
|
||||||
|
-- 't/a4444444-4444-4444-4444-444444444404');
|
||||||
|
-- (Musterbeitrag-Threads verschwinden mit den .md-Dateien automatisch.)
|
||||||
+15
-3
@@ -6,7 +6,10 @@
|
|||||||
# 2. JWT_SECRET + POSTGRES_PASSWORD setzen (openssl rand -hex 32)
|
# 2. JWT_SECRET + POSTGRES_PASSWORD setzen (openssl rand -hex 32)
|
||||||
# 3. node scripts/generate-keys.mjs → ANON_KEY + SERVICE_ROLE_KEY in .env
|
# 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
|
# 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 1–6 automatisch.)
|
||||||
# Dann: docker compose up -d --build
|
# Dann: docker compose up -d --build
|
||||||
#
|
#
|
||||||
# Abweichung von RAPPORT: realtime + storage weggelassen (nutzt das CMS nicht).
|
# Abweichung von RAPPORT: realtime + storage weggelassen (nutzt das CMS nicht).
|
||||||
@@ -86,6 +89,10 @@ services:
|
|||||||
# Single-Author: Self-Signup aus. User wird per Admin-API angelegt
|
# Single-Author: Self-Signup aus. User wird per Admin-API angelegt
|
||||||
# (Kommando steht im README / LXC-Output).
|
# (Kommando steht im README / LXC-Output).
|
||||||
GOTRUE_DISABLE_SIGNUP: "true"
|
GOTRUE_DISABLE_SIGNUP: "true"
|
||||||
|
# Brute-Force-Bremse aufs /token: das öffentliche Login läuft direkt gegen
|
||||||
|
# GoTrue (nicht über das Node-Rate-Limit), daher hier kappen — max. 100
|
||||||
|
# Token-Anfragen / 5 Min. Reichlich für einen Autor, bremst Rateversuche.
|
||||||
|
GOTRUE_RATE_LIMIT_TOKEN_REFRESH: "100"
|
||||||
GOTRUE_JWT_ADMIN_ROLES: service_role
|
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||||
GOTRUE_JWT_AUD: authenticated
|
GOTRUE_JWT_AUD: authenticated
|
||||||
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||||
@@ -129,8 +136,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./kong.yml:/var/lib/kong/kong.yml:ro
|
- ./kong.yml:/var/lib/kong/kong.yml:ro
|
||||||
ports:
|
ports:
|
||||||
- "${KONG_HTTP_PORT:-8000}:8000"
|
# Standard 127.0.0.1: nur lokal/Reverse-Proxy erreichbar. Für LAN-Direkt-
|
||||||
- "${KONG_HTTPS_PORT:-8443}:8443"
|
# 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
|
# CMS — Node-API + Hugo-Binary + Admin-SPA, serviert die Site
|
||||||
@@ -156,6 +165,8 @@ services:
|
|||||||
# Server-seitig: intern über Kong, mit Service-Key.
|
# Server-seitig: intern über Kong, mit Service-Key.
|
||||||
SUPABASE_URL: http://kong:8000
|
SUPABASE_URL: http://kong:8000
|
||||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
# Für lokale JWT-Verifikation (kein GoTrue-Roundtrip pro Request).
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
SITE_DIR: /site
|
SITE_DIR: /site
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
@@ -168,7 +179,8 @@ services:
|
|||||||
# Repo-Root: api schreibt content/ und baut public/ + preview/.
|
# Repo-Root: api schreibt content/ und baut public/ + preview/.
|
||||||
- ..:/site
|
- ..:/site
|
||||||
ports:
|
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:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ services:
|
|||||||
- /auth/v1/
|
- /auth/v1/
|
||||||
plugins:
|
plugins:
|
||||||
- name: cors
|
- 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
|
- name: rest-v1
|
||||||
url: http://rest:3000/
|
url: http://rest:3000/
|
||||||
@@ -25,3 +35,13 @@ services:
|
|||||||
- /rest/v1/
|
- /rest/v1/
|
||||||
plugins:
|
plugins:
|
||||||
- name: cors
|
- 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
|
||||||
|
|||||||
@@ -13,23 +13,30 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
############################ CONFIG ############################
|
############################ CONFIG ############################
|
||||||
|
# Alle Werte sind per Umgebungsvariable überschreibbar, z.B.:
|
||||||
|
# ROOTFS_STORAGE=local-zfs HOSTNAME=openbureau-dev SITE_DOMAIN=dev.openbureau.ch \
|
||||||
|
# IP=192.168.1.134/24 GATEWAY=192.168.1.1 bash create-openbureau-lxc.sh
|
||||||
CTID="${CTID:-$(pvesh get /cluster/nextid)}"
|
CTID="${CTID:-$(pvesh get /cluster/nextid)}"
|
||||||
HOSTNAME="openbureau"
|
HOSTNAME="${HOSTNAME:-openbureau}"
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
TEMPLATE_STORAGE="local"
|
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
|
||||||
ROOTFS_STORAGE="local-lvm"
|
ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}"
|
||||||
DISK_GB="20" # Supabase + CMS
|
DISK_GB="${DISK_GB:-20}" # Supabase + CMS
|
||||||
|
|
||||||
# Ressourcen
|
# Ressourcen
|
||||||
RAM_MB="4096"
|
RAM_MB="${RAM_MB:-4096}"
|
||||||
SWAP_MB="1024"
|
SWAP_MB="${SWAP_MB:-1024}"
|
||||||
CORES="2"
|
CORES="${CORES:-2}"
|
||||||
|
|
||||||
# Netzwerk
|
# Netzwerk
|
||||||
BRIDGE="vmbr0"
|
BRIDGE="${BRIDGE:-vmbr0}"
|
||||||
IP="dhcp" # "dhcp" ODER statisch z.B. "192.168.1.50/24"
|
IP="${IP:-dhcp}" # "dhcp" ODER statisch z.B. "192.168.1.50/24"
|
||||||
GATEWAY="" # nur bei statischer IP
|
GATEWAY="${GATEWAY:-}" # nur bei statischer IP
|
||||||
|
|
||||||
|
# Öffentliche Domain hinter einem Reverse-Proxy (Caddy o.ä.) mit Pfad-Routing
|
||||||
|
# (/auth/* + /rest/* → :8000, Rest → :8080). Leer = LAN-Direktzugriff per IP:Port.
|
||||||
|
SITE_DOMAIN="${SITE_DOMAIN:-}"
|
||||||
|
|
||||||
# Zugang
|
# Zugang
|
||||||
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
|
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
|
||||||
@@ -142,24 +149,59 @@ pct exec "$CTID" -- bash -euo pipefail -c "
|
|||||||
sed -i \"s|^ANON_KEY=.*|ANON_KEY=\${ANON}|\" .env
|
sed -i \"s|^ANON_KEY=.*|ANON_KEY=\${ANON}|\" .env
|
||||||
sed -i \"s|^SERVICE_ROLE_KEY=.*|SERVICE_ROLE_KEY=\${SVC}|\" .env
|
sed -i \"s|^SERVICE_ROLE_KEY=.*|SERVICE_ROLE_KEY=\${SVC}|\" .env
|
||||||
|
|
||||||
# URLs auf die Container-IP setzen
|
# URLs setzen — bei gesetzter SITE_DOMAIN auf die öffentliche HTTPS-Domain
|
||||||
|
# (Browser ruft /auth/* + /rest/* same-origin auf, der Proxy routet sie an
|
||||||
|
# :8000), sonst auf die Container-IP fürs LAN.
|
||||||
HOSTIP=\$(hostname -I | awk '{print \$1}')
|
HOSTIP=\$(hostname -I | awk '{print \$1}')
|
||||||
sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env
|
SITE_DOMAIN='${SITE_DOMAIN}'
|
||||||
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env
|
if [ -n \"\$SITE_DOMAIN\" ]; then
|
||||||
|
SITE_URL=\"https://\$SITE_DOMAIN\"; API_URL=\"https://\$SITE_DOMAIN\"
|
||||||
|
else
|
||||||
|
SITE_URL=\"http://\${HOSTIP}:8080\"; API_URL=\"http://\${HOSTIP}:8000\"
|
||||||
|
fi
|
||||||
|
sed -i \"s|^SITE_URL=.*|SITE_URL=\${SITE_URL}|\" .env
|
||||||
|
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=\${API_URL}|\" .env
|
||||||
sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env
|
sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env
|
||||||
|
# Auf allen Interfaces lauschen, damit Reverse-Proxy bzw. LAN drankommen.
|
||||||
|
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__|\${SITE_URL}|g\" kong.yml
|
||||||
echo 'OK: .env generiert.'
|
echo 'OK: .env generiert.'
|
||||||
fi
|
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
|
if [ '${COMPOSE_UP}' = 'true' ]; then
|
||||||
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
|
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Tägliches DB-Backup (3:15 Uhr) — Dialog-Daten liegen NUR in Postgres.
|
||||||
|
printf 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n15 3 * * * root cd ${APP_DIR}/cms && bash scripts/backup-db.sh >> /var/log/openbureau-backup.log 2>&1\n' > /etc/cron.d/openbureau-backup
|
||||||
|
echo 'OK: tägliches DB-Backup eingerichtet (/etc/cron.d/openbureau-backup).'
|
||||||
"
|
"
|
||||||
|
|
||||||
# --- 5. Abschluss --------------------------------------------------------
|
# --- 5. Abschluss --------------------------------------------------------
|
||||||
IPADDR="$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')"
|
IPADDR="$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
say "Fertig. LXC $CTID läuft${IPADDR:+ unter $IPADDR}."
|
say "Fertig. LXC $CTID läuft${IPADDR:+ unter $IPADDR}."
|
||||||
|
|
||||||
|
if [ -n "$SITE_DOMAIN" ]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Öffentlich: https://${SITE_DOMAIN} (sobald der Reverse-Proxy auf ${IPADDR:-<ip>} zeigt)
|
||||||
|
Caddy-Block: ${SITE_DOMAIN} {
|
||||||
|
# Nur /auth/* muss public ans Gateway (Browser-Login). Daten
|
||||||
|
# laufen über /api/* (Node spricht kong intern an). /rest, /storage,
|
||||||
|
# /realtime NICHT exponieren — unnötige Angriffsfläche.
|
||||||
|
@auth path /auth/*
|
||||||
|
reverse_proxy @auth ${IPADDR:-<ip>}:8000
|
||||||
|
reverse_proxy ${IPADDR:-<ip>}:8080
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
Admin: http://${IPADDR:-<ip>}:8080/admin/
|
Admin: http://${IPADDR:-<ip>}:8080/admin/
|
||||||
|
|||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OPENBUREAU — Backup der Postgres-DB.
|
||||||
|
#
|
||||||
|
# WICHTIG: Foren, Threads und Wortmeldungen (Dialog) leben NUR in Postgres —
|
||||||
|
# anders als content/*.md sind sie NICHT in Git. Ohne Backup sind sie beim
|
||||||
|
# Verlust des Volumes weg. Dieses Skript dumpt die ganze DB komprimiert weg.
|
||||||
|
#
|
||||||
|
# Auf dem Host/LXC im cms/-Verzeichnis ausführen (oder per Cron, siehe README):
|
||||||
|
# bash scripts/backup-db.sh
|
||||||
|
#
|
||||||
|
# Wiederherstellen:
|
||||||
|
# gunzip -c backups/openbureau-<TS>.sql.gz \
|
||||||
|
# | docker compose exec -T db psql -U supabase_admin -d postgres
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Ins cms/-Verzeichnis (eine Ebene über scripts/).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DIR="${BACKUP_DIR:-./backups}"
|
||||||
|
KEEP="${BACKUP_KEEP:-14}" # wie viele Dumps behalten
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
OUT="$DIR/openbureau-$TS.sql.gz"
|
||||||
|
|
||||||
|
docker compose exec -T db pg_dump -U supabase_admin -d postgres | gzip > "$OUT"
|
||||||
|
echo "✓ Backup: $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
|
|
||||||
|
# Rotation: nur die letzten $KEEP Dumps behalten.
|
||||||
|
ls -1t "$DIR"/openbureau-*.sql.gz 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -f
|
||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OPENBUREAU — Update im LXC in einem Rutsch.
|
||||||
|
# Statt `git pull` direkt: holt den Code, rendert die Deploy-Config, setzt die
|
||||||
|
# Dateirechte für den non-root-Container und startet den Stack neu.
|
||||||
|
#
|
||||||
|
# Im Container (als root) ausführen:
|
||||||
|
# bash /opt/openbureau/cms/update.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Repo-Root = eine Ebene über diesem Skript (cms/..).
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Git läuft hier als root auf einem Repo, das dem Container-User (uid 1000)
|
||||||
|
# gehört → ohne das meckert Git über „dubious ownership".
|
||||||
|
git config --global --add safe.directory "$ROOT" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "→ git pull…"
|
||||||
|
# kong.yml wird beim Deploy lokal gerendert (CORS-Origin eingesetzt). Vor dem
|
||||||
|
# Pull auf die versionierte Vorlage (mit __CORS_ORIGIN__) zurücksetzen, sonst
|
||||||
|
# kollidiert der Pull mit der lokalen Änderung.
|
||||||
|
git checkout -- cms/kong.yml 2>/dev/null || true
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
cd "$ROOT/cms"
|
||||||
|
|
||||||
|
# CORS-Origin aus SITE_URL (.env) in kong.yml einsetzen — eine Quelle der Wahrheit.
|
||||||
|
ORIGIN="$(grep -E '^SITE_URL=' .env | head -1 | cut -d= -f2-)"
|
||||||
|
if [ -n "${ORIGIN:-}" ]; then
|
||||||
|
sed -i "s|__CORS_ORIGIN__|${ORIGIN}|g" kong.yml
|
||||||
|
echo "✓ CORS-Origin gesetzt: ${ORIGIN}"
|
||||||
|
else
|
||||||
|
echo "WARN: SITE_URL in .env nicht gefunden — kong.yml-Origin bleibt Platzhalter."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der CMS-Container läuft als uid 1000 und muss das ganze Repo schreiben können
|
||||||
|
# (Hugo baut public/, schreibt content/). git pull als root zieht neue Dateien
|
||||||
|
# als root → hier wieder geradeziehen.
|
||||||
|
chown -R 1000:1000 "$ROOT"
|
||||||
|
echo "✓ Dateirechte (uid 1000) gesetzt."
|
||||||
|
|
||||||
|
echo "→ docker compose up…"
|
||||||
|
docker compose up -d --build
|
||||||
|
# kong liest die declarative config nur beim Start — nach kong.yml-Änderung neu.
|
||||||
|
docker compose restart kong
|
||||||
|
echo "✓ Stack neu gestartet."
|
||||||
|
|
||||||
|
# Kurzer Healthcheck (localhost im LXC, unabhängig von BIND_ADDR).
|
||||||
|
PORT="$(grep -E '^APP_PORT=' .env | head -1 | cut -d= -f2-)"; PORT="${PORT:-8080}"
|
||||||
|
sleep 2
|
||||||
|
if curl -fsS -I "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
|
||||||
|
echo "✓ Seite antwortet auf :${PORT}."
|
||||||
|
else
|
||||||
|
echo "WARN: Seite antwortet (noch) nicht auf :${PORT} — 'docker compose ps' prüfen."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
title: "Archiv"
|
||||||
|
description: "Fertige Texte des Büros, nach Thema geordnet."
|
||||||
|
---
|
||||||
|
|
||||||
|
Fertige Texte, nach Thema geordnet. Das **Journal** auf der Startseite zeigt dieselben Inhalte chronologisch.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
title: "Im Offenen arbeiten"
|
||||||
|
date: 2026-05-30
|
||||||
|
tags: ["büroführung", "open-source", "muster"]
|
||||||
|
summary: "Warum ein offenes Büro robuster ist — und wie Lizenzen das absichern. (Musterbeitrag mit Fußnoten.)"
|
||||||
|
color: sakura
|
||||||
|
layout: text
|
||||||
|
---
|
||||||
|
|
||||||
|
Offen zu arbeiten heißt nicht, alles zu verschenken. Es heißt, die Grundlagen so zu teilen, dass andere darauf aufbauen können — und dass die Arbeit den Wechsel von Werkzeugen, Mitarbeitenden und Jahren übersteht.
|
||||||
|
|
||||||
|
Die rechtliche Absicherung dafür sind Lizenzen. Inhalte auf OPENBUREAU stehen unter CC BY-SA,[^ccbysa] der Code überwiegend unter AGPL oder MIT.[^agpl] Beide sorgen dafür, dass Offenheit weitergegeben wird, statt verloren zu gehen.
|
||||||
|
|
||||||
|
Verwandt: Der [Werkzeug-Stack](/archiv/software/stack/) zeigt, womit wir das konkret tun.
|
||||||
|
|
||||||
|
[^ccbysa]: Creative Commons, *Attribution-ShareAlike 4.0 International* (CC BY-SA 4.0), <https://creativecommons.org/licenses/by-sa/4.0/>.
|
||||||
|
[^agpl]: Free Software Foundation, *GNU Affero General Public License v3.0*, 2007.
|
||||||
+1
-1
@@ -14,4 +14,4 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
|
|||||||
|
|
||||||
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
|
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
|
||||||
|
|
||||||
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet — [Werkzeuge](/library/werkzeuge).
|
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet — [Werkzeuge](/archiv/werkzeuge).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
title: "Die Werkzeugkette"
|
||||||
|
date: 2026-06-01
|
||||||
|
tags: ["software", "werkzeuge", "muster"]
|
||||||
|
summary: "Wie DOSSIER, RAPPORT und die Site zusammenspielen. (Musterbeitrag mit Fußnoten und Code.)"
|
||||||
|
color: yuyake
|
||||||
|
layout: text
|
||||||
|
---
|
||||||
|
|
||||||
|
Die Werkzeuge des Büros sind keine Inseln, sondern eine Kette: [DOSSIER](/archiv/software/dossier/) hält die Projektdaten, [RAPPORT](/archiv/software/rapport/) erzeugt Berichte daraus, und diese Site veröffentlicht, was öffentlich sein soll.
|
||||||
|
|
||||||
|
Der Build ist bewusst banal — ein Befehl:[^hugo]
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hugo --minify --destination public
|
||||||
|
```
|
||||||
|
|
||||||
|
Alles dateibasiert, alles versioniert. Wer den Stand von gestern braucht, fragt Git, nicht ein Backup.[^git]
|
||||||
|
|
||||||
|
[^hugo]: Hugo, *The world's fastest framework for building websites*, <https://gohugo.io>.
|
||||||
|
[^git]: Versionierung ersetzt kein Backup — aber sie macht jede Änderung nachvollziehbar; siehe „Verlauf" unter jedem Beitrag.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
title: "Proxmox, Schritt für Schritt"
|
||||||
|
date: 2026-06-02
|
||||||
|
tags: ["software", "proxmox", "self-hosting", "anleitung", "lxc"]
|
||||||
|
summary: "Wie aus einer gebrauchten Kiste die Infrastruktur eines Architekturbüros wird — mit den Skripten, die einen Dienst in Minuten aufstellen."
|
||||||
|
color: kusa
|
||||||
|
layout: text
|
||||||
|
---
|
||||||
|
|
||||||
|
Die Kiste aus dem [ersten Teil](/archiv/software/server-im-eigenen-haus/) muss man nicht streicheln können, um sie zu verstehen. Es genügt ein Bild: Proxmox macht aus einem Rechner ein Mehrfamilienhaus. Das Haus ist die Maschine, die Wohnungen sind die Container, und in jeder Wohnung lebt genau ein Dienst — die Website, die Zeiterfassung, der Dateispeicher. Niemand stört den anderen, jeder hat seine eigene Tür, und zieht eine Partei aus, bleiben die übrigen, wo sie sind.
|
||||||
|
|
||||||
|
Dieser Text zeigt, wie man das Haus baut und die erste Wohnung bezieht. Er setzt keine Erfahrung mit Servern voraus, nur die Bereitschaft, einen Befehl abzutippen und zu lesen, was er antwortet.
|
||||||
|
|
||||||
|
## Das Fundament
|
||||||
|
|
||||||
|
Proxmox VE ist im Kern ein Debian-Linux mit einer Weboberfläche und der Fähigkeit, zweierlei Sorten Wohnungen zu vermieten: vollwertige virtuelle Maschinen und — das ist unser Fall — Linux-Container, sogenannte LXC. Ein Container teilt sich den Kern des Wirts und ist deshalb sparsam: Vier Gigabyte Arbeitsspeicher reichen für einen ausgewachsenen Dienst, ein Dutzend davon laufen auf gewöhnlicher Bürohardware.
|
||||||
|
|
||||||
|
Installiert wird Proxmox einmalig vom USB-Stick, so wie man ein Betriebssystem installiert. Das ist gut dokumentiert und hier nicht das Thema. Ab dem Moment, in dem die Weboberfläche unter `https://<ip>:8006` erscheint, beginnt der interessante Teil.
|
||||||
|
|
||||||
|
## Das Muster: ein Container, ein Dienst, ein Befehl
|
||||||
|
|
||||||
|
Wir richten keinen Dienst von Hand ein. Jeder Handgriff, den man zweimal macht, gehört in ein Skript — schon weil man ihn sonst beim Wiederaufsetzen vergisst. Unser Muster, von Dienst zu Dienst gleich, lautet:
|
||||||
|
|
||||||
|
1. einen **unprivilegierten** LXC anlegen (er darf weniger, also kann weniger schiefgehen),
|
||||||
|
2. ihn so einstellen, dass **Docker** darin läuft (`nesting` und `keyctl`),
|
||||||
|
3. den Dienst als **Docker-Compose-Stack** hineinstellen,
|
||||||
|
4. alle **Geheimnisse automatisch erzeugen** lassen, nichts von Hand eintippen,
|
||||||
|
5. ein **Backup** einrichten, bevor überhaupt Daten da sind.
|
||||||
|
|
||||||
|
Das ist die ganze Liturgie. Wer sie einmal in ein Skript gegossen hat, stellt den nächsten Dienst hin, indem er das Skript ruft.
|
||||||
|
|
||||||
|
## Die erste Wohnung: unser CMS
|
||||||
|
|
||||||
|
Diese Website ist das Musterbeispiel. Ein einziger Befehl, abgesetzt auf dem Proxmox-Wirt als `root`, baut den ganzen Container — Docker, das Repository, sämtliche Schlüssel, der laufende Stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/cms/proxmox/create-openbureau-lxc.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript fragt nur nach Speicherort, Netzwerkbrücke und IP — Enter übernimmt je den Vorschlag — und ist nach wenigen Minuten fertig. Am Ende nennt es die Adressen: den Editor unter `…:8080/admin/`, die Website unter `…:8080/`.
|
||||||
|
|
||||||
|
Spannend ist nicht der Einzeiler, sondern was er tut. Das [vollständige Skript](https://git.kgva.ch/karim/OPENBUREAU/src/branch/main/cms/proxmox/create-openbureau-lxc.sh) liest sich von oben nach unten wie ein Protokoll. Den Container anlegen, mit den zwei Schaltern, die Docker erlauben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct create "$CTID" "$TEMPLATE_REF" \
|
||||||
|
--hostname openbureau \
|
||||||
|
--cores 2 --memory 4096 --swap 1024 \
|
||||||
|
--rootfs "local-lvm:20" \
|
||||||
|
--net0 "name=eth0,bridge=vmbr0,ip=dhcp" \
|
||||||
|
--unprivileged 1 \
|
||||||
|
--features "nesting=1,keyctl=1" \
|
||||||
|
--onboot 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann, im Container, Docker installieren, das Repository ziehen und — der Teil, der einem die durchwachte Nacht erspart — die Geheimnisse erzeugen, statt sie von Hand zu setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
systemctl enable --now docker
|
||||||
|
git clone https://git.kgva.ch/karim/OPENBUREAU.git /opt/openbureau
|
||||||
|
|
||||||
|
cd /opt/openbureau/cms
|
||||||
|
cp .env.example .env
|
||||||
|
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 32)|" .env
|
||||||
|
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$(openssl rand -hex 32)|" .env
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Und schliesslich, noch bevor der erste Beitrag geschrieben ist, das tägliche Backup — denn das Forum lebt allein in der Datenbank, nicht im Git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '15 3 * * * root cd /opt/openbureau/cms && bash scripts/backup-db.sh\n' \
|
||||||
|
> /etc/cron.d/openbureau-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein Schritt davon ist klug; jeder ist nur aufgeschrieben. Das ist der ganze Trick.
|
||||||
|
|
||||||
|
## Ein Menü statt Handarbeit
|
||||||
|
|
||||||
|
Weil das Muster sich von Dienst zu Dienst wiederholt, haben wir es ein einziges Mal in ein Installationsskript gegossen. Es ruft sich genauso wie das CMS-Skript — ein Einzeiler auf dem Proxmox-Wirt, als `root` —, nur legt es kein bestimmtes Programm fest, sondern fragt, was man haben will:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Zuerst fragt es nicht nach Technik, sondern nach dem Vorhaben: ein ganzes Büro einrichten, bloss Office 365 und die Synology ersetzen, nur die öffentliche Website — oder, für jene, die genau wissen, was sie wollen, einzeln auswählen. Aus der Antwort leitet das Skript ab, welche Container es braucht, und baut sie der Reihe nach. Jeder bekommt seine eigene Wohnung; für jede erledigt das Skript dasselbe, was oben Schritt für Schritt stand: Template holen, unprivilegierten Container anlegen, Docker hineinlegen, den Dienst starten.
|
||||||
|
|
||||||
|
Wer das Menü überspringen will, hängt den gewünschten Dienst direkt an:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
… install.sh nextcloud # nur Nextcloud
|
||||||
|
… install.sh empty dateien 200 8192 # leerer Docker-LXC, 200 GB / 8 GB RAM
|
||||||
|
… install.sh git git.kgva.ch/karim/RAPPORT-SERVER.git rapport
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinter dem Menü steckt keine grosse Maschine, sondern ein Bündel kleiner, eigenständiger Skripte — eines pro Dienst. Die Suite ist nur der Dialog, der sie der Reihe nach aufruft. Wer das Menü gar nicht braucht, lädt das einzelne Skript direkt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL …/proxmox/nextcloud-lxc.sh) # 500 GB / 8 GB RAM
|
||||||
|
bash <(curl -fsSL …/proxmox/empty-lxc.sh) dateien 200 8192
|
||||||
|
bash <(curl -fsSL …/proxmox/git-compose-lxc.sh) git.kgva.ch/karim/RAPPORT-SERVER.git rapport
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes dieser Skripte ist in sich geschlossen und tut, was oben Schritt für Schritt stand: Template holen, unprivilegierten Container anlegen, Docker hineinlegen, den Dienst starten. Genau diese Wiederholbarkeit ist der Sinn der Übung — ein Dienst, den man nicht mit einem Befehl neu aufsetzen kann, ist ein Dienst, vor dem man sich beim nächsten Mal fürchtet.
|
||||||
|
|
||||||
|
## Office 365 und die Synology ersetzen: Nextcloud
|
||||||
|
|
||||||
|
Der grösste Brocken verdient einen eigenen Blick, weil er am meisten ersetzt. [Nextcloud](https://nextcloud.com) übernimmt in einem Aufwasch, wofür sonst zwei Abos und eine NAS herhalten: die Dateiablage mit Synchronisation auf alle Geräte — das OneDrive- und Synology-Drive-Erbe —, gemeinsame Kalender und Kontakte, dazu über das eingebaute Office das Bearbeiten von Dokumenten und Tabellen im Browser, zu zweit am selben Text.
|
||||||
|
|
||||||
|
Im Menü ist es ein Haken, von Hand der Befehl oben. Was dann läuft, ist die offizielle All-in-One-Variante: ein verwalteter Container, der die übrigen selbst aufsetzt. Den Rest erledigt die Weboberfläche unter Port 8080 — sie vergibt das Admin-Passwort, fragt die Domain ab und startet die eigentlichen Dienste. Ohne eigene Domain erreicht man das Ganze vorerst im lokalen Netz; für den Zugriff von aussen kommt später ein Reverse-Proxy davor, dasselbe Prinzip, das auch unser CMS hinter TLS bringt.
|
||||||
|
|
||||||
|
Damit ist die Rechnung geschlossen: Mail, Kalender, Kontakte, Dateien, gemeinsame Dokumente — alles, wofür das Büro bisher Monat für Monat pro Kopf bezahlt hat, läuft im Schrank. Und unsere eigenen Werkzeuge, RAPPORT und DOSSIER, ziehen über denselben Git-Eintrag im Menü nach, weil sie demselben Muster folgen wie alles andere.
|
||||||
|
|
||||||
|
## Das Backup ist kein Anhang
|
||||||
|
|
||||||
|
Ein Satz zum Schluss, der eigentlich an den Anfang gehört. Selbst zu hosten heisst, selbst für die Sicherung geradezustehen. Zweierlei greift bei uns ineinander. Innerhalb jedes Dienstes sichert ein nächtlicher cron-Lauf die Datenbank weg — bei dieser Website das Forum, bei Nextcloud die Metadaten. Und für die Container als Ganzes nimmt der **Proxmox Backup Server** allabendlich einen Schnappschuss, aus dem sich eine ganze Wohnung in Minuten wiederherstellen lässt, sollte sie einmal abbrennen.
|
||||||
|
|
||||||
|
Ein Backup, das man nie zurückgespielt hat, ist eine Hoffnung, kein Backup. Darum gehört der erste Wiederherstellungs-Versuch an den Tag, an dem der Dienst aufgesetzt wird — nicht an den Tag, an dem man ihn braucht.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: "Server im eigenen Haus"
|
||||||
|
date: 2026-06-02
|
||||||
|
tags: ["software", "proxmox", "self-hosting", "infrastruktur"]
|
||||||
|
summary: "Warum unsere Dienste auf einer eigenen Kiste laufen statt bei Microsoft, Google oder Synology — und was das mit Architektur zu tun hat."
|
||||||
|
color: yuyake
|
||||||
|
layout: text
|
||||||
|
---
|
||||||
|
|
||||||
|
Im Schrank neben dem Plotter steht jetzt eine Kiste. Kein schönes Gerät, ein ausgemusterter Bürorechner mit zu vielen Lüftern, der leise vor sich hin rauscht. Auf ihm liegt, was sonst über ein halbes Dutzend Abonnements verteilt wäre: die Korrespondenz, die Pläne, die Zeiterfassung, diese Website. Das Büro hat seine Daten nach Hause geholt.
|
||||||
|
|
||||||
|
Lange war das anders, und lange fiel es nicht auf. Ein Architekturbüro produziert Daten, bevor es das erste Gebäude produziert — Wettbewerbsbeiträge, Pläne in dreissig Revisionen, Honorarabrechnungen, die Korrespondenz mit Bauherrschaft und Amt. Dieser Bestand wächst still, und ebenso still ist er in die Cloud gewandert. Microsoft 365 für Mail und Dokumente, OneDrive oder die Synology im Keller für die Dateien, ein gemietetes CRM für die Adressen. Jedes Stück für sich vernünftig, zusammen ein Büro, dessen Substanz auf fremden Servern liegt, zu Bedingungen, die ein anderer schreibt.
|
||||||
|
|
||||||
|
Das funktioniert tadellos. Es ist bequem. Und es heisst, dass das Gedächtnis des Büros zur Miete wohnt.
|
||||||
|
|
||||||
|
## Die Praxis besitzt ihre Werkzeuge
|
||||||
|
|
||||||
|
Dass wir das umdrehen, ist keine Prinzipienreiterei, sondern eine Konsequenz aus dem [Manifest](/manifest/): Ein Büro offen zu führen heisst auch, die eigenen Werkzeuge zu besitzen — so wie ein Schreiner seine Hobel besitzt und nicht pro Span bezahlt. Wer die Werkzeuge mietet, mietet am Ende die eigene Arbeitsweise.
|
||||||
|
|
||||||
|
Der Hobel ist in diesem Fall die Kiste im Schrank. Darauf läuft Proxmox, eine quelloffene Software, die aus einem gewöhnlichen Rechner viele kleine, sauber getrennte Maschinen macht. In jeder steckt ein Dienst: diese Website samt dem Editor, mit dem dieser Text geschrieben wurde; RAPPORT, unsere Zeiterfassung; DOSSIER, die Projektablage; der Dateispeicher, der die Synology ablöst; Kalender, Kontakte, Mail. Alles offen, alles auf den eigenen Platten. Was vorher Monat für Monat pro Kopf abgebucht wurde, deckt die gebrauchte Hardware in unter einem Jahr.
|
||||||
|
|
||||||
|
## Was das mit Architektur zu tun hat
|
||||||
|
|
||||||
|
Mehr, als es zunächst scheint. Wo die Daten einer Bauherrschaft liegen, ist keine Geschmacksfrage, sondern eine des Anstands und des Datenschutzes. Eine Maschine im eigenen Haus beantwortet die Frage, wo die anvertrauten Unterlagen sind, mit einem Fingerzeig auf den Schrank — nicht mit einem Verweis auf Rechenzentren in einer anderen Rechtsordnung.
|
||||||
|
|
||||||
|
Dazu kommt die schlichte Unkündbarkeit. Verdoppelt ein Anbieter den Preis, streicht eine Funktion oder stellt das Produkt ein, ist das sein gutes Recht; man steht daneben und zahlt. Bei uns gibt es nichts, das gekündigt werden kann. Die Formate sind offen — die Texte dieser Bibliothek etwa sind schlichte Textdateien, lesbar auch dann, wenn unser ganzer Apparat einmal verschwindet.
|
||||||
|
|
||||||
|
Und schliesslich behandelt ein offenes Büro seine Infrastruktur wie einen Entwurf: Man versteht sie, ändert sie, dokumentiert sie. Dieser Aufbau ist deshalb kein Betriebsgeheimnis, sondern steht [unter freier Lizenz](/lizenz/) offen. Wer sein Büro ähnlich einrichten will, kopiert unsere Skripte und macht weiter.
|
||||||
|
|
||||||
|
## Der Preis der Selbstverständlichkeit
|
||||||
|
|
||||||
|
Bleibt die unbequeme Seite, und sie gehört in jeden ehrlichen Text dieser Art: Man wird sein eigener Hauswart. Backups laufen nicht mehr von allein, Aktualisierungen muss jemand einspielen, und fällt der Strom, klingelt kein Support.
|
||||||
|
|
||||||
|
Tragbar finden wir das aus zwei Gründen. Der Aufwand ist kleiner, als er klingt, sobald die Handgriffe automatisiert sind — einen neuen Dienst aufzusetzen ist bei uns ein einziger Befehl und kein verlorener Nachmittag. Und die Kontrolle ist den Rest wert: Lieber für ein Backup geradestehen, das man versteht, als sich auf eines verlassen, das man nie gesehen hat.
|
||||||
|
|
||||||
|
Wie die Kiste im Schrank konkret eingerichtet ist — die Maschine, die Container, die Befehle, mit denen ein Dienst in Minuten steht — steht im zweiten Teil: [Proxmox, Schritt für Schritt](/archiv/software/proxmox-schritt-fuer-schritt/).
|
||||||
@@ -28,7 +28,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
|
|||||||
|
|
||||||
## Lorem Ipsum IV
|
## Lorem Ipsum IV
|
||||||
|
|
||||||
- **[DOSSIER](/library/software/dossier/)** — lorem ipsum.
|
- **[DOSSIER](/archiv/software/dossier/)** — lorem ipsum.
|
||||||
- **[RAPPORT](/library/software/rapport/)** — dolor sit amet.
|
- **[RAPPORT](/archiv/software/rapport/)** — dolor sit amet.
|
||||||
|
|
||||||
— sed ut perspiciatis unde omnis iste natus error sit voluptatem.
|
— sed ut perspiciatis unde omnis iste natus error sit voluptatem.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
title: "Typus und Modell"
|
||||||
|
date: 2026-05-28
|
||||||
|
tags: ["theorie", "typologie", "muster"]
|
||||||
|
summary: "Eine kurze Unterscheidung — und warum sie fürs Entwerfen praktisch ist. (Musterbeitrag mit Fußnoten.)"
|
||||||
|
color: kusa
|
||||||
|
layout: text
|
||||||
|
---
|
||||||
|
|
||||||
|
Quatremère de Quincy trennte im frühen 19. Jahrhundert *Typus* und *Modell*: Das Modell ist die exakte Vorlage zum Kopieren, der Typus dagegen ein Prinzip, das viele verschiedene Werke begründen kann.[^quatremere]
|
||||||
|
|
||||||
|
Rafael Moneo griff diese Idee 1978 wieder auf und machte sie für die Praxis brauchbar — der Typus ist kein Käfig, sondern ein Ausgangspunkt, gegen den man entwirft.[^moneo] Aldo Rossi schließlich verband den Typus mit der Stadt: als dauerhaftes Element, das den Wandel überdauert.[^rossi]
|
||||||
|
|
||||||
|
Fürs Büro heißt das konkret: Wer den Typus einer Aufgabe versteht, entwirft nicht aus dem Nichts, sondern variiert bewusst. Das spart Zeit und macht Entscheidungen begründbar.
|
||||||
|
|
||||||
|
[^quatremere]: Antoine-Chrysostome Quatremère de Quincy, *Encyclopédie méthodique. Architecture*, Bd. 3, Paris 1825, Stichwort „Type".
|
||||||
|
[^moneo]: Rafael Moneo, „On Typology", in: *Oppositions* 13 (1978), S. 22–45.
|
||||||
|
[^rossi]: Aldo Rossi, *L'architettura della città*, Padua 1966.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: "Impressum"
|
||||||
|
toc: false
|
||||||
|
showreadingtime: false
|
||||||
|
aliases:
|
||||||
|
- /datenschutz/
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kontakt
|
||||||
|
|
||||||
|
[karim@gabrielevarano.ch](mailto:karim@gabrielevarano.ch)
|
||||||
|
|
||||||
|
Oder direkt im [Dialog](/dialog/).
|
||||||
|
|
||||||
|
## Verantwortlich
|
||||||
|
|
||||||
|
Karim Varano\
|
||||||
|
Fluhmühlerain 1\
|
||||||
|
6015 Luzern
|
||||||
|
|
||||||
|
Privatperson
|
||||||
|
|
||||||
|
## Datenschutz
|
||||||
|
|
||||||
|
OPENBUREAU ist selbst-gehostet — ohne Werbung, ohne Tracker, ohne Analyse-Dienste Dritter. So wenig Daten wie möglich zu erheben, ist Teil der Idee.
|
||||||
|
|
||||||
|
### Beim Besuch der Seite
|
||||||
|
|
||||||
|
Beim Aufruf werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browsertyp) kurzzeitig in Server-Logs erfasst — ausschließlich für Betrieb und Sicherheit. Diese Daten werden nicht mit anderen Quellen zusammengeführt, nicht für Werbung verwendet und nicht an Dritte weitergegeben.
|
||||||
|
|
||||||
|
Es kommen **keine** Tracking-Cookies, **keine** Analyse-Werkzeuge (etwa Google Analytics) und **keine** Werbenetzwerke zum Einsatz.
|
||||||
|
|
||||||
|
### Dialog (Mitschreiben)
|
||||||
|
|
||||||
|
Lesen ist anonym. Wer im [Dialog](/dialog/) mitschreibt, legt ein Konto an: dafür werden eine E-Mail-Adresse und ein angezeigter Name gespeichert. Deine Wortmeldungen erscheinen mit diesem Namen öffentlich. Nach dem Login wird ein Sitzungs-Token lokal in deinem Browser (localStorage) abgelegt — kein serverseitiges Tracking.
|
||||||
|
|
||||||
|
Diese Daten liegen auf eigener Infrastruktur in Luzern (self-hosted) und werden nicht an Dritte weitergegeben.
|
||||||
|
|
||||||
|
### Deine Rechte
|
||||||
|
|
||||||
|
Nach dem Schweizer Datenschutzgesetz (revDSG) hast du das Recht auf Auskunft, Berichtigung und Löschung deiner Daten — eine kurze Mail genügt. Beschwerden kannst du beim Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten (EDÖB) einreichen.
|
||||||
|
|
||||||
|
### Offenheit
|
||||||
|
|
||||||
|
Die Seite ist quelloffen; der Code ist einsehbar. Zu Lizenzen und Technik siehe [Colophon](/colophon/).
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Library"
|
title: "Library"
|
||||||
description: "Die Bibliothek von OPENBUREAU — Texte, Notizen, Recherchen."
|
summary: "Notizen zu Abläufen, Begriffen und Werkzeugen des Büros."
|
||||||
---
|
---
|
||||||
|
|
||||||
Die Library ist die Sammlung. Alles, was gelesen und geschrieben wird, lebt hier.
|
Notizen zu Abläufen, Begriffen und Werkzeugen aus dem Büroalltag. Fertige Texte stehen im [Archiv](/archiv/). Ergänzungen im CMS oder direkt im Repository.
|
||||||
Texte werden thematisch organisiert; das **Journal** auf der Startseite zeigt dieselben Inhalte chronologisch.
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
title: "Dateiablage & Benennung"
|
||||||
|
group: "Konventionen"
|
||||||
|
summary: "Wie Projektdateien heissen, damit man sie in fünf Jahren noch findet."
|
||||||
|
toc: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Eine Konvention ist nur dann eine, wenn sich alle daran halten. Dies ist ein Vorschlag, kein Gesetz — verbessern erwünscht.
|
||||||
|
|
||||||
|
## Projektordner
|
||||||
|
|
||||||
|
Jedes Projekt liegt unter `Projekte/JJJJ_Nummer_Kurzname/`, z. B. `2026_014_Mehrfamilienhaus-Seeblick/`. Das Jahr vorne sortiert chronologisch, die Nummer ist eindeutig, der Kurzname macht es lesbar.
|
||||||
|
|
||||||
|
## Dateinamen
|
||||||
|
|
||||||
|
`JJMMTT_Projekt_Inhalt_vNN` — Datum zuerst (sortiert sich selbst), dann was es ist, dann die Version:
|
||||||
|
|
||||||
|
- `260604_Seeblick_Grundriss-EG_v03.pdf`
|
||||||
|
- `260604_Seeblick_Kostenschaetzung_v01.xlsx`
|
||||||
|
|
||||||
|
Keine Umlaute, keine Leerzeichen, keine Sonderzeichen — Bindestrich trennt Wörter, Unterstrich trennt Felder.
|
||||||
|
|
||||||
|
## Versionen
|
||||||
|
|
||||||
|
`vNN` zählt hoch, nichts wird überschrieben. Die jeweils gültige Fassung bekommt keinen Sonderstatus im Namen — das erledigt das Datum. Wer mit Git arbeitet, lässt die Versionsnummer weg und vertraut der Historie.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
title: "Typus"
|
||||||
|
group: "Begriffe"
|
||||||
|
summary: "Ein Prinzip, das viele Werke begründet — nicht die Vorlage zum Kopieren."
|
||||||
|
---
|
||||||
|
|
||||||
|
Der **Typus** ist nicht das fertige Vorbild, sondern das zugrunde liegende Prinzip einer Bauaufgabe: das, was eine Markthalle zur Markthalle macht, unabhängig von Ort, Material und Epoche. Vom *Modell* unterscheidet er sich darin, dass man ihn nicht kopiert, sondern gegen ihn entwirft.[^quatremere]
|
||||||
|
|
||||||
|
Fürs Büro ist der Typus ein Werkzeug der Ökonomie: Wer den Typus einer Aufgabe kennt, beginnt nicht bei null, sondern variiert bewusst — und kann die eigenen Entscheidungen begründen.[^moneo]
|
||||||
|
|
||||||
|
Ausführlicher im Archiv: [Typus und Modell](/archiv/theorie/muster-typologie-fussnoten/).
|
||||||
|
|
||||||
|
[^quatremere]: Antoine-Chrysostome Quatremère de Quincy, *Encyclopédie méthodique. Architecture*, Bd. 3, Paris 1825, Stichwort „Type".
|
||||||
|
[^moneo]: Rafael Moneo, „On Typology", in: *Oppositions* 13 (1978), S. 22–45.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
title: "Wie die Library funktioniert"
|
||||||
|
group: "Werkstatt"
|
||||||
|
summary: "Kleine Seiten, klare Titel, viele Verweise."
|
||||||
|
toc: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Die Library ist kein Lexikon, das jemand fertigstellt, sondern ein gemeinsames Gedächtnis, das beim Arbeiten entsteht. Ein paar Konventionen halten sie übersichtlich.
|
||||||
|
|
||||||
|
## Eine Seite, ein Begriff
|
||||||
|
|
||||||
|
Lieber viele kleine Seiten als wenige grosse. Eine Seite behandelt einen Begriff, einen Handgriff, eine Entscheidung. Passt etwas nicht mehr auf eine Bildschirmseite, wird es meist zwei Themen sein.
|
||||||
|
|
||||||
|
## Verweise
|
||||||
|
|
||||||
|
Seiten verweisen mit gewöhnlichen Markdown-Links aufeinander — `[Typus](/library/typus/)` — und gerne auch ins [Archiv](/archiv/), wenn ein Gedanke dort ausführlicher steht. Verlinken ist die eigentliche Arbeit: Eine Notiz, auf die nichts zeigt, findet niemand.
|
||||||
|
|
||||||
|
## Gruppen
|
||||||
|
|
||||||
|
Das Feld `group` im Frontmatter sortiert eine Seite in einen Bereich der Übersicht — z. B. `group: "Begriffe"`. Seiten ohne Gruppe landen unter „Allgemein". Mehr Struktur braucht es selten.
|
||||||
|
|
||||||
|
## Bearbeiten
|
||||||
|
|
||||||
|
Jede Seite hat unten einen **bearbeiten**-Link, der direkt ins Repository führt. Wer lieber im Redaktions-Editor arbeitet, legt eine Seite vom Typ *Library* an und füllt Titel, Gruppe und Text.
|
||||||
@@ -69,17 +69,17 @@ menus:
|
|||||||
- name: LIBRARY
|
- name: LIBRARY
|
||||||
pageRef: /library
|
pageRef: /library
|
||||||
weight: 20
|
weight: 20
|
||||||
- name: MANIFEST
|
- name: ARCHIV
|
||||||
pageRef: /manifest
|
pageRef: /archiv
|
||||||
weight: 30
|
weight: 25
|
||||||
- name: DIALOG
|
- name: DIALOG
|
||||||
pageRef: /dialog
|
pageRef: /dialog
|
||||||
weight: 40
|
weight: 40
|
||||||
- name: CODE
|
# MANIFEST + CODE stehen im Footer (schlankeres Hauptmenü).
|
||||||
url: https://git.openbureau.ch
|
|
||||||
weight: 50
|
|
||||||
|
|
||||||
params:
|
params:
|
||||||
|
# Öffentliche Gitea-Repo-URL (für Provenance: Version → Commit, Verlauf).
|
||||||
|
repoURL: "https://git.openbureau.ch/karim/OPENBUREAU"
|
||||||
author:
|
author:
|
||||||
name: "Karim Gabriele Varano"
|
name: "Karim Gabriele Varano"
|
||||||
email: "karim@gabrielevarano.ch"
|
email: "karim@gabrielevarano.ch"
|
||||||
|
|||||||
@@ -14,12 +14,14 @@
|
|||||||
{{ partial "menu.html" (dict "menuID" "main" "page" .) }}
|
{{ partial "menu.html" (dict "menuID" "main" "page" .) }}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" role="main">{{ block "main" . }}{{ end }}</main>
|
<main id="main-content" role="main">
|
||||||
|
{{ block "main" . }}{{ end }}
|
||||||
{{ if not .IsHome }}
|
{{ if not .IsHome }}
|
||||||
<nav class="page-foot-nav" aria-label="Breadcrumb">
|
<nav class="page-foot-nav" aria-label="Breadcrumb">
|
||||||
{{ partial "header.html" . }}
|
{{ partial "header.html" . }}
|
||||||
</nav>
|
</nav>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
</main>
|
||||||
<footer role="contentinfo">{{ partial "footer.html" . }}</footer>
|
<footer role="contentinfo">{{ partial "footer.html" . }}</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
{{ with .Params.summary }}
|
{{ with .Params.summary }}
|
||||||
<p class="single-summary">{{ . }}</p>
|
<p class="single-summary">{{ . }}</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{/* Byline + Meta nur bei Library-Beiträgen — Seiten wie Manifest,
|
||||||
|
Kontakt, Spenden brauchen weder Autor noch „Aktualisiert am". */}}
|
||||||
|
{{ if eq .Section "archiv" }}
|
||||||
{{ $author := .Params.author | default site.Params.author.name }}
|
{{ $author := .Params.author | default site.Params.author.name }}
|
||||||
{{ $aslug := urlize $author }}
|
{{ $aslug := urlize $author }}
|
||||||
{{ if not .Params.author }}{{ with index site.Data.authors site.Params.author.email }}{{ with .slug }}{{ $aslug = . }}{{ end }}{{ end }}{{ end }}
|
{{ if not .Params.author }}{{ with index site.Data.authors site.Params.author.email }}{{ with .slug }}{{ $aslug = . }}{{ end }}{{ end }}{{ end }}
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
{{ if $hasLastmod }}{{ if and $showReadingTime .ReadingTime }} · {{ end }}<span class="lastmod">Aktualisiert am {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
|
{{ if $hasLastmod }}{{ if and $showReadingTime .ReadingTime }} · {{ end }}<span class="lastmod">Aktualisiert am {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
|
||||||
</p>
|
</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{/* Table of Contents */}}
|
{{/* Table of Contents */}}
|
||||||
@@ -56,25 +60,20 @@
|
|||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{/* Tags as small pills at the bottom — Republik-style, no hash symbol */}}
|
{{/* Tags: bei Seiten (nicht-Library) wie bisher unter dem Text. Bei Library
|
||||||
|
wandern sie in die Aktionsreihe (rechts neben Dialog) im Partial. */}}
|
||||||
|
{{ if ne .Section "archiv" }}
|
||||||
{{- with .Params.tags }}
|
{{- with .Params.tags }}
|
||||||
<ul class="tag-pills" aria-label="Tags">
|
<ul class="tag-pills" aria-label="Tags">
|
||||||
{{- range . -}}<li><a href="/tags/{{ . | urlize }}/">{{ . }}</a></li>{{- end -}}
|
{{- range . -}}<li><a href="/tags/{{ . | urlize }}/">{{ . }}</a></li>{{- end -}}
|
||||||
</ul>
|
</ul>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{/* Dialog nur bei Artikeln (Library), nicht auf Seiten wie Spenden/Manifest. */}}
|
{{/* Artikel-Fuß (zitieren, Dialog, Tags, Versionen) — nur bei Library. */}}
|
||||||
{{ if eq .Section "library" }}
|
{{ if eq .Section "archiv" }}
|
||||||
<a class="dialog-link" id="dialog-link" data-thread="{{ .RelPermalink }}" href="/dialog/?thread={{ .RelPermalink }}">→ Dialog</a>
|
{{ partial "provenance.html" . }}
|
||||||
<script>
|
<script src="/version-history.js"></script>
|
||||||
(function () {
|
|
||||||
var l = document.getElementById('dialog-link'); if (!l) return;
|
|
||||||
fetch('/api/comments?thread=' + encodeURIComponent(l.dataset.thread))
|
|
||||||
.then(function (r) { return r.ok ? r.json() : []; })
|
|
||||||
.then(function (d) { var n = d.filter(function (c) { return !c.deleted; }).length; if (n) l.textContent = '→ Dialog · ' + n; })
|
|
||||||
.catch(function () {});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="footer-links" aria-label="Footer">
|
<nav class="footer-links" aria-label="Footer">
|
||||||
|
<a href="/manifest/">Manifest</a>
|
||||||
<a href="/colophon/">Colophon</a>
|
<a href="/colophon/">Colophon</a>
|
||||||
<a href="mailto:karim@gabrielevarano.ch">Kontakt</a>
|
<a href="/impressum/">Impressum</a>
|
||||||
|
<a href="https://git.openbureau.ch">Code</a>
|
||||||
<a href="/index.xml">RSS</a>
|
<a href="/index.xml">RSS</a>
|
||||||
<a href="/spenden/">Spenden</a>
|
<a href="/spenden/">Spenden</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{{/* Artikel-Fuß für Library-Beiträge: Quellenangabe (zitieren), Aktionsreihe
|
||||||
|
(Dialog links, Tags rechts) und der Versionsverlauf (eine Zeile darunter). */}}
|
||||||
|
{{ $author := .Params.author | default site.Params.author.name }}
|
||||||
|
|
||||||
|
{{/* Zitieren: schlichter Link direkt unter den Quellen. */}}
|
||||||
|
<div class="cite">
|
||||||
|
<button type="button" class="cite-toggle" aria-expanded="false">zitieren <span class="cite-arrow">↗</span></button>
|
||||||
|
<div class="cite-box" hidden
|
||||||
|
data-title="{{ .Title }}"
|
||||||
|
data-author="{{ $author }}"
|
||||||
|
data-url="{{ .Permalink }}"
|
||||||
|
data-year="{{ .Date.Format "2006" }}"
|
||||||
|
{{ with .GitInfo }}data-version="{{ .AbbreviatedHash }}"{{ end }}>
|
||||||
|
<p class="cite-text"></p>
|
||||||
|
<div class="cite-actions">
|
||||||
|
<button type="button" class="cite-fmt is-active" data-fmt="ob">intern</button>
|
||||||
|
<button type="button" class="cite-fmt" data-fmt="apa">APA</button>
|
||||||
|
<button type="button" class="cite-fmt" data-fmt="din">DIN</button>
|
||||||
|
<button type="button" class="cite-copy">kopieren</button>
|
||||||
|
<span class="cite-status" role="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Aktionsreihe: Dialog links, Tags ganz rechts. */}}
|
||||||
|
<div class="article-actions">
|
||||||
|
<a class="prov-dialog" id="dialog-link" data-thread="{{ .RelPermalink }}" href="/dialog/?thread={{ .RelPermalink }}">→ Dialog</a>
|
||||||
|
{{ with .Params.tags }}
|
||||||
|
<ul class="tag-pills" aria-label="Tags">
|
||||||
|
{{- range . -}}<li><a href="/tags/{{ . | urlize }}/">{{ . }}</a></li>{{- end -}}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Versionen: eine Zeile darunter; öffnet den Verlauf direkt auf der Seite. */}}
|
||||||
|
<div class="article-versions">
|
||||||
|
<button type="button" class="versions-toggle" id="version-badge" aria-expanded="false"
|
||||||
|
data-path="{{ .File.Path }}"><svg class="pill-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7.5V12l3 2"/></svg>Versionen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Zitieren: APA/DIN umschaltbar, gesamthaft kopierbar. */
|
||||||
|
(function () {
|
||||||
|
if (window.__cite) return; window.__cite = 1;
|
||||||
|
var toggle = document.querySelector('.cite-toggle');
|
||||||
|
var box = document.querySelector('.cite-box');
|
||||||
|
if (!toggle || !box) return;
|
||||||
|
var textEl = box.querySelector('.cite-text');
|
||||||
|
var statusEl = box.querySelector('.cite-status');
|
||||||
|
var d = box.dataset;
|
||||||
|
var fmt = 'ob';
|
||||||
|
|
||||||
|
function nameParts(n) {
|
||||||
|
var p = (n || '').trim().split(/\s+/);
|
||||||
|
var last = p.pop() || '';
|
||||||
|
return { last: last, first: p.join(' '), initials: p.map(function (w) { return w.charAt(0) + '.'; }).join(' ') };
|
||||||
|
}
|
||||||
|
function today() { return new Date().toLocaleDateString('de-CH'); }
|
||||||
|
function build() {
|
||||||
|
var n = nameParts(d.author);
|
||||||
|
if (fmt === 'din') {
|
||||||
|
return (n.last ? n.last.toUpperCase() + ', ' + n.first + ': ' : '')
|
||||||
|
+ d.title + '. OPENBUREAU. ' + d.url + ' (abgerufen am ' + today() + ').';
|
||||||
|
}
|
||||||
|
if (fmt === 'apa') {
|
||||||
|
return (n.last ? n.last + ', ' + n.initials + ' ' : '')
|
||||||
|
+ '(' + d.year + '). ' + d.title + '. OPENBUREAU. Abgerufen am ' + today() + ', von ' + d.url;
|
||||||
|
}
|
||||||
|
// intern (OPENBUREAU-Hausformat): inkl. Version, weil Beiträge lebende Dokumente sind.
|
||||||
|
return (d.author ? d.author + ': ' : '') + d.title + '. OPENBUREAU'
|
||||||
|
+ (d.version ? ', Version ' + d.version : '') + '. Abgerufen am ' + today() + ', ' + d.url;
|
||||||
|
}
|
||||||
|
function render() { textEl.textContent = build(); }
|
||||||
|
function copy() {
|
||||||
|
var t = build();
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
|
||||||
|
return new Promise(function (res, rej) {
|
||||||
|
try {
|
||||||
|
var ta = document.createElement('textarea'); ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta); ta.select();
|
||||||
|
var ok = document.execCommand('copy'); document.body.removeChild(ta); ok ? res() : rej();
|
||||||
|
} catch (e) { rej(e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var open = box.hasAttribute('hidden');
|
||||||
|
if (open) { box.removeAttribute('hidden'); render(); } else { box.setAttribute('hidden', ''); }
|
||||||
|
toggle.setAttribute('aria-expanded', String(open));
|
||||||
|
});
|
||||||
|
box.querySelectorAll('.cite-fmt').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
fmt = b.dataset.fmt;
|
||||||
|
box.querySelectorAll('.cite-fmt').forEach(function (x) { x.classList.toggle('is-active', x === b); });
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
box.querySelector('.cite-copy').addEventListener('click', function () {
|
||||||
|
copy().then(function () { statusEl.textContent = 'kopiert ✓'; })
|
||||||
|
.catch(function () {
|
||||||
|
statusEl.textContent = 'markieren & kopieren';
|
||||||
|
var r = document.createRange(); r.selectNodeContents(textEl);
|
||||||
|
var s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||||
|
});
|
||||||
|
setTimeout(function () { statusEl.textContent = ''; }, 2500);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
/* Wortmeldungs-Zahl an die Dialog-Pill hängen (→ Dialog · 3). */
|
||||||
|
(function () {
|
||||||
|
var l = document.getElementById('dialog-link'); if (!l) return;
|
||||||
|
fetch('/api/comments?thread=' + encodeURIComponent(l.dataset.thread))
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (d) { var n = d.filter(function (c) { return !c.deleted; }).length; if (n) l.textContent = '→ Dialog · ' + n; })
|
||||||
|
.catch(function () {});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{{ define "main" }}
|
||||||
|
{{ if eq .Path "/archiv" }}
|
||||||
|
{{/* Archiv-Übersicht: Umschalter Kategorie ↔ Jahr */}}
|
||||||
|
<div class="collection" style="--section-color: var(--palette-kusa)">
|
||||||
|
<h1 class="collection-title">{{ .Title }}</h1>
|
||||||
|
<div class="collection-inner">
|
||||||
|
{{ .Content }}
|
||||||
|
|
||||||
|
<div class="archiv-toggle" role="group" aria-label="Sortierung">
|
||||||
|
<button type="button" data-mode="cat" class="is-active">nach Kategorie</button>
|
||||||
|
<button type="button" data-mode="year">nach Jahr</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Ansicht nach Kategorie — Jahr hinter dem Beitrag, je Rubrik die letzten 10 */}}
|
||||||
|
<div class="archiv-view" data-view="cat">
|
||||||
|
<section class="atlas">
|
||||||
|
{{ range .Sections.ByWeight }}
|
||||||
|
{{ $section := path.Base .RelPermalink }}
|
||||||
|
<article class="atlas-section" data-section="{{ $section }}">
|
||||||
|
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
|
||||||
|
{{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }}
|
||||||
|
<ul class="atlas-list">
|
||||||
|
{{ range first 10 .RegularPages.ByDate.Reverse }}
|
||||||
|
<li>
|
||||||
|
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||||
|
<span class="list-meta text-muted"> · {{ .Date.Format "2006" }}</span>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
{{ if gt (len .RegularPages) 10 }}
|
||||||
|
<p class="more"><a href="{{ .RelPermalink }}">alle in {{ .Title }} →</a></p>
|
||||||
|
{{ end }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with site.Taxonomies.tags }}
|
||||||
|
<article class="atlas-tags">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<ul class="tag-cloud">
|
||||||
|
{{ range $name, $taxonomy := . }}
|
||||||
|
<li><a href="/tags/{{ $name | urlize }}/">{{ $name }} <span class="text-muted">({{ len $taxonomy }})</span></a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* Ansicht nach Jahr — Kategorie hinter dem Beitrag, alle Beiträge */}}
|
||||||
|
<div class="archiv-view" data-view="year" hidden>
|
||||||
|
{{ $all := where site.RegularPages "Section" "archiv" }}
|
||||||
|
{{ range $all.GroupByDate "2006" }}
|
||||||
|
<article class="atlas-section">
|
||||||
|
<h2>{{ .Key }}</h2>
|
||||||
|
<ul class="atlas-list">
|
||||||
|
{{ range .Pages }}
|
||||||
|
<li>
|
||||||
|
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||||
|
{{ with .CurrentSection }}<span class="list-meta text-muted"> · {{ .LinkTitle }}</span>{{ end }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var bar = document.querySelector('.archiv-toggle'); if (!bar) return;
|
||||||
|
var views = document.querySelectorAll('.archiv-view');
|
||||||
|
function set(mode) {
|
||||||
|
bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('is-active', b.dataset.mode === mode); });
|
||||||
|
views.forEach(function (v) { v.hidden = v.dataset.view !== mode; });
|
||||||
|
try { localStorage.setItem('ob_archiv_mode', mode); } catch (e) {}
|
||||||
|
}
|
||||||
|
bar.addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) set(b.dataset.mode); });
|
||||||
|
var saved; try { saved = localStorage.getItem('ob_archiv_mode'); } catch (e) {}
|
||||||
|
if (saved === 'year') set('year');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ else if eq .Path "/archiv/software" }}
|
||||||
|
{{/* Software: kuratierte Landing — Werkzeuge (mit externem Link) getrennt
|
||||||
|
von Texten & Anleitungen. */}}
|
||||||
|
<header class="section-header" data-section="software">
|
||||||
|
<p class="section-rubric">Archiv</p>
|
||||||
|
<h1 class="section-title">{{ .Title }}</h1>
|
||||||
|
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{ .Content }}
|
||||||
|
|
||||||
|
{{ $tools := where .RegularPages "Params.external" "!=" nil }}
|
||||||
|
{{ $texts := where .RegularPages "Params.external" nil }}
|
||||||
|
|
||||||
|
{{ with $tools }}
|
||||||
|
<section class="software-tools">
|
||||||
|
<h2 class="software-h">Werkzeuge</h2>
|
||||||
|
<ul class="tool-list">
|
||||||
|
{{ range .ByWeight }}
|
||||||
|
<li class="tool-item"{{ with .Params.color }} data-color="{{ . }}"{{ end }}>
|
||||||
|
<a class="tool-main" href="{{ .RelPermalink }}">
|
||||||
|
<span class="tool-name">{{ .LinkTitle }}</span>
|
||||||
|
{{ with .Params.summary }}<span class="tool-sum text-muted">{{ . }}</span>{{ end }}
|
||||||
|
</a>
|
||||||
|
{{ with .Params.external }}<a class="tool-ext" href="{{ . }}" rel="noopener" aria-label="extern öffnen">↗</a>{{ end }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<section class="software-texts">
|
||||||
|
<h2 class="software-h">Texte & Anleitungen</h2>
|
||||||
|
<div class="time-list" data-section="software">
|
||||||
|
<ul>
|
||||||
|
{{ range $texts.ByDate.Reverse }}
|
||||||
|
<li class="list-item">
|
||||||
|
<div class="list-title-row">
|
||||||
|
<div class="list-title">
|
||||||
|
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||||
|
{{ with .Params.summary }}<div class="list-summary text-muted">{{ . }}</div>{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ else }}
|
||||||
|
{{/* Archiv-Unterseite (Rubrik): gleiche Optik wie Übersichten */}}
|
||||||
|
<div class="collection" style="--section-color: var(--palette-kusa)">
|
||||||
|
<p class="section-rubric">Archiv</p>
|
||||||
|
<h1 class="collection-title">{{ .Title }}</h1>
|
||||||
|
<div class="collection-inner">
|
||||||
|
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
||||||
|
{{ .Content }}
|
||||||
|
<div class="time-list">
|
||||||
|
<ul>
|
||||||
|
{{ range .RegularPages.ByDate.Reverse }}
|
||||||
|
<li class="list-item">
|
||||||
|
<div class="list-title-row">
|
||||||
|
<div class="list-title">
|
||||||
|
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||||
|
{{ with .Params.summary }}
|
||||||
|
<div class="list-summary text-muted">{{ . }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
|
|
||||||
{{ $library := where site.RegularPages "Section" "library" }}
|
{{ $archiv := where site.RegularPages "Section" "archiv" }}
|
||||||
{{ $journal := first 20 $library.ByDate.Reverse }}
|
{{ $journal := first 20 $archiv.ByDate.Reverse }}
|
||||||
|
|
||||||
<section class="journal" aria-label="Journal — neueste Beiträge">
|
<section class="journal" aria-label="Journal — neueste Beiträge">
|
||||||
<header class="journal-header">
|
<header class="journal-header">
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ if gt (len $library) 20 }}
|
{{ if gt (len $archiv) 20 }}
|
||||||
<p class="more"><a href="/library/">→ Alle Beiträge in der Library</a></p>
|
<p class="more"><a href="/archiv/">→ Alle Beiträge im Archiv</a></p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</section>
|
</section>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
+75
-52
@@ -1,66 +1,89 @@
|
|||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
|
<div class="collection" style="--section-color: var(--palette-ichigo)">
|
||||||
|
<h1 class="collection-title">{{ .Title }}</h1>
|
||||||
|
<div class="collection-inner">
|
||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
|
|
||||||
{{ if .IsSection }}
|
{{ $pages := where site.RegularPages "Section" "library" }}
|
||||||
{{ if eq .Path "/library" }}
|
|
||||||
{{/* Library root: Atlas — gruppiert nach Untersection */}}
|
|
||||||
<section class="atlas">
|
|
||||||
{{ range .Sections.ByWeight }}
|
|
||||||
{{ $section := path.Base .RelPermalink }}
|
|
||||||
<article class="atlas-section" data-section="{{ $section }}">
|
|
||||||
<h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
|
|
||||||
{{ with .Params.description }}<p class="text-muted">{{ . }}</p>{{ end }}
|
|
||||||
<ul class="atlas-list">
|
|
||||||
{{ range first 6 .RegularPages.ByDate.Reverse }}
|
|
||||||
<li>
|
|
||||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
|
||||||
<span class="list-meta text-muted"> · {{ partial "date.html" .Date }}</span>
|
|
||||||
</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
{{ if gt (len .RegularPages) 6 }}
|
|
||||||
<p class="more"><a href="{{ .RelPermalink }}">alle in {{ .Title }} →</a></p>
|
|
||||||
{{ end }}
|
|
||||||
</article>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{/* Tag-Cloud */}}
|
{{ if $pages }}
|
||||||
{{ with site.Taxonomies.tags }}
|
{{/* Gruppen sammeln + sortieren */}}
|
||||||
<article class="atlas-tags">
|
{{ $groups := dict }}
|
||||||
<h2>Tags</h2>
|
{{ range $pages }}
|
||||||
<ul class="tag-cloud">
|
{{ $g := .Params.group | default "Allgemein" }}
|
||||||
{{ range $name, $taxonomy := . }}
|
{{ $existing := index $groups $g | default slice }}
|
||||||
<li><a href="/tags/{{ $name | urlize }}/">{{ $name }} <span class="text-muted">({{ len $taxonomy }})</span></a></li>
|
{{ $groups = merge $groups (dict $g ($existing | append .)) }}
|
||||||
|
{{ end }}
|
||||||
|
{{ $groupNames := slice }}
|
||||||
|
{{ range $g, $_ := $groups }}{{ $groupNames = $groupNames | append $g }}{{ end }}
|
||||||
|
{{ $groupNames = sort $groupNames }}
|
||||||
|
|
||||||
|
<div class="lib-filter">
|
||||||
|
<input id="lib-search" class="lib-search" type="search" placeholder="Suchen …" autocomplete="off" spellcheck="false">
|
||||||
|
<div class="lib-pills">
|
||||||
|
<button class="lib-pill active" data-group="">Alle</button>
|
||||||
|
{{ range $groupNames }}<button class="lib-pill" data-group="{{ . }}">{{ . }}</button>{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="atlas atlas--grid2">
|
||||||
|
{{ range $groupNames }}
|
||||||
|
{{ $ps := index $groups . }}
|
||||||
|
<article class="atlas-section" data-group="{{ . }}">
|
||||||
|
<h2>{{ . }}</h2>
|
||||||
|
<ul class="atlas-list">
|
||||||
|
{{ range sort $ps "Title" }}
|
||||||
|
{{ $norm := lower .Title }}
|
||||||
|
{{ $norm = replace $norm "ä" "a" }}
|
||||||
|
{{ $norm = replace $norm "ö" "o" }}
|
||||||
|
{{ $norm = replace $norm "ü" "u" }}
|
||||||
|
{{ $norm = replace $norm "ß" "ss" }}
|
||||||
|
<li data-title="{{ $norm }}">
|
||||||
|
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
||||||
|
{{ with .Params.summary }}<span class="list-meta text-muted"> — {{ . }}</span>{{ end }}
|
||||||
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var input = document.getElementById('lib-search');
|
||||||
|
var pills = document.querySelectorAll('.lib-pill');
|
||||||
|
var activeGroup = '';
|
||||||
|
|
||||||
|
function filter() {
|
||||||
|
var q = input.value.trim().toLowerCase()
|
||||||
|
.replace(/ä/g,'a').replace(/ö/g,'o').replace(/ü/g,'u').replace(/ß/g,'ss');
|
||||||
|
document.querySelectorAll('.atlas-section').forEach(function(sec) {
|
||||||
|
var groupMatch = !activeGroup || sec.dataset.group === activeGroup;
|
||||||
|
if (!groupMatch) { sec.style.display = 'none'; return; }
|
||||||
|
var visible = 0;
|
||||||
|
sec.querySelectorAll('li[data-title]').forEach(function(li) {
|
||||||
|
var matchQ = !q || li.dataset.title.indexOf(q) !== -1;
|
||||||
|
li.style.display = matchQ ? '' : 'none';
|
||||||
|
if (matchQ) visible++;
|
||||||
|
});
|
||||||
|
sec.style.display = visible ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', filter);
|
||||||
|
pills.forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
pills.forEach(function(b){ b.classList.remove('active'); });
|
||||||
|
this.classList.add('active');
|
||||||
|
activeGroup = this.dataset.group || '';
|
||||||
|
filter();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
{{/* Library subsection: chronologisch */}}
|
<p class="text-muted"><em>Noch keine Einträge — der erste entsteht im Redaktions-Editor.</em></p>
|
||||||
{{ $section := path.Base .RelPermalink }}
|
|
||||||
<header class="section-header" data-section="{{ $section }}">
|
|
||||||
<p class="section-rubric">Library</p>
|
|
||||||
<h1 class="section-title">{{ .Title }}</h1>
|
|
||||||
{{ with .Params.description }}<p class="section-description">{{ . }}</p>{{ end }}
|
|
||||||
</header>
|
|
||||||
<div class="time-list" data-section="{{ $section }}">
|
|
||||||
<ul>
|
|
||||||
{{ range .RegularPages.ByDate.Reverse }}
|
|
||||||
<li class="list-item">
|
|
||||||
<div class="list-title-row">
|
|
||||||
<div class="list-title">
|
|
||||||
<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a>
|
|
||||||
{{ with .Params.summary }}
|
|
||||||
<div class="list-summary text-muted">{{ . }}</div>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="list-meta">{{ partial "date.html" .Date }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{{ end }}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{{ define "main" }}
|
||||||
|
<article class="single library-entry" style="--section-color: var(--palette-ichigo)">
|
||||||
|
<header class="single-header">
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
{{ with .Params.summary }}<p class="single-summary">{{ . }}</p>{{ end }}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{ $hasToC := .Params.toc | default false }}
|
||||||
|
{{ $headers := findRE "<h[2-6]" .Content }}
|
||||||
|
{{ if and $hasToC (ge (len $headers) 2) }}
|
||||||
|
<nav class="toc">
|
||||||
|
<strong>Inhalt</strong>
|
||||||
|
<div class="toc-content">{{ .TableOfContents }}</div>
|
||||||
|
</nav>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{/* Wiki-Links [[Titel]] / [[slug]] → Link auf die passende Library-Seite. */}}
|
||||||
|
{{ $html := .Content }}
|
||||||
|
{{ range (where site.RegularPages "Section" "library") }}
|
||||||
|
{{ $a := printf `<a href="%s" class="wikilink">%s</a>` .RelPermalink .LinkTitle }}
|
||||||
|
{{ $html = replace $html (printf "[[%s]]" .LinkTitle) $a }}
|
||||||
|
{{ $html = replace $html (printf "[[%s]]" .File.ContentBaseName) $a }}
|
||||||
|
{{ end }}
|
||||||
|
{{/* Übrige (noch nicht angelegte) Verweise: ohne Klammern, dezent markiert. */}}
|
||||||
|
{{ $html = replaceRE `\[\[([^\]]+)\]\]` `<span class="wikilink-missing" title="Seite existiert noch nicht">$1</span>` $html }}
|
||||||
|
<div class="single-content">{{ $html | safeHTML }}</div>
|
||||||
|
|
||||||
|
{{/* ── Siehe auch: gleiche Gruppe + geteilte Tags ── */}}
|
||||||
|
{{ $cur := . }}
|
||||||
|
{{ $related := slice }}
|
||||||
|
{{ range where (where site.RegularPages "Section" "library") "Params.group" .Params.group }}
|
||||||
|
{{ if ne .RelPermalink $cur.RelPermalink }}{{ $related = $related | append . }}{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Params.tags }}
|
||||||
|
{{ range $t := . }}
|
||||||
|
{{ range (where site.RegularPages "Section" "library") }}
|
||||||
|
{{ if and (ne .RelPermalink $cur.RelPermalink) (in (.Params.tags | default slice) $t) }}
|
||||||
|
{{ $related = $related | append . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ $related = $related | uniq }}
|
||||||
|
{{ with $related }}
|
||||||
|
<nav class="entry-links" aria-label="Siehe auch">
|
||||||
|
<span class="entry-links-label">Siehe auch</span>
|
||||||
|
<ul>{{ range . }}<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>{{ end }}</ul>
|
||||||
|
</nav>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{/* ── Erwähnt in (Backlinks): Seiten, die per Link oder [[…]] hierher zeigen ── */}}
|
||||||
|
{{ $back := slice }}
|
||||||
|
{{ $url := .RelPermalink }}
|
||||||
|
{{ $tok1 := printf "[[%s]]" .Title }}
|
||||||
|
{{ $tok2 := printf "[[%s]]" .File.ContentBaseName }}
|
||||||
|
{{ range site.RegularPages }}
|
||||||
|
{{ if ne .RelPermalink $url }}
|
||||||
|
{{ $raw := .RawContent }}
|
||||||
|
{{ if or (in $raw $url) (in $raw $tok1) (in $raw $tok2) }}
|
||||||
|
{{ $back = $back | append . }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ with $back }}
|
||||||
|
<nav class="entry-links" aria-label="Erwähnt in">
|
||||||
|
<span class="entry-links-label">Erwähnt in</span>
|
||||||
|
<ul>{{ range . }}<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>{{ end }}</ul>
|
||||||
|
</nav>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{/* Fuss: Gruppe + zuletzt bearbeitet + bearbeiten. */}}
|
||||||
|
<div class="entry-foot">
|
||||||
|
<span class="entry-more"><strong>{{ .Params.group | default "Allgemein" }}</strong></span>
|
||||||
|
{{ if .Lastmod }}<span>Zuletzt bearbeitet am {{ .Lastmod.Format "02.01.2006" }}</span>{{ end }}
|
||||||
|
{{ with .File }}<a href="{{ site.Params.repoURL }}/_edit/branch/main/content/{{ .Path }}" rel="nofollow">bearbeiten ↗</a>{{ end }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# OPENBUREAU — Proxmox-Selbsthosting-Set
|
||||||
|
|
||||||
|
Skripte, um die Dienste eines Architekturbüros auf einem Proxmox-VE-Host
|
||||||
|
aufzusetzen — jeder Dienst in seinem eigenen, unprivilegierten, Docker-tauglichen
|
||||||
|
LXC. Alle Skripte werden **auf dem Proxmox-Host als `root`** ausgeführt.
|
||||||
|
|
||||||
|
## Zwei Wege
|
||||||
|
|
||||||
|
**1. Suite mit Dialog** — fragt, was man will, und installiert einen LXC nach dem
|
||||||
|
anderen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Einzelskripte** (für Fortgeschrittene) — direkt, ohne Menü. Jedes ist in sich
|
||||||
|
geschlossen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Website + CMS (eigenes Skript, erzeugt alle Supabase-Secrets)
|
||||||
|
bash <(curl -fsSL …/cms/proxmox/create-openbureau-lxc.sh)
|
||||||
|
|
||||||
|
# Nextcloud (Dateien/Kalender/Kontakte/Office — ersetzt 365 + Synology)
|
||||||
|
bash <(curl -fsSL …/proxmox/nextcloud-lxc.sh) [disk_gb] [ram_mb]
|
||||||
|
|
||||||
|
# Leerer Docker-LXC als Gerüst
|
||||||
|
bash <(curl -fsSL …/proxmox/empty-lxc.sh) [name] [disk_gb] [ram_mb]
|
||||||
|
|
||||||
|
# Beliebiger Dienst aus einem Git-Repo mit docker-compose (RAPPORT, DOSSIER …)
|
||||||
|
bash <(curl -fsSL …/proxmox/git-compose-lxc.sh) <repo-url> [name] [disk_gb] [ram_mb]
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` akzeptiert dieselben Dienste auch direkt als Argument
|
||||||
|
(`install.sh nextcloud`, `install.sh git <repo> …`).
|
||||||
|
|
||||||
|
## Gemeinsames Muster
|
||||||
|
|
||||||
|
Jedes Skript macht dasselbe: aktuelles Debian-12-Template sicherstellen,
|
||||||
|
unprivilegierten LXC mit `nesting=1,keyctl=1` anlegen (damit Docker darin läuft),
|
||||||
|
Docker installieren, den Dienst als Container/Compose-Stack starten. Storage, Netz
|
||||||
|
und SSH-Key lassen sich per Umgebungsvariable überschreiben:
|
||||||
|
|
||||||
|
| Variable | Default |
|
||||||
|
|--------------------|--------------------------|
|
||||||
|
| `ROOTFS_STORAGE` | `local-lvm` |
|
||||||
|
| `TEMPLATE_STORAGE` | `local` |
|
||||||
|
| `BRIDGE` | `vmbr0` |
|
||||||
|
| `SSH_PUBKEY_FILE` | `~/.ssh/id_ed25519.pub` |
|
||||||
|
|
||||||
|
## Domain & HTTPS hinter einem Reverse-Proxy
|
||||||
|
|
||||||
|
Für eine öffentliche Adresse (statt LAN-IP:Port) kennt
|
||||||
|
`cms/proxmox/create-openbureau-lxc.sh` die Variable **`SITE_DOMAIN`**. Ist sie
|
||||||
|
gesetzt, werden `SITE_URL` und `API_EXTERNAL_URL` auf `https://<domain>` gelegt
|
||||||
|
(Same-Origin) — der Browser ruft `/auth/*` + `/rest/*` auf derselben Domain auf,
|
||||||
|
der Reverse-Proxy routet sie ans Supabase-Gateway (`:8000`), alles andere an die
|
||||||
|
Site (`:8080`). `BIND_ADDR` bleibt `0.0.0.0`, damit der Proxy drankommt.
|
||||||
|
|
||||||
|
So wurde **dev.openbureau.ch** aufgesetzt (LXC auf einem ZFS-Host, statische IP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ROOTFS_STORAGE=local-zfs HOSTNAME=openbureau-dev CTID=134 \
|
||||||
|
IP=192.168.1.134/24 GATEWAY=192.168.1.1 SITE_DOMAIN=dev.openbureau.ch \
|
||||||
|
bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/cms/proxmox/create-openbureau-lxc.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Den passenden Reverse-Proxy-Eintrag gibt das Skript am Ende selbst aus. Für
|
||||||
|
**Caddy** (Pfad-Routing, ein Zertifikat):
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
dev.openbureau.ch {
|
||||||
|
# Nur /auth/* muss public ans Supabase-Gateway (Browser-Login). Alle Daten
|
||||||
|
# laufen über /api/* (Node spricht kong intern an). /rest, /storage,
|
||||||
|
# /realtime bewusst NICHT exponieren — sonst gibt /rest/v1/ die ganze
|
||||||
|
# DB-Schema-Beschreibung preis (PostgREST-OpenAPI).
|
||||||
|
@auth path /auth/*
|
||||||
|
reverse_proxy @auth 192.168.1.134:8000
|
||||||
|
reverse_proxy 192.168.1.134:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Den Caddy-Block in die jeweilige Proxy-Config eintragen, validieren und neu laden
|
||||||
|
(`caddy reload`). Caddy holt das Let's-Encrypt-Zertifikat beim ersten Aufruf
|
||||||
|
selbst. Login-User danach über die Admin-API anlegen (Self-Signup ist aus):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct enter <CTID>; cd /opt/openbureau/cms; source .env
|
||||||
|
curl -s -X POST "http://localhost:8000/auth/v1/admin/users" \
|
||||||
|
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"du@example.ch","password":"…","email_confirm":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hintergrund
|
||||||
|
|
||||||
|
Warum und wie — die zwei Artikel in der Bibliothek:
|
||||||
|
[Server im eigenen Haus](https://openbureau.ch/library/software/server-im-eigenen-haus/)
|
||||||
|
und [Proxmox, Schritt für Schritt](https://openbureau.ch/library/software/proxmox-schritt-fuer-schritt/).
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Leerer, Docker-tauglicher LXC für Proxmox VE — das Gerüst für eigene Dienste.
|
||||||
|
#
|
||||||
|
# AUF DEM PROXMOX-HOST, als root:
|
||||||
|
# bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/empty-lxc.sh) [name] [disk_gb] [ram_mb]
|
||||||
|
#
|
||||||
|
# Beispiel: … empty-lxc.sh dateien 200 8192
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
############################# gemeinsamer Kopf #############################
|
||||||
|
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
|
||||||
|
ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}"
|
||||||
|
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
|
||||||
|
BRIDGE="${BRIDGE:-vmbr0}"
|
||||||
|
|
||||||
|
say() { echo -e "\n\033[1;36m▸ $*\033[0m"; }
|
||||||
|
ok() { echo -e "\033[1;32m✓ $*\033[0m"; }
|
||||||
|
warn() { echo -e "\033[1;33m! $*\033[0m" >&2; }
|
||||||
|
die() { echo -e "\033[1;31m✗ $*\033[0m" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Bitte als root auf dem Proxmox-Host ausführen."
|
||||||
|
command -v pct >/dev/null || die "pct nicht gefunden — läuft das wirklich auf Proxmox VE?"
|
||||||
|
|
||||||
|
ensure_template() {
|
||||||
|
[ -n "${TEMPLATE_REF:-}" ] && return 0
|
||||||
|
pveam update >/dev/null 2>&1 || true
|
||||||
|
local tpl
|
||||||
|
tpl="$(pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
|
||||||
|
[ -n "$tpl" ] || die "Kein debian-12-Template verfügbar."
|
||||||
|
pveam list "$TEMPLATE_STORAGE" | grep -q "$tpl" || { say "Lade Template $tpl…"; pveam download "$TEMPLATE_STORAGE" "$tpl" >/dev/null; }
|
||||||
|
TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${tpl}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# create_lxc <name> <disk_gb> <ram_mb> [cores] — setzt $CTID
|
||||||
|
create_lxc() {
|
||||||
|
local name="$1" disk="$2" ram="$3" cores="${4:-2}" ctid
|
||||||
|
ensure_template
|
||||||
|
ctid="$(pvesh get /cluster/nextid)"
|
||||||
|
local args=(
|
||||||
|
"$ctid" "$TEMPLATE_REF" --hostname "$name"
|
||||||
|
--cores "$cores" --memory "$ram" --swap 1024
|
||||||
|
--rootfs "${ROOTFS_STORAGE}:${disk}"
|
||||||
|
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp"
|
||||||
|
--unprivileged 1 --features "nesting=1,keyctl=1" --onboot 1
|
||||||
|
)
|
||||||
|
[ -f "$SSH_PUBKEY_FILE" ] && args+=(--ssh-public-keys "$SSH_PUBKEY_FILE")
|
||||||
|
say "Erstelle LXC $ctid ($name) — ${cores} Kerne, ${ram} MB RAM, ${disk} GB…"
|
||||||
|
pct create "${args[@]}" >/dev/null
|
||||||
|
pct start "$ctid"; sleep 5
|
||||||
|
CTID="$ctid"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_docker() {
|
||||||
|
say "Installiere Docker in $1…"
|
||||||
|
pct exec "$1" -- bash -euo pipefail -c '
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl git openssl >/dev/null
|
||||||
|
curl -fsSL https://get.docker.com | sh >/dev/null
|
||||||
|
systemctl enable --now docker
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_of() { pct exec "$1" -- hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||||
|
|
||||||
|
################################# Dienst #################################
|
||||||
|
NAME="${1:-docker}"; DISK="${2:-20}"; RAM="${3:-4096}"
|
||||||
|
|
||||||
|
create_lxc "$NAME" "$DISK" "$RAM"
|
||||||
|
install_docker "$CTID"
|
||||||
|
ok "Leerer Docker-LXC $CTID ($NAME) läuft unter $(ip_of "$CTID")."
|
||||||
|
echo " Hinein: pct enter $CTID"
|
||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Beliebiger self-hosted Dienst aus einem Git-Repo mit docker-compose, als
|
||||||
|
# eigener LXC für Proxmox VE. Folgt demselben Muster wie OPENBUREAU — passend
|
||||||
|
# für eigene Werkzeuge wie RAPPORT oder DOSSIER.
|
||||||
|
#
|
||||||
|
# AUF DEM PROXMOX-HOST, als root:
|
||||||
|
# bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/git-compose-lxc.sh) <repo-url> [name] [disk_gb] [ram_mb]
|
||||||
|
#
|
||||||
|
# Beispiel: … git-compose-lxc.sh git.kgva.ch/karim/RAPPORT-SERVER.git rapport
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
############################# gemeinsamer Kopf #############################
|
||||||
|
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
|
||||||
|
ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}"
|
||||||
|
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
|
||||||
|
BRIDGE="${BRIDGE:-vmbr0}"
|
||||||
|
|
||||||
|
say() { echo -e "\n\033[1;36m▸ $*\033[0m"; }
|
||||||
|
ok() { echo -e "\033[1;32m✓ $*\033[0m"; }
|
||||||
|
warn() { echo -e "\033[1;33m! $*\033[0m" >&2; }
|
||||||
|
die() { echo -e "\033[1;31m✗ $*\033[0m" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Bitte als root auf dem Proxmox-Host ausführen."
|
||||||
|
command -v pct >/dev/null || die "pct nicht gefunden — läuft das wirklich auf Proxmox VE?"
|
||||||
|
|
||||||
|
ensure_template() {
|
||||||
|
[ -n "${TEMPLATE_REF:-}" ] && return 0
|
||||||
|
pveam update >/dev/null 2>&1 || true
|
||||||
|
local tpl
|
||||||
|
tpl="$(pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
|
||||||
|
[ -n "$tpl" ] || die "Kein debian-12-Template verfügbar."
|
||||||
|
pveam list "$TEMPLATE_STORAGE" | grep -q "$tpl" || { say "Lade Template $tpl…"; pveam download "$TEMPLATE_STORAGE" "$tpl" >/dev/null; }
|
||||||
|
TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${tpl}"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_lxc() {
|
||||||
|
local name="$1" disk="$2" ram="$3" cores="${4:-2}" ctid
|
||||||
|
ensure_template
|
||||||
|
ctid="$(pvesh get /cluster/nextid)"
|
||||||
|
local args=(
|
||||||
|
"$ctid" "$TEMPLATE_REF" --hostname "$name"
|
||||||
|
--cores "$cores" --memory "$ram" --swap 1024
|
||||||
|
--rootfs "${ROOTFS_STORAGE}:${disk}"
|
||||||
|
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp"
|
||||||
|
--unprivileged 1 --features "nesting=1,keyctl=1" --onboot 1
|
||||||
|
)
|
||||||
|
[ -f "$SSH_PUBKEY_FILE" ] && args+=(--ssh-public-keys "$SSH_PUBKEY_FILE")
|
||||||
|
say "Erstelle LXC $ctid ($name) — ${cores} Kerne, ${ram} MB RAM, ${disk} GB…"
|
||||||
|
pct create "${args[@]}" >/dev/null
|
||||||
|
pct start "$ctid"; sleep 5
|
||||||
|
CTID="$ctid"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_docker() {
|
||||||
|
say "Installiere Docker in $1…"
|
||||||
|
pct exec "$1" -- bash -euo pipefail -c '
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl git openssl >/dev/null
|
||||||
|
curl -fsSL https://get.docker.com | sh >/dev/null
|
||||||
|
systemctl enable --now docker
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_of() { pct exec "$1" -- hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||||
|
|
||||||
|
################################# Dienst #################################
|
||||||
|
REPO="${1:-}"; NAME="${2:-}"; DISK="${3:-20}"; RAM="${4:-4096}"
|
||||||
|
[ -n "$REPO" ] || die "Bitte eine Repo-URL angeben (Arg 1)."
|
||||||
|
[ -n "$NAME" ] || NAME="$(basename "${REPO%.git}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
case "$REPO" in http*://*) : ;; *) REPO="https://$REPO" ;; esac
|
||||||
|
|
||||||
|
create_lxc "$NAME" "$DISK" "$RAM"
|
||||||
|
install_docker "$CTID"
|
||||||
|
|
||||||
|
say "Klone $REPO und starte den Stack…"
|
||||||
|
pct exec "$CTID" -- bash -euo pipefail -c "
|
||||||
|
git clone --quiet '$REPO' '/opt/$NAME' || { echo 'Clone fehlgeschlagen.'; exit 1; }
|
||||||
|
cd '/opt/$NAME'
|
||||||
|
CF=\$(ls docker-compose.y*ml compose.y*ml */docker-compose.y*ml */compose.y*ml 2>/dev/null | head -1)
|
||||||
|
[ -n \"\$CF\" ] || { echo 'Keine docker-compose-Datei gefunden.'; exit 1; }
|
||||||
|
cd \"\$(dirname \"\$CF\")\"
|
||||||
|
if [ ! -f .env ] && [ -f .env.example ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo 'HINWEIS: .env aus .env.example kopiert — Secrets ggf. anpassen.'
|
||||||
|
fi
|
||||||
|
docker compose up -d --build
|
||||||
|
"
|
||||||
|
|
||||||
|
ok "$NAME (LXC $CTID) läuft unter $(ip_of "$CTID")."
|
||||||
|
warn "App-spezifische Secrets (.env) ggf. prüfen: pct enter $CTID"
|
||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# OPENBUREAU-Suite — der Dialog-Installer für das Selbsthosting-Set auf Proxmox.
|
||||||
|
#
|
||||||
|
# Fragt im Dialog, welche Dienste man will, und installiert dann einen LXC nach
|
||||||
|
# dem anderen — jeder über sein eigenes, eigenständiges Skript (proxmox/*-lxc.sh).
|
||||||
|
# Fortgeschrittene können diese Einzelskripte auch direkt curlen, ohne die Suite.
|
||||||
|
#
|
||||||
|
# AUF DEM PROXMOX-HOST (nicht im Container), als root:
|
||||||
|
# bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/install.sh)
|
||||||
|
#
|
||||||
|
# Direkt, ohne Dialog:
|
||||||
|
# … install.sh openbureau
|
||||||
|
# … install.sh nextcloud [disk_gb] [ram_mb]
|
||||||
|
# … install.sh empty [name] [disk_gb] [ram_mb]
|
||||||
|
# … install.sh git <repo-url> [name] [disk_gb] [ram_mb]
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RAW="https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main"
|
||||||
|
|
||||||
|
say() { echo -e "\n\033[1;36m▸ $*\033[0m"; }
|
||||||
|
die() { echo -e "\033[1;31m✗ $*\033[0m" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Bitte als root auf dem Proxmox-Host ausführen."
|
||||||
|
command -v pct >/dev/null || die "pct nicht gefunden — läuft das wirklich auf Proxmox VE?"
|
||||||
|
|
||||||
|
# Jeder Dienst = ein eigenständiges Skript. Die Suite ruft sie nur auf.
|
||||||
|
run_service() {
|
||||||
|
local svc="$1"; shift || true
|
||||||
|
case "$svc" in
|
||||||
|
openbureau) say "OPENBUREAU — Website + CMS…"; bash <(curl -fsSL "$RAW/cms/proxmox/create-openbureau-lxc.sh") ;;
|
||||||
|
nextcloud) say "Nextcloud…"; bash <(curl -fsSL "$RAW/proxmox/nextcloud-lxc.sh") "$@" ;;
|
||||||
|
empty) say "Leerer Docker-LXC…"; bash <(curl -fsSL "$RAW/proxmox/empty-lxc.sh") "$@" ;;
|
||||||
|
git) say "Git-Compose-Dienst…"; bash <(curl -fsSL "$RAW/proxmox/git-compose-lxc.sh") "$@" ;;
|
||||||
|
*) die "Unbekannter Dienst: $svc" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stufe 1: Vorhaben. schlüssel | Beschreibung | Liste der Dienste
|
||||||
|
# (Sonderfall "custom" → Einzelauswahl, siehe service_checklist.)
|
||||||
|
PROFILES=(
|
||||||
|
"buero|Komplettes Büro einrichten — Website/CMS + Nextcloud|openbureau nextcloud"
|
||||||
|
"cloud|Office 365 + Synology ersetzen — nur Nextcloud|nextcloud"
|
||||||
|
"web|Nur die öffentliche Website + CMS|openbureau"
|
||||||
|
"custom|Einzeln auswählen … (für Fortgeschrittene)|custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stufe 2 (custom): einzelne Dienste. schlüssel | Beschreibung
|
||||||
|
SERVICES=(
|
||||||
|
"openbureau|OPENBUREAU — Website + CMS (Hugo + Supabase)"
|
||||||
|
"nextcloud|Nextcloud — Dateien, Kalender, Kontakte, Office (ersetzt 365/Synology)"
|
||||||
|
"empty|Leerer Docker-LXC — Gerüst für eigene Dienste"
|
||||||
|
"git|Git-Compose-Dienst — eigenes Repo (z. B. RAPPORT / DOSSIER)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Holt für die interaktive Auswahl die Zusatzangaben (Repo-URL, Name) nach.
|
||||||
|
run_from_menu() {
|
||||||
|
case "$1" in
|
||||||
|
git)
|
||||||
|
local repo name
|
||||||
|
read -rp " Repo-URL (z. B. git.kgva.ch/karim/RAPPORT-SERVER.git): " repo
|
||||||
|
[ -n "$repo" ] || { echo " übersprungen (keine URL)."; return 0; }
|
||||||
|
read -rp " Name [auto]: " name
|
||||||
|
run_service git "$repo" "$name" ;;
|
||||||
|
empty)
|
||||||
|
local name
|
||||||
|
read -rp " Name des Containers [docker]: " name
|
||||||
|
run_service empty "${name:-docker}" ;;
|
||||||
|
*) run_service "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stufe 2: Einzelauswahl der Dienste (Checkliste).
|
||||||
|
service_checklist() {
|
||||||
|
local choices=()
|
||||||
|
if command -v whiptail >/dev/null && [ -t 0 ]; then
|
||||||
|
local items=()
|
||||||
|
for s in "${SERVICES[@]}"; do items+=("${s%%|*}" "${s#*|}" OFF); done
|
||||||
|
local sel
|
||||||
|
sel="$(whiptail --title "OPENBUREAU — Dienste auswählen" \
|
||||||
|
--checklist "Mit der Leertaste auswählen, Enter bestätigt:" 20 78 ${#SERVICES[@]} \
|
||||||
|
"${items[@]}" 3>&1 1>&2 2>&3)" || { echo "Abgebrochen."; exit 0; }
|
||||||
|
eval "choices=($sel)" # whiptail liefert die Tags in Anführungszeichen
|
||||||
|
else
|
||||||
|
echo "Welche Dienste installieren? (Nummern mit Komma/Leerzeichen, Enter = nichts)"
|
||||||
|
local i=1
|
||||||
|
for s in "${SERVICES[@]}"; do printf " %d) %s\n" "$i" "${s#*|}"; i=$((i+1)); done
|
||||||
|
read -rp "Auswahl: " line
|
||||||
|
for n in ${line//,/ }; do
|
||||||
|
[ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "${#SERVICES[@]}" ] && choices+=("${SERVICES[$((n-1))]%%|*}")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
[ "${#choices[@]}" -gt 0 ] || { echo "Nichts ausgewählt."; exit 0; }
|
||||||
|
for c in "${choices[@]}"; do run_from_menu "$c"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stufe 1: Vorhaben wählen, dann das passende Bündel installieren.
|
||||||
|
choose_profile() {
|
||||||
|
local key=""
|
||||||
|
if command -v whiptail >/dev/null && [ -t 0 ]; then
|
||||||
|
local items=() first=ON
|
||||||
|
for p in "${PROFILES[@]}"; do items+=("${p%%|*}" "$(echo "$p" | cut -d'|' -f2)" "$first"); first=OFF; done
|
||||||
|
key="$(whiptail --title "OPENBUREAU — Was hast du vor?" \
|
||||||
|
--radiolist "Vorhaben wählen (Leertaste markiert, Enter bestätigt):" 20 78 ${#PROFILES[@]} \
|
||||||
|
"${items[@]}" 3>&1 1>&2 2>&3)" || { echo "Abgebrochen."; exit 0; }
|
||||||
|
else
|
||||||
|
echo "Was hast du vor? (eine Nummer)"
|
||||||
|
local i=1
|
||||||
|
for p in "${PROFILES[@]}"; do printf " %d) %s\n" "$i" "$(echo "$p" | cut -d'|' -f2)"; i=$((i+1)); done
|
||||||
|
read -rp "Auswahl [1]: " n; n="${n:-1}"
|
||||||
|
[ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "${#PROFILES[@]}" ] || die "Ungültige Auswahl."
|
||||||
|
key="${PROFILES[$((n-1))]%%|*}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$key" = "custom" ]; then
|
||||||
|
service_checklist
|
||||||
|
else
|
||||||
|
local svcs=""
|
||||||
|
for p in "${PROFILES[@]}"; do [ "${p%%|*}" = "$key" ] && svcs="${p##*|}"; done
|
||||||
|
say "Vorhaben '$key': installiere -> $svcs"
|
||||||
|
for s in $svcs; do run_service "$s"; done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
################################# Einstieg #################################
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
run_service "$@" # direkt, ohne Dialog
|
||||||
|
else
|
||||||
|
choose_profile # geführter Dialog: erst Vorhaben, dann Dienste
|
||||||
|
fi
|
||||||
|
say "Fertig. Alle ausgewählten Dienste sind durch."
|
||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Nextcloud (All-in-One) als eigener LXC für Proxmox VE.
|
||||||
|
# Dateien, Kalender, Kontakte, Office in einem verwalteten Container —
|
||||||
|
# ersetzt OneDrive / Synology-Drive + Office 365.
|
||||||
|
#
|
||||||
|
# AUF DEM PROXMOX-HOST, als root:
|
||||||
|
# bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/nextcloud-lxc.sh) [disk_gb] [ram_mb]
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
############################# gemeinsamer Kopf #############################
|
||||||
|
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
|
||||||
|
ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}"
|
||||||
|
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
|
||||||
|
BRIDGE="${BRIDGE:-vmbr0}"
|
||||||
|
|
||||||
|
say() { echo -e "\n\033[1;36m▸ $*\033[0m"; }
|
||||||
|
ok() { echo -e "\033[1;32m✓ $*\033[0m"; }
|
||||||
|
warn() { echo -e "\033[1;33m! $*\033[0m" >&2; }
|
||||||
|
die() { echo -e "\033[1;31m✗ $*\033[0m" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Bitte als root auf dem Proxmox-Host ausführen."
|
||||||
|
command -v pct >/dev/null || die "pct nicht gefunden — läuft das wirklich auf Proxmox VE?"
|
||||||
|
|
||||||
|
ensure_template() {
|
||||||
|
[ -n "${TEMPLATE_REF:-}" ] && return 0
|
||||||
|
pveam update >/dev/null 2>&1 || true
|
||||||
|
local tpl
|
||||||
|
tpl="$(pveam available --section system | awk '/debian-12-standard/{print $2}' | sort -V | tail -1)"
|
||||||
|
[ -n "$tpl" ] || die "Kein debian-12-Template verfügbar."
|
||||||
|
pveam list "$TEMPLATE_STORAGE" | grep -q "$tpl" || { say "Lade Template $tpl…"; pveam download "$TEMPLATE_STORAGE" "$tpl" >/dev/null; }
|
||||||
|
TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${tpl}"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_lxc() {
|
||||||
|
local name="$1" disk="$2" ram="$3" cores="${4:-2}" ctid
|
||||||
|
ensure_template
|
||||||
|
ctid="$(pvesh get /cluster/nextid)"
|
||||||
|
local args=(
|
||||||
|
"$ctid" "$TEMPLATE_REF" --hostname "$name"
|
||||||
|
--cores "$cores" --memory "$ram" --swap 1024
|
||||||
|
--rootfs "${ROOTFS_STORAGE}:${disk}"
|
||||||
|
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp"
|
||||||
|
--unprivileged 1 --features "nesting=1,keyctl=1" --onboot 1
|
||||||
|
)
|
||||||
|
[ -f "$SSH_PUBKEY_FILE" ] && args+=(--ssh-public-keys "$SSH_PUBKEY_FILE")
|
||||||
|
say "Erstelle LXC $ctid ($name) — ${cores} Kerne, ${ram} MB RAM, ${disk} GB…"
|
||||||
|
pct create "${args[@]}" >/dev/null
|
||||||
|
pct start "$ctid"; sleep 5
|
||||||
|
CTID="$ctid"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_docker() {
|
||||||
|
say "Installiere Docker in $1…"
|
||||||
|
pct exec "$1" -- bash -euo pipefail -c '
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq ca-certificates curl git openssl >/dev/null
|
||||||
|
curl -fsSL https://get.docker.com | sh >/dev/null
|
||||||
|
systemctl enable --now docker
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
ip_of() { pct exec "$1" -- hostname -I 2>/dev/null | awk '{print $1}'; }
|
||||||
|
|
||||||
|
################################# Dienst #################################
|
||||||
|
# Grosszügig dimensioniert — hier leben die Bürodaten.
|
||||||
|
DISK="${1:-500}"; RAM="${2:-8192}"
|
||||||
|
|
||||||
|
create_lxc nextcloud "$DISK" "$RAM"
|
||||||
|
install_docker "$CTID"
|
||||||
|
|
||||||
|
say "Starte Nextcloud All-in-One (mastercontainer)…"
|
||||||
|
pct exec "$CTID" -- bash -euo pipefail -c '
|
||||||
|
docker run -d --name nextcloud-aio-mastercontainer --restart always \
|
||||||
|
-p 8080:8080 -e APACHE_PORT=11000 \
|
||||||
|
-v nextcloud_aio_mastercontainer:/mnt/docker-aio-config \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
||||||
|
nextcloud/all-in-one:latest >/dev/null
|
||||||
|
'
|
||||||
|
|
||||||
|
IP="$(ip_of "$CTID")"
|
||||||
|
ok "Nextcloud-LXC $CTID läuft."
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Einrichtung im Browser: https://${IP}:8080
|
||||||
|
Dort Admin-Passwort + Domain setzen — Nextcloud startet dann die übrigen
|
||||||
|
Container (Datenbank, Office, Talk …) selbst.
|
||||||
|
|
||||||
|
Ohne eigene Domain ist das vorerst nur im LAN erreichbar. Für den Zugriff
|
||||||
|
von aussen einen Reverse-Proxy davorsetzen.
|
||||||
|
EOF
|
||||||
+85
-16
@@ -28,9 +28,53 @@
|
|||||||
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
|
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
|
||||||
return d.toLocaleDateString('de-CH');
|
return d.toLocaleDateString('de-CH');
|
||||||
}
|
}
|
||||||
|
// Volles Datum + Uhrzeit (für die Wortmeldungen in der Thread-Ansicht).
|
||||||
|
function fmtFull(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleDateString('de-CH') + ' · ' + d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
const api = (p, opt) => fetch(p, opt).then(async (r) => ({ ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }));
|
const api = (p, opt) => fetch(p, opt).then(async (r) => ({ ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }));
|
||||||
const authHdr = () => ({ Authorization: 'Bearer ' + token });
|
const authHdr = () => ({ Authorization: 'Bearer ' + token });
|
||||||
|
|
||||||
|
// ── UX-Helfer ─────────────────────────────────────────────────────────────
|
||||||
|
// Deterministische, dezente Avatar-Farbe aus dem Namen (wenn kein Bild).
|
||||||
|
function hashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||||
|
function paintAvatar(av, name, avatarUrl) {
|
||||||
|
if (avatarUrl) { av.style.backgroundImage = 'url(' + avatarUrl + ')'; av.textContent = ''; return; }
|
||||||
|
const hue = hashHue(name || '?');
|
||||||
|
av.style.background = 'hsl(' + hue + ' 36% 82%)';
|
||||||
|
av.style.color = 'hsl(' + hue + ' 30% 28%)';
|
||||||
|
av.textContent = (name || '?').trim().slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
// URLs im Text klickbar machen — sicher: nur Text- + Anchor-Knoten, kein innerHTML.
|
||||||
|
function linkify(container, text) {
|
||||||
|
const re = /(https?:\/\/[^\s<]+)/g; let last = 0, m;
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
if (m.index > last) container.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||||||
|
const a = el('a', 'dialog-link', m[0].replace(/[.,;:)]+$/, ''));
|
||||||
|
a.href = a.textContent; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||||
|
container.appendChild(a);
|
||||||
|
last = m.index + a.textContent.length;
|
||||||
|
}
|
||||||
|
if (last < text.length) container.appendChild(document.createTextNode(text.slice(last)));
|
||||||
|
}
|
||||||
|
// Dezente Lade-Platzhalter (schimmernd).
|
||||||
|
function skeleton(container, n) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const w = el('div', 'dialog-skel');
|
||||||
|
for (let i = 0; i < (n || 3); i++) w.appendChild(el('div', 'dialog-skel-line'));
|
||||||
|
container.appendChild(w);
|
||||||
|
}
|
||||||
|
// ⌘/Ctrl + Enter sendet ab.
|
||||||
|
function sendOnCmdEnter(ta, fn) {
|
||||||
|
ta.addEventListener('keydown', (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); fn(); } });
|
||||||
|
}
|
||||||
|
// Textarea wächst mit dem Inhalt mit (bis zu einer Grenze).
|
||||||
|
function autoGrow(ta, max) {
|
||||||
|
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, max || 320) + 'px'; };
|
||||||
|
ta.addEventListener('input', fit); requestAnimationFrame(fit);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth ────────────────────────────────────────────────────────────────
|
// ── Auth ────────────────────────────────────────────────────────────────
|
||||||
async function doLogin(email, password, after) {
|
async function doLogin(email, password, after) {
|
||||||
const r = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
|
const r = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
|
||||||
@@ -79,7 +123,11 @@
|
|||||||
right.appendChild(el('h2', 'dialog-title', 'Foren'));
|
right.appendChild(el('h2', 'dialog-title', 'Foren'));
|
||||||
grid.append(left, right); root.appendChild(grid);
|
grid.append(left, right); root.appendChild(grid);
|
||||||
|
|
||||||
|
const recentSkel = el('div'); left.appendChild(recentSkel); skeleton(recentSkel, 5);
|
||||||
|
const forumSkel = el('div'); right.appendChild(forumSkel); skeleton(forumSkel, 4);
|
||||||
|
|
||||||
api('/api/recent?limit=15').then((r) => {
|
api('/api/recent?limit=15').then((r) => {
|
||||||
|
recentSkel.remove();
|
||||||
const rows = r.body || [];
|
const rows = r.body || [];
|
||||||
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
|
if (!rows.length) { left.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen.')); return; }
|
||||||
const list = el('div', 'dialog-recent-list');
|
const list = el('div', 'dialog-recent-list');
|
||||||
@@ -96,6 +144,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
api('/api/forums').then((r) => {
|
api('/api/forums').then((r) => {
|
||||||
|
forumSkel.remove();
|
||||||
const rows = r.body || [];
|
const rows = r.body || [];
|
||||||
const list = el('div', 'dialog-forum-list');
|
const list = el('div', 'dialog-forum-list');
|
||||||
rows.forEach((f) => {
|
rows.forEach((f) => {
|
||||||
@@ -115,8 +164,10 @@
|
|||||||
// ── Forum-Ansicht: Threads + neuer Thread ─────────────────────────────────
|
// ── Forum-Ansicht: Threads + neuer Thread ─────────────────────────────────
|
||||||
function renderForum(slug) {
|
function renderForum(slug) {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
if (ctxEl) { ctxEl.innerHTML = ''; const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; ctxEl.appendChild(b); }
|
if (ctxEl) { ctxEl.innerHTML = ''; const c = el('nav', 'dialog-crumb'); const b = el('a', null, '← Dialoge'); b.href = '/dialog/'; c.appendChild(b); ctxEl.appendChild(c); }
|
||||||
|
const loadHost = el('div'); skeleton(loadHost, 4); root.appendChild(loadHost);
|
||||||
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
|
api('/api/forums/' + encodeURIComponent(slug)).then((r) => {
|
||||||
|
loadHost.remove();
|
||||||
if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; }
|
if (!r.ok) { root.appendChild(el('p', 'dialog-empty', 'Forum nicht gefunden.')); return; }
|
||||||
const { forum, threads } = r.body;
|
const { forum, threads } = r.body;
|
||||||
const head = el('div', 'dialog-forum-head');
|
const head = el('div', 'dialog-forum-head');
|
||||||
@@ -171,6 +222,8 @@
|
|||||||
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
|
if (!r.ok) { alert(r.body.error || 'Konnte Thread nicht anlegen'); return; }
|
||||||
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key);
|
location.href = '/dialog/?thread=' + encodeURIComponent(r.body.key);
|
||||||
};
|
};
|
||||||
|
autoGrow(ta); sendOnCmdEnter(ta, () => send.click());
|
||||||
|
ti.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ta.focus(); } });
|
||||||
row.append(send, cancel); box.append(ti, ta, row); ti.focus();
|
row.append(send, cancel); box.append(ti, ta, row); ti.focus();
|
||||||
}
|
}
|
||||||
paint();
|
paint();
|
||||||
@@ -184,6 +237,7 @@
|
|||||||
const list = el('div', 'dialog-list');
|
const list = el('div', 'dialog-list');
|
||||||
const composer = el('div', 'dialog-composer');
|
const composer = el('div', 'dialog-composer');
|
||||||
root.append(title, modbar, list, composer);
|
root.append(title, modbar, list, composer);
|
||||||
|
skeleton(list, 3);
|
||||||
let replyTo = null, textarea = null, locked = false;
|
let replyTo = null, textarea = null, locked = false;
|
||||||
|
|
||||||
// Kontext: Rücklink + Titel.
|
// Kontext: Rücklink + Titel.
|
||||||
@@ -192,9 +246,19 @@
|
|||||||
const m = r.body; title.textContent = m.title || 'Dialog'; locked = m.locked;
|
const m = r.body; title.textContent = m.title || 'Dialog'; locked = m.locked;
|
||||||
if (ctxEl) {
|
if (ctxEl) {
|
||||||
ctxEl.innerHTML = '';
|
ctxEl.innerHTML = '';
|
||||||
const back = el('a', null, m.kind === 'library' ? '← zum Beitrag' : (m.forum ? '← ' + m.forum.name : '← Dialoge'));
|
if (m.kind === 'library') {
|
||||||
back.href = m.kind === 'library' ? m.url : (m.forum ? '/dialog/?forum=' + encodeURIComponent(m.forum.slug) : '/dialog/');
|
const back = el('a', null, '← zum Beitrag'); back.href = m.url; ctxEl.appendChild(back);
|
||||||
ctxEl.appendChild(back);
|
} else {
|
||||||
|
// Breadcrumb-Navigation oben: Dialoge › Forum (beide anklickbar).
|
||||||
|
const crumb = el('nav', 'dialog-crumb');
|
||||||
|
const a0 = el('a', null, 'Dialoge'); a0.href = '/dialog/'; crumb.appendChild(a0);
|
||||||
|
if (m.forum) {
|
||||||
|
crumb.appendChild(el('span', 'dialog-crumb-sep', '›'));
|
||||||
|
const a1 = el('a', null, m.forum.name); a1.href = '/dialog/?forum=' + encodeURIComponent(m.forum.slug);
|
||||||
|
crumb.appendChild(a1);
|
||||||
|
}
|
||||||
|
ctxEl.appendChild(crumb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
renderModbar(); renderComposer();
|
renderModbar(); renderComposer();
|
||||||
});
|
});
|
||||||
@@ -230,23 +294,26 @@
|
|||||||
const names = {}; data.forEach((c) => { names[c.id] = c.author_name; });
|
const names = {}; data.forEach((c) => { names[c.id] = c.author_name; });
|
||||||
if (!data.length) { list.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen — beginne den Dialog.')); return; }
|
if (!data.length) { list.appendChild(el('p', 'dialog-empty', 'Noch keine Wortmeldungen — beginne den Dialog.')); return; }
|
||||||
data.forEach((c) => {
|
data.forEach((c) => {
|
||||||
const card = el('article', 'dialog-card');
|
// Nüchtern: Avatar · Name (+ Position) · darunter Datum/Uhrzeit · Text.
|
||||||
const head = el('header', 'dialog-card-head');
|
const post = el('article', 'dialog-post');
|
||||||
|
const head = el('header', 'dialog-post-head');
|
||||||
const av = el('span', 'dialog-avatar');
|
const av = el('span', 'dialog-avatar');
|
||||||
if (c.author_avatar) av.style.backgroundImage = 'url(' + c.author_avatar + ')';
|
paintAvatar(av, c.author_name, c.author_avatar);
|
||||||
else av.textContent = (c.author_name || '?').slice(0, 1).toUpperCase();
|
const ident = el('div', 'dialog-ident');
|
||||||
const meta = el('div', 'dialog-meta');
|
const nameline = el('div', 'dialog-nameline');
|
||||||
meta.append(el('span', 'dialog-name', c.author_name || 'Unbekannt'), el('time', 'dialog-time', fmt(c.created_at)));
|
nameline.appendChild(el('span', 'dialog-name', c.author_name || 'Unbekannt'));
|
||||||
if (c.parent_id && names[c.parent_id]) meta.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
|
if (c.author_role) nameline.appendChild(el('span', 'dialog-pos', c.author_role));
|
||||||
head.append(av, meta); card.appendChild(head);
|
if (c.parent_id && names[c.parent_id]) nameline.appendChild(el('span', 'dialog-replyto', '↳ ' + names[c.parent_id]));
|
||||||
card.appendChild(el('div', 'dialog-body', c.body));
|
ident.append(nameline, el('time', 'dialog-time', fmtFull(c.created_at)));
|
||||||
|
head.append(av, ident); post.appendChild(head);
|
||||||
|
const bodyEl = el('div', 'dialog-body'); linkify(bodyEl, c.body || ''); post.appendChild(bodyEl);
|
||||||
if (token && !c.deleted) {
|
if (token && !c.deleted) {
|
||||||
const actions = el('div', 'dialog-actions');
|
const actions = el('div', 'dialog-actions');
|
||||||
if (!locked) { const rep = el('button', null, 'Antworten'); rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); }; actions.appendChild(rep); }
|
if (!locked) { const rep = el('button', null, 'Antworten'); rep.onclick = () => { replyTo = { id: c.id, name: c.author_name }; renderComposer(); if (textarea) textarea.focus(); }; actions.appendChild(rep); }
|
||||||
const del = el('button', null, 'Löschen'); del.onclick = () => remove(c.id); actions.appendChild(del);
|
const del = el('button', null, 'Löschen'); del.onclick = () => remove(c.id); actions.appendChild(del);
|
||||||
card.appendChild(actions);
|
post.appendChild(actions);
|
||||||
}
|
}
|
||||||
list.appendChild(card);
|
list.appendChild(post);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,10 +333,12 @@
|
|||||||
composer.appendChild(r);
|
composer.appendChild(r);
|
||||||
}
|
}
|
||||||
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …';
|
textarea = el('textarea', 'dialog-textarea'); textarea.placeholder = locked ? 'Thread gesperrt — nur Moderation …' : 'Deine Wortmeldung …';
|
||||||
|
autoGrow(textarea); sendOnCmdEnter(textarea, submit);
|
||||||
const row = el('div', 'dialog-row');
|
const row = el('div', 'dialog-row');
|
||||||
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
|
const send = el('button', 'dialog-send', 'Senden'); send.onclick = submit;
|
||||||
|
const hint = el('span', 'dialog-hint', '⌘ + ↵');
|
||||||
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
|
const out = el('button', 'dialog-logout', 'Abmelden' + (myName ? ' · ' + myName : '')); out.onclick = () => logout(() => { renderModbar(); renderComposer(); load(); });
|
||||||
row.append(send, out); composer.append(textarea, row);
|
row.append(send, hint, el('span', 'dialog-spacer'), out); composer.append(textarea, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/* OPENBUREAU — Versionsverlauf eines Beitrags direkt auf der Seite.
|
||||||
|
Die Pill „Version vom …" öffnet die Liste der Fassungen (aus /api/history).
|
||||||
|
Auswahl zeigt standardmäßig den Diff (rot/grün, wie auf GitHub) aus
|
||||||
|
/api/history/diff; ein Toggle zeigt die ganze alte Fassung (/api/history/version).
|
||||||
|
Alles auf openbureau — keine externen Git-Links. */
|
||||||
|
(function () {
|
||||||
|
var trigger = document.getElementById('version-badge');
|
||||||
|
if (!trigger) return;
|
||||||
|
var path = trigger.dataset.path;
|
||||||
|
var content = document.querySelector('.single-content');
|
||||||
|
var article = document.querySelector('article.single');
|
||||||
|
if (!path || !content || !article) return;
|
||||||
|
|
||||||
|
var originalHTML = content.innerHTML;
|
||||||
|
var panel = null, banner = null;
|
||||||
|
|
||||||
|
function api(p) { return fetch(p).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); }
|
||||||
|
function fmt(d) { try { return new Date(d).toLocaleDateString('de-CH'); } catch (e) { return d || ''; } }
|
||||||
|
function q(p, rev) { return p + '?path=' + encodeURIComponent(path) + '&rev=' + encodeURIComponent(rev); }
|
||||||
|
function toTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); }
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
if (panel) { panel.remove(); panel = null; }
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
function restore() {
|
||||||
|
content.innerHTML = originalHTML;
|
||||||
|
if (banner) { banner.remove(); banner = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified-Diff → farbige Zeilen (rot gelöscht, grün neu, grau Hunk/Kontext).
|
||||||
|
function renderDiff(diff) {
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'diff';
|
||||||
|
diff.split('\n').forEach(function (ln) {
|
||||||
|
if (/^(diff --git|index |new file|deleted file|similarity |rename |--- |\+\+\+ )/.test(ln)) return;
|
||||||
|
var cls = 'd-ctx';
|
||||||
|
if (/^@@/.test(ln)) cls = 'd-hunk';
|
||||||
|
else if (ln.charAt(0) === '+') cls = 'd-add';
|
||||||
|
else if (ln.charAt(0) === '-') cls = 'd-del';
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'diff-line ' + cls;
|
||||||
|
row.textContent = ln || ' ';
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBanner(v, mode) {
|
||||||
|
if (banner) banner.remove();
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.className = 'version-banner';
|
||||||
|
banner.append((mode === 'diff' ? 'Änderungen' : 'Fassung') + ' vom ' + fmt(v.date) + ' · Version ' + v.short + ' ');
|
||||||
|
var toggle = document.createElement('button');
|
||||||
|
toggle.type = 'button'; toggle.className = 'version-toggle';
|
||||||
|
toggle.textContent = mode === 'diff' ? 'ganze Fassung anzeigen' : 'Änderungen anzeigen';
|
||||||
|
toggle.addEventListener('click', function () { mode === 'diff' ? loadVersion(v) : loadDiff(v); });
|
||||||
|
var back = document.createElement('button');
|
||||||
|
back.type = 'button'; back.className = 'version-back'; back.textContent = '→ zur aktuellen Fassung';
|
||||||
|
back.addEventListener('click', restore);
|
||||||
|
banner.append(toggle, ' ', back);
|
||||||
|
article.insertBefore(banner, article.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDiff(v) {
|
||||||
|
closePanel();
|
||||||
|
content.innerHTML = '<p class="version-loading">Lade Änderungen …</p>';
|
||||||
|
api(q('/api/history/diff', v.rev)).then(function (data) {
|
||||||
|
if (!data || data.diff == null) { restore(); return; }
|
||||||
|
content.innerHTML = '';
|
||||||
|
if (!data.diff.trim()) content.innerHTML = '<p class="version-empty">Keine Änderungen an dieser Datei in dieser Fassung.</p>';
|
||||||
|
else content.appendChild(renderDiff(data.diff));
|
||||||
|
showBanner(v, 'diff');
|
||||||
|
toTop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadVersion(v) {
|
||||||
|
closePanel();
|
||||||
|
content.innerHTML = '<p class="version-loading">Lade Fassung …</p>';
|
||||||
|
api(q('/api/history/version', v.rev)).then(function (data) {
|
||||||
|
if (!data || !data.html) { restore(); return; }
|
||||||
|
content.innerHTML = data.html;
|
||||||
|
showBanner(v, 'full');
|
||||||
|
toTop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
api('/api/history?path=' + encodeURIComponent(path)).then(function (list) {
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.className = 'version-panel';
|
||||||
|
if (!list || !list.length) {
|
||||||
|
panel.innerHTML = '<p class="version-empty">Kein Verlauf verfügbar.</p>';
|
||||||
|
} else {
|
||||||
|
var ol = document.createElement('ol');
|
||||||
|
ol.className = 'version-list';
|
||||||
|
list.forEach(function (v, i) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
var date = document.createElement('span'); date.className = 'v-date'; date.textContent = fmt(v.date);
|
||||||
|
var subj = document.createElement('span'); subj.className = 'v-subject'; subj.textContent = v.subject || '';
|
||||||
|
var hash = document.createElement('span'); hash.className = 'v-hash'; hash.textContent = v.short + (i === 0 ? ' · aktuell' : '');
|
||||||
|
b.append(date, subj, hash);
|
||||||
|
if (i === 0) b.addEventListener('click', function () { restore(); closePanel(); });
|
||||||
|
else b.addEventListener('click', function () { loadDiff(v); });
|
||||||
|
li.appendChild(b);
|
||||||
|
ol.appendChild(li);
|
||||||
|
});
|
||||||
|
panel.appendChild(ol);
|
||||||
|
}
|
||||||
|
var host = trigger.closest('.article-versions') || trigger;
|
||||||
|
host.parentNode.insertBefore(panel, host.nextSibling);
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function () { panel ? closePanel() : openPanel(); });
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user