v2-Neuerstellung
Some checks failed
release-tag / release-image (push) Failing after 1m48s

This commit is contained in:
2026-05-18 11:25:31 +02:00
parent a38c883450
commit b7edfdd544
15 changed files with 836 additions and 600 deletions

64
.gitea/workflows/v2.yml Normal file
View File

@@ -0,0 +1,64 @@
name: release-tag
on:
push:
branches:
- 'v2'
jobs:
release-image:
runs-on: ubuntu-fast
env:
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
DOCKER_LATEST: v2
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with: # replace it with your local IP
config-inline: |
[registry."${{ vars.DOCKER_REGISTRY }}"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
- name: Build and push StarCitizen
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
build-args: |
CONTENT_REPO=https://git.send.nrw/b1tsblog/sccontent.git
CONTENT_REF=main
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:sc-${{ env.DOCKER_LATEST }}

View File

@@ -1,7 +1,7 @@
############################ ############################
# 1) GoBuild # 1) GoBuild
############################ ############################
FROM golang:1.25 AS build FROM golang:1.26 AS build
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./

View File

@@ -1,25 +1,39 @@
# b1tsblog # B1TS Blog optimiert
## B1tsBlog Features:
- Anpassung Datenschutzerklärung - Artikel und Pages aus `.md`, `.markdown`, `.html`, `.htm`
- Windows Server 2025 Domänen-Controller legt Netzwerkprofil auf Öffentlich fest - JSON-Frontmatter in der ersten Zeile, z. B. `<!-- {"title":"Hallo","date":"2026-05-18","slug":"hallo"} -->`
- TLS-Zertifikat mit SHA3 auf Windows Server 2016 einspielen - SQLite-Datenbank für Aufrufzähler statt JSON-Datei
- Warum personenbezogene Daten nie in die Betreffzeile einer E-Mail gehören - Git-Sync mit anschließendem Reload
- Warum meldet der Client, dass die Default Domain Policy nicht gelesen werden kann - Templates werden beim Reload neu geladen
- Open Source als Innovationsmotor: Warum Unternehmen auf offene Software setzen - `BLOG_TRUSTED_HTML` steuert, ob rohe HTML-Inhalte gerendert oder escaped werden
- Neue Blogserie: Open Source im Unternehmen
- Optimiert für schnelleres Laden mit webp-Inhalten
- docker-swarm-mit-abweichendem-port-einrichten
- Content-Update 2025-05-05
- PHPMyAdmin mit Serverauswahl im Homelab mittels Docker bereitstellen
- Windows Fehler 0x80072F8F - Installation optionaler Features schlägt fehl
- In Ubuntu den Port 53 - DNS - selber nutzen
- Ein eigenes Docker-Image erstellen - so geht's
- Der eigene DNS-Server im Homelab
- Content-Update 2025-05-04
## B1ts Star Citizen Blog ## Start lokal
- Anpassung Datenschutzerklärung ```bash
- Invictus 2955 - Das Jahr der Idris-P? export BLOG_CONTENT_DIR=./content
export BLOG_PAGES_DIR=./pages
export BLOG_STATIC_DIR=./internal/web/static
export BLOG_TEMPLATES_DIR=./internal/web/templates
export BLOG_DATA_DIR=./data
export BLOG_TRUSTED_HTML=true
go mod tidy
go run .
```
## Wichtige ENV-Variablen
- `BLOG_ADDR`, Default `:8080`
- `BLOG_CONTENT_DIR`, Default `/content`
- `BLOG_PAGES_DIR`, Default `/pages`
- `BLOG_STATIC_DIR`, Default `/static`
- `BLOG_TEMPLATES_DIR`, Default `/templates`
- `BLOG_DATA_DIR`, Default `/data`
- `BLOG_DB_PATH`, Default `/data/blog.db`
- `BLOG_TRUSTED_HTML`, Default `true`
- `GIT_ENABLE`, Default `false`
- `GIT_REPO`
- `GIT_BRANCH`, Default `main`
- `GIT_DIR`, Default `/git-temp`
- `GIT_INTERVAL`, Minuten, Default `10`

View File

@@ -1,424 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"git.send.nrw/sendnrw/b1tsblog/internal/article"
)
var TickCatalog []TickEntry
func SaveTickCatalog(filename string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(TickCatalog)
}
func LoadTickCatalog(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
return json.NewDecoder(file).Decode(&TickCatalog)
}
func IncTick(xSlug string) error {
for a, b := range TickCatalog {
if b.Slug == xSlug {
TickCatalog[a].Count = TickCatalog[a].Count + 1
return nil
}
}
newEntry := TickEntry{Slug: xSlug, Count: 1}
TickCatalog = append(TickCatalog, newEntry)
return fmt.Errorf("")
}
func getTick(xSlug string) string {
for _, b := range TickCatalog {
if b.Slug == xSlug {
var n int64 = b.Count
return strconv.FormatInt(n, 10)
}
}
return "0"
}
type TickEntry struct {
Slug string `json:"slug"`
Count int64 `json:"count"`
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func enabled(k string, def bool) bool {
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
if err != nil {
return def
}
return b
}
func cacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
next.ServeHTTP(w, r)
})
}
var (
tplList *template.Template
tplArticle *template.Template
)
var Xarticles []article.Article
func main() {
// Signal-Kanal einrichten
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
// Goroutine, die auf Signale wartet
go func() {
<-stop
fmt.Println("Stop Sign...")
prepareExit()
os.Exit(0)
}()
// --- Verzeichnisse konfigurierbar machen -------------------------
contentDir := getenv("BLOG_CONTENT_DIR", "/content")
staticDir := getenv("BLOG_STATIC_DIR", "/app/internal/web/static")
pagesDir := getenv("BLOG_PAGES_DIR", "/pages")
templatesDir := getenv("BLOG_TEMPLATES_DIR", "/templates")
storeEnabled := enabled("STORE_ENABLE", false)
ticksDir := getenv("BLOG_TICKS_DIR", "/ticks")
gitEnable := enabled("GIT_ENABLE", false)
gitRepo := getenv("GIT_REPO", "null")
gitBranch := getenv("GIT_BRANCH", "main")
gitDir := getenv("GIT_DIR", "/git-temp")
gitInterval := getenv("GIT_INTERVAL", "10")
TickCatalog = nil
if err := LoadTickCatalog(ticksDir + "/ticks.json"); err != nil {
fmt.Println("Fehler beim Laden:", err)
}
fmt.Println("Geladener Katalog:", TickCatalog)
cloneRepo(gitRepo, gitBranch, gitDir)
funcs := template.FuncMap{
"now": time.Now, // jetztZeit bereitstellen
}
// Basislayout zuerst parsen
layout := template.Must(template.New("base").Funcs(funcs).ParseFiles(templatesDir + "/base.html"))
var tplStore *template.Template
if storeEnabled {
tplStore = template.Must(template.New("store").Funcs(funcs).ParseFiles(templatesDir + "/store.html"))
}
// LISTSeite: base + list.html
tplList = template.Must(layout.Clone())
template.Must(tplList.Funcs(funcs).ParseFiles(templatesDir + "/list.html"))
// ARTICLEInstanz
tplArticle = template.Must(layout.Clone())
template.Must(tplArticle.Funcs(funcs).ParseFiles(templatesDir + "/article.html"))
tplPage := template.Must(layout.Clone())
template.Must(tplPage.ParseFiles(templatesDir + "/page.html"))
mux := http.NewServeMux()
articles, err := article.LoadDir(contentDir)
if err != nil {
fmt.Println(err)
}
Xarticles = articles
staticPages, err := article.LoadStatic(pagesDir)
if err != nil {
fmt.Println(err)
} else {
fmt.Println(staticPages)
}
// Handler für /
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
/* */
for a, b := range Xarticles {
Xarticles[a].Counter = getTick(b.Slug)
}
/* */
if err := tplList.ExecuteTemplate(w, "layout", article.ListPage{
Title: "Startseite",
Description: "Alle Artikel im Überblick",
Articles: Xarticles,
}); err != nil {
http.Error(w, err.Error(), 500)
}
})
mux.HandleFunc("/post/", func(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/post/")
for _, a := range Xarticles {
if a.Slug == slug {
IncTick(slug)
t := getTick(slug)
a.Counter = t
if err := tplArticle.ExecuteTemplate(w, "layout", a); err != nil {
http.Error(w, err.Error(), 500)
}
return
}
}
http.NotFound(w, r)
})
mux.HandleFunc("/page/", func(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/page/")
p, ok := staticPages[slug]
if !ok {
http.NotFound(w, r)
return
}
// "layout" kommt aus deinem TemplatePool (list/article nutzen es ja auch)
if err := tplPage.ExecuteTemplate(w, "page", p); err != nil {
http.Error(w, err.Error(), 500)
}
})
mux.Handle("/static/", cacheControl(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))))
mux.HandleFunc("/store", func(w http.ResponseWriter, r *http.Request) {
if storeEnabled {
if err := tplStore.ExecuteTemplate(w, "store", nil); err != nil {
http.Error(w, err.Error(), 500)
}
} else {
http.NotFound(w, r)
}
})
if gitEnable {
xMinute, _ := strconv.Atoi(gitInterval)
xDuration := time.Duration(xMinute) * time.Minute
go startAutoClone(gitRepo, gitBranch, gitDir, xDuration)
}
StopServer(http.ListenAndServe(":8080", mux))
}
func prepareExit() {
fmt.Println("~", "Running exit tasks...")
if err := SaveTickCatalog(getenv("BLOG_TICKS_DIR", "/ticks") + "/ticks.json"); err != nil {
fmt.Println("Fehler beim Speichern:", err)
}
fmt.Println("Geladener Katalog:", TickCatalog)
fmt.Println("~", "Exit completed.")
}
func StopServer(e error) {
fmt.Println("~", "Stopping server...")
prepareExit()
fmt.Println("~", "Server stopped!")
}
func cloneRepo(repoURL, branch, dir string) {
fmt.Printf("Starte Klonvorgang für Branch '%s'...\n", branch)
// Verzeichnis löschen
if err := os.RemoveAll(dir); err != nil {
fmt.Println("Fehler beim Löschen des Verzeichnisses:", err)
return
}
// Git-Clone mit Branch
cmd := exec.Command("git", "clone", "--branch", branch, "--single-branch", repoURL, dir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Println("Fehler beim Klonen:", err)
} else {
fmt.Println("Repo erfolgreich geklont.")
}
contentDir := getenv("BLOG_CONTENT_DIR", "/content")
err := os.RemoveAll("/content")
if err != nil {
fmt.Println(err, "/content")
}
err = os.RemoveAll("/static")
if err != nil {
fmt.Println(err, "/static")
}
err = os.RemoveAll("/pages")
if err != nil {
fmt.Println(err, "/pages")
}
err = os.RemoveAll("/templates")
if err != nil {
fmt.Println(err, "/templates")
}
if err := os.MkdirAll("/content", 0755); err != nil {
fmt.Println("Fehler beim Erstellen des Zielordners:", err)
return
}
if err := os.MkdirAll("/static", 0755); err != nil {
fmt.Println("Fehler beim Erstellen des Zielordners:", err)
return
}
if err := os.MkdirAll("/pages", 0755); err != nil {
fmt.Println("Fehler beim Erstellen des Zielordners:", err)
return
}
if err := os.MkdirAll("/templates", 0755); err != nil {
fmt.Println("Fehler beim Erstellen des Zielordners:", err)
return
}
if err := copyDirContents("/git-temp/articles", "/content"); err != nil {
fmt.Println("Fehler beim Kopieren:", err)
} else {
fmt.Println("Kopieren abgeschlossen.")
}
if err := copyDirContents("/git-temp/static", "/static"); err != nil {
fmt.Println("Fehler beim Kopieren:", err)
} else {
fmt.Println("Kopieren abgeschlossen.")
}
if err := copyDirContents("/git-temp/pages", "/pages"); err != nil {
fmt.Println("Fehler beim Kopieren:", err)
} else {
fmt.Println("Kopieren abgeschlossen.")
}
if err := copyDirContents("/git-temp/templates", "/templates"); err != nil {
fmt.Println("Fehler beim Kopieren:", err)
} else {
fmt.Println("Kopieren abgeschlossen.")
}
articles, err := article.LoadDir(contentDir)
if err != nil {
fmt.Println(err)
}
Xarticles = articles
}
func copyDirContents(srcDir, destDir string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return fmt.Errorf("Fehler beim Lesen von %s: %w", srcDir, err)
}
for _, entry := range entries {
srcPath := filepath.Join(srcDir, entry.Name())
destPath := filepath.Join(destDir, entry.Name())
info, err := entry.Info()
if err != nil {
return err
}
if info.IsDir() {
if err := os.MkdirAll(destPath, info.Mode()); err != nil {
return fmt.Errorf("Fehler beim Erstellen von Ordner %s: %w", destPath, err)
}
// rekursiv kopieren
if err := copyDirContents(srcPath, destPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, destPath, info); err != nil {
return err
}
}
}
return nil
}
func copyFile(src, dest string, info os.FileInfo) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return os.Chmod(dest, info.Mode())
}
func startAutoClone(repoURL, branch, dir string, interval time.Duration) {
go cloneRepo(repoURL, branch, dir) // sofortiger Start
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
go cloneRepo(repoURL, branch, dir)
}
}

19
go.mod
View File

@@ -1,5 +1,20 @@
module git.send.nrw/sendnrw/b1tsblog module git.send.nrw/sendnrw/b1tsblog
go 1.25.3 go 1.22
require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b require (
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
modernc.org/sqlite v1.34.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)

47
go.sum
View File

@@ -1,2 +1,45 @@
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,8 +1,5 @@
// internal/article/load.go
package article package article
// internal/article/load.go (gekürzt)
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
@@ -19,15 +16,24 @@ import (
"github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/html"
) )
// LoadDir liest alle *.html und *.md unter root/content, type LoaderOptions struct {
// parst FrontMatter (JSONKommentar in der 1. Zeile) und liefert []Article. // TrustedHTML erlaubt rohe .html-Inhalte aus deinem Content-Repository.
func LoadDir(root string) ([]Article, error) { // Achtung: Nur aktivieren, wenn du dem Repository vertraust.
var out []Article TrustedHTML bool
seen := make(map[string]bool) // DuplikatsCheck }
mdRenderer := html.NewRenderer(html.RendererOptions{ type frontMatter struct {
Flags: html.CommonFlags | html.HrefTargetBlank, Title string `json:"title"`
}) Date string `json:"date"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Description string `json:"description"`
Format string `json:"format"`
}
func LoadDir(root string, opts LoaderOptions) ([]Article, error) {
var out []Article
seen := make(map[string]bool)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -38,37 +44,15 @@ func LoadDir(root string) ([]Article, error) {
} }
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
if ext != ".md" && ext != ".html" { if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" {
return nil // uninteressante Datei return nil
} }
raw, err := os.ReadFile(path) meta, body, format, err := readContentFile(path, opts)
if err != nil { if err != nil {
return fmt.Errorf("read %s: %w", path, err) return err
} }
parts := bytes.SplitN(raw, []byte("\n"), 2)
if len(parts) < 2 {
return fmt.Errorf("%s: missing frontmatter", path)
}
headerJSON, err := extractJSONHeader(parts[0])
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
var meta struct {
Title string `json:"title"`
Date string `json:"date"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Description string `json:"description"`
}
if err := json.Unmarshal(headerJSON, &meta); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
// FallbackSlug aus Dateinamen
if meta.Slug == "" { if meta.Slug == "" {
meta.Slug = strings.TrimSuffix(filepath.Base(path), ext) meta.Slug = strings.TrimSuffix(filepath.Base(path), ext)
} }
@@ -77,11 +61,6 @@ func LoadDir(root string) ([]Article, error) {
} }
seen[meta.Slug] = true seen[meta.Slug] = true
body := parts[1]
if ext == ".md" {
body = md.ToHTML(body, nil, mdRenderer) // frischer Parser pro Call
}
date, err := time.Parse("2006-01-02", meta.Date) date, err := time.Parse("2006-01-02", meta.Date)
if err != nil { if err != nil {
return fmt.Errorf("%s: parse date: %w", path, err) return fmt.Errorf("%s: parse date: %w", path, err)
@@ -93,7 +72,9 @@ func LoadDir(root string) ([]Article, error) {
Date: date, Date: date,
Cover: meta.Cover, Cover: meta.Cover,
Description: meta.Description, Description: meta.Description,
Body: template.HTML(body), Body: body,
Format: format,
SourcePath: path,
}) })
return nil return nil
}) })
@@ -105,34 +86,7 @@ func LoadDir(root string) ([]Article, error) {
return out, nil return out, nil
} }
// ---------- NEU ---------- func LoadStatic(dir string, opts LoaderOptions) (map[string]StaticPage, error) {
type StaticPage struct {
Title string
Slug string
Description string
Body template.HTML
}
// helper.go einmal zentral verwenden
func extractJSONHeader(line []byte) ([]byte, error) {
s := strings.TrimSpace(string(line))
s = strings.TrimPrefix(s, "<!--")
// alles bis zum ersten "-->" abschneiden
if idx := strings.Index(s, "-->"); idx != -1 {
s = s[:idx]
}
s = strings.TrimSpace(s)
// optional: falls doch kein JSON, besserer Fehler
if !strings.HasPrefix(s, "{") {
return nil, fmt.Errorf("no JSON frontmatter found")
}
return []byte(s), nil
}
// LoadStatic liest alle .md/.htmlDateien unter dir und liefert sie als Map[slug]StaticPage.
func LoadStatic(dir string) (map[string]StaticPage, error) {
pages := make(map[string]StaticPage) pages := make(map[string]StaticPage)
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
@@ -144,38 +98,17 @@ func LoadStatic(dir string) (map[string]StaticPage, error) {
if e.IsDir() { if e.IsDir() {
continue continue
} }
ext := filepath.Ext(e.Name()) ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".md" && ext != ".html" { if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" {
continue // unbekanntes Format continue
} }
path := filepath.Join(dir, e.Name()) path := filepath.Join(dir, e.Name())
raw, err := os.ReadFile(path) meta, body, format, err := readContentFile(path, opts)
if err != nil { if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err) return nil, err
} }
// FrontMatter (erste Zeile) herauslösen
parts := bytes.SplitN(raw, []byte("\n"), 2)
if len(parts) < 2 {
return nil, fmt.Errorf("%s: missing frontmatter", path)
}
headerJSON, err := extractJSONHeader(parts[0])
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var meta struct {
Title string `json:"title"`
Slug string `json:"slug"`
Description string `json:"description"`
}
if err := json.Unmarshal(headerJSON, &meta); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
// Fallback: Slug aus Dateinamen ableiten, falls im Header leer
if meta.Slug == "" { if meta.Slug == "" {
meta.Slug = strings.TrimSuffix(e.Name(), ext) meta.Slug = strings.TrimSuffix(e.Name(), ext)
} }
@@ -183,17 +116,88 @@ func LoadStatic(dir string) (map[string]StaticPage, error) {
return nil, fmt.Errorf("%s: duplicate slug %q", path, meta.Slug) return nil, fmt.Errorf("%s: duplicate slug %q", path, meta.Slug)
} }
body := parts[1]
if ext == ".md" {
body = md.ToHTML(body, nil, nil) // eigener Parser je Aufruf
}
pages[meta.Slug] = StaticPage{ pages[meta.Slug] = StaticPage{
Title: meta.Title, Title: meta.Title,
Slug: meta.Slug, Slug: meta.Slug,
Description: meta.Description, Description: meta.Description,
Body: template.HTML(body), Body: body,
Format: format,
SourcePath: path,
} }
} }
return pages, nil return pages, nil
} }
func readContentFile(path string, opts LoaderOptions) (frontMatter, template.HTML, Format, error) {
raw, err := os.ReadFile(path)
if err != nil {
return frontMatter{}, "", "", fmt.Errorf("read %s: %w", path, err)
}
parts := bytes.SplitN(raw, []byte("\n"), 2)
if len(parts) < 2 {
return frontMatter{}, "", "", fmt.Errorf("%s: missing front-matter", path)
}
headerJSON, err := extractJSONHeader(parts[0])
if err != nil {
return frontMatter{}, "", "", fmt.Errorf("%s: %w", path, err)
}
var meta frontMatter
if err := json.Unmarshal(headerJSON, &meta); err != nil {
return frontMatter{}, "", "", fmt.Errorf("%s: %w", path, err)
}
ext := strings.ToLower(filepath.Ext(path))
format := detectFormat(ext, meta.Format)
body := renderBody(parts[1], format, opts)
return meta, body, format, nil
}
func detectFormat(ext, explicit string) Format {
switch strings.ToLower(strings.TrimSpace(explicit)) {
case "html":
return FormatHTML
case "md", "markdown":
return FormatMarkdown
}
switch ext {
case ".html", ".htm":
return FormatHTML
default:
return FormatMarkdown
}
}
func renderBody(body []byte, format Format, opts LoaderOptions) template.HTML {
if format == FormatMarkdown {
renderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.HrefTargetBlank,
})
return template.HTML(md.ToHTML(body, nil, renderer))
}
if opts.TrustedHTML {
return template.HTML(body)
}
// Sicherheits-Fallback: HTML-Dateien werden escaped, solange BLOG_TRUSTED_HTML=false ist.
return template.HTML(template.HTMLEscapeString(string(body)))
}
func extractJSONHeader(line []byte) ([]byte, error) {
s := strings.TrimSpace(string(line))
s = strings.TrimPrefix(s, "<!--")
if idx := strings.Index(s, "-->"); idx != -1 {
s = s[:idx]
}
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
return nil, fmt.Errorf("no JSON front-matter found")
}
return []byte(s), nil
}

View File

@@ -1,4 +1,3 @@
// internal/article/model.go
package article package article
import ( import (
@@ -6,6 +5,13 @@ import (
"time" "time"
) )
type Format string
const (
FormatMarkdown Format = "markdown"
FormatHTML Format = "html"
)
type Article struct { type Article struct {
Title string Title string
Slug string Slug string
@@ -13,7 +19,9 @@ type Article struct {
Cover string Cover string
Body template.HTML Body template.HTML
Description string Description string
Counter string Counter int64
Format Format
SourcePath string
} }
type ListPage struct { type ListPage struct {
@@ -21,3 +29,12 @@ type ListPage struct {
Description string Description string
Articles []Article Articles []Article
} }
type StaticPage struct {
Title string
Slug string
Description string
Body template.HTML
Format Format
SourcePath string
}

95
internal/store/sqlite.go Normal file
View File

@@ -0,0 +1,95 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
type SQLiteCounterStore struct {
db *sql.DB
}
func NewSQLiteCounterStore(path string) (*SQLiteCounterStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
return &SQLiteCounterStore{db: db}, nil
}
func (s *SQLiteCounterStore) Init(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS ticks (
slug TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
return err
}
func (s *SQLiteCounterStore) Increment(ctx context.Context, slug string) (int64, error) {
if slug == "" {
return 0, fmt.Errorf("empty slug")
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO ticks(slug, count) VALUES(?, 1)
ON CONFLICT(slug) DO UPDATE SET
count = count + 1,
updated_at = CURRENT_TIMESTAMP;
`, slug)
if err != nil {
return 0, err
}
return s.Get(ctx, slug)
}
func (s *SQLiteCounterStore) Get(ctx context.Context, slug string) (int64, error) {
var count int64
err := s.db.QueryRowContext(ctx, `SELECT count FROM ticks WHERE slug = ?`, slug).Scan(&count)
if err == sql.ErrNoRows {
return 0, nil
}
return count, err
}
func (s *SQLiteCounterStore) GetMany(ctx context.Context, slugs []string) (map[string]int64, error) {
out := make(map[string]int64, len(slugs))
if len(slugs) == 0 {
return out, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(slugs)), ",")
args := make([]any, len(slugs))
for i, slug := range slugs {
args[i] = slug
out[slug] = 0
}
rows, err := s.db.QueryContext(ctx, `SELECT slug, count FROM ticks WHERE slug IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var slug string
var count int64
if err := rows.Scan(&slug, &count); err != nil {
return nil, err
}
out[slug] = count
}
return out, rows.Err()
}
func (s *SQLiteCounterStore) Close() error {
return s.db.Close()
}

11
internal/store/store.go Normal file
View File

@@ -0,0 +1,11 @@
package store
import "context"
type CounterStore interface {
Init(ctx context.Context) error
Increment(ctx context.Context, slug string) (int64, error)
Get(ctx context.Context, slug string) (int64, error)
GetMany(ctx context.Context, slugs []string) (map[string]int64, error)
Close() error
}

View File

@@ -0,0 +1,8 @@
body { font-family: system-ui, sans-serif; margin: 0 auto; max-width: 920px; padding: 2rem; line-height: 1.6; }
header, footer { margin: 2rem 0; }
a { color: inherit; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; }
.card { border: 1px solid #ddd; border-radius: 16px; padding: 1rem; }
.card img, .article img { max-width: 100%; border-radius: 12px; }
.meta { opacity: .7; }
.body pre { overflow: auto; padding: 1rem; background: #f5f5f5; border-radius: 12px; }

View File

@@ -1,20 +1,8 @@
{{ define "title" }}{{ .Title }}  B1tsblog{{ end }} {{ define "content" }}
<article class="article">
{{ define "body" }}
<article>
{{ if .Cover }}
<img class="hero" src="{{ .Cover }}" alt="">
{{ end }}
<p><a class="no-underline" href="/">Zurück zur Übersicht</a></p>
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time> <p class="meta">{{ .Date.Format "02.01.2006" }} · {{ .Counter }} Aufrufe · {{ .Format }}</p>
{{ if .Cover }}<img src="{{ .Cover }}" alt="">{{ end }}
<div class="article-content"> <div class="body">{{ .Body }}</div>
{{ .Body }}
</div>
<p><a class="no-underline" href="/">Zurück zur Übersicht</a></p>
</article> </article>
{{ end }} {{ end }}
{{ define "article" }}{{ template "layout" . }}{{ end }}

View File

@@ -1,19 +1,16 @@
{{ define "body" }} {{ define "content" }}
<ul class="post-list"> <h1>{{ .Title }}</h1>
{{ range .Articles }} <p>{{ .Description }}</p>
<li> <section class="grid">
<a class="card no-underline" href="/post/{{ .Slug }}"> {{ range .Articles }}
{{ if .Cover }} <article class="card">
<img src="{{ .Cover }}" alt=""> {{ if .Cover }}<img src="{{ .Cover }}" alt="">{{ end }}
{{ else }} <h2><a href="/post/{{ .Slug }}">{{ .Title }}</a></h2>
<img src="/static/img/placeholder.webp" alt=""> <p>{{ .Description }}</p>
{{ end }} <small>{{ .Date.Format "02.01.2006" }} · {{ .Counter }} Aufrufe · {{ .Format }}</small>
<div class="card-content"> </article>
<h2>{{ .Title }}</h2> {{ else }}
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time> ({{ .Counter }}) <p>Noch keine Artikel.</p>
</div>
</a>
</li>
{{ end }} {{ end }}
</ul> </section>
{{ end }} {{ end }}

View File

@@ -1,12 +1,6 @@
{{ define "title" }}{{ .Title }}  B1tsblog{{ end }} {{ define "content" }}
<article class="article">
{{ define "body" }} <h1>{{ .Title }}</h1>
<article> <div class="body">{{ .Body }}</div>
<p><a class="no-underline" href="/">Zurück</a></p> </article>
<h1>{{ .Title }}</h1>
<div class="article-content">{{ .Body }}</div>
<p><a class="no-underline" href="/">Zurück</a></p>
</article>
{{ end }} {{ end }}
{{ define "page" }}{{ template "layout" . }}{{ end }}

410
main.go Normal file
View File

@@ -0,0 +1,410 @@
package main
import (
"context"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"git.send.nrw/sendnrw/b1tsblog/internal/article"
"git.send.nrw/sendnrw/b1tsblog/internal/store"
)
type Config struct {
ContentDir string
StaticDir string
PagesDir string
TemplatesDir string
DataDir string
DBPath string
TrustedHTML bool
GitEnable bool
GitRepo string
GitBranch string
GitDir string
GitInterval time.Duration
Addr string
}
type App struct {
cfg Config
counter store.CounterStore
tplList *template.Template
tplArticle *template.Template
tplPage *template.Template
articles []article.Article
staticPages map[string]article.StaticPage
mu sync.RWMutex
}
func main() {
cfg := loadConfig()
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
log.Fatal(err)
}
counter, err := store.NewSQLiteCounterStore(cfg.DBPath)
if err != nil {
log.Fatal(err)
}
defer counter.Close()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := counter.Init(ctx); err != nil {
log.Fatal(err)
}
app := &App{cfg: cfg, counter: counter}
if err := app.Reload(); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", app.handleHome)
mux.HandleFunc("/post/", app.handlePost)
mux.HandleFunc("/page/", app.handlePage)
mux.Handle("/static/", cacheControl(http.StripPrefix("/static/", http.FileServer(http.Dir(cfg.StaticDir)))))
server := &http.Server{
Addr: cfg.Addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
if cfg.GitEnable {
go app.startAutoClone(ctx)
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("listening on %s", cfg.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
log.Println("server stopped")
}
func loadConfig() Config {
intervalMinutes := getenvInt("GIT_INTERVAL", 10)
dataDir := getenv("BLOG_DATA_DIR", "/data")
return Config{
ContentDir: getenv("BLOG_CONTENT_DIR", "/content"),
StaticDir: getenv("BLOG_STATIC_DIR", "/static"),
PagesDir: getenv("BLOG_PAGES_DIR", "/pages"),
TemplatesDir: getenv("BLOG_TEMPLATES_DIR", "/templates"),
DataDir: dataDir,
DBPath: getenv("BLOG_DB_PATH", filepath.Join(dataDir, "blog.db")),
TrustedHTML: getenvBool("BLOG_TRUSTED_HTML", true),
GitEnable: getenvBool("GIT_ENABLE", false),
GitRepo: getenv("GIT_REPO", ""),
GitBranch: getenv("GIT_BRANCH", "main"),
GitDir: getenv("GIT_DIR", "/git-temp"),
GitInterval: time.Duration(intervalMinutes) * time.Minute,
Addr: getenv("BLOG_ADDR", ":8080"),
}
}
func (a *App) Reload() error {
funcs := template.FuncMap{"now": time.Now}
layout := template.Must(template.New("base").Funcs(funcs).ParseFiles(filepath.Join(a.cfg.TemplatesDir, "base.html")))
tplList := template.Must(layout.Clone())
template.Must(tplList.Funcs(funcs).ParseFiles(filepath.Join(a.cfg.TemplatesDir, "list.html")))
tplArticle := template.Must(layout.Clone())
template.Must(tplArticle.Funcs(funcs).ParseFiles(filepath.Join(a.cfg.TemplatesDir, "article.html")))
tplPage := template.Must(layout.Clone())
template.Must(tplPage.Funcs(funcs).ParseFiles(filepath.Join(a.cfg.TemplatesDir, "page.html")))
loaderOpts := article.LoaderOptions{TrustedHTML: a.cfg.TrustedHTML}
articles, err := article.LoadDir(a.cfg.ContentDir, loaderOpts)
if err != nil {
return err
}
pages, err := article.LoadStatic(a.cfg.PagesDir, loaderOpts)
if err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
a.tplList = tplList
a.tplArticle = tplArticle
a.tplPage = tplPage
a.articles = articles
a.staticPages = pages
return nil
}
func (a *App) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
a.mu.RLock()
articles := cloneArticles(a.articles)
tpl := a.tplList
a.mu.RUnlock()
slugs := make([]string, 0, len(articles))
for _, item := range articles {
slugs = append(slugs, item.Slug)
}
counts, err := a.counter.GetMany(r.Context(), slugs)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for i := range articles {
articles[i].Counter = counts[articles[i].Slug]
}
err = tpl.ExecuteTemplate(w, "layout", article.ListPage{
Title: "Startseite",
Description: "Alle Artikel im Überblick",
Articles: articles,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *App) handlePost(w http.ResponseWriter, r *http.Request) {
slug := strings.Trim(strings.TrimPrefix(r.URL.Path, "/post/"), "/")
if slug == "" {
http.NotFound(w, r)
return
}
a.mu.RLock()
var found article.Article
ok := false
for _, item := range a.articles {
if item.Slug == slug {
found = item
ok = true
break
}
}
tpl := a.tplArticle
a.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
count, err := a.counter.Increment(r.Context(), slug)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
found.Counter = count
if err := tpl.ExecuteTemplate(w, "layout", found); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *App) handlePage(w http.ResponseWriter, r *http.Request) {
slug := strings.Trim(strings.TrimPrefix(r.URL.Path, "/page/"), "/")
if slug == "" {
http.NotFound(w, r)
return
}
a.mu.RLock()
p, ok := a.staticPages[slug]
tpl := a.tplPage
a.mu.RUnlock()
if !ok {
http.NotFound(w, r)
return
}
if err := tpl.ExecuteTemplate(w, "layout", p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (a *App) startAutoClone(ctx context.Context) {
if err := a.cloneAndReload(); err != nil {
log.Println("git sync failed:", err)
}
ticker := time.NewTicker(a.cfg.GitInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := a.cloneAndReload(); err != nil {
log.Println("git sync failed:", err)
}
}
}
}
func (a *App) cloneAndReload() error {
if a.cfg.GitRepo == "" {
return fmt.Errorf("GIT_REPO is empty")
}
log.Printf("cloning %s branch %s", a.cfg.GitRepo, a.cfg.GitBranch)
if err := os.RemoveAll(a.cfg.GitDir); err != nil {
return err
}
cmd := exec.Command("git", "clone", "--branch", a.cfg.GitBranch, "--single-branch", a.cfg.GitRepo, a.cfg.GitDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
copies := map[string]string{
"articles": a.cfg.ContentDir,
"static": a.cfg.StaticDir,
"pages": a.cfg.PagesDir,
"templates": a.cfg.TemplatesDir,
}
for src, dst := range copies {
source := filepath.Join(a.cfg.GitDir, src)
if _, err := os.Stat(source); os.IsNotExist(err) {
log.Printf("skip missing %s", source)
continue
}
if err := replaceDir(source, dst); err != nil {
return err
}
}
return a.Reload()
}
func replaceDir(src, dst string) error {
if err := os.RemoveAll(dst); err != nil {
return err
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return copyDirContents(src, dst)
}
func copyDirContents(srcDir, destDir string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return fmt.Errorf("read %s: %w", srcDir, err)
}
for _, entry := range entries {
srcPath := filepath.Join(srcDir, entry.Name())
destPath := filepath.Join(destDir, entry.Name())
info, err := entry.Info()
if err != nil {
return err
}
if info.IsDir() {
if err := os.MkdirAll(destPath, info.Mode()); err != nil {
return err
}
if err := copyDirContents(srcPath, destPath); err != nil {
return err
}
continue
}
if err := copyFile(srcPath, destPath, info); err != nil {
return err
}
}
return nil
}
func copyFile(src, dest string, info os.FileInfo) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return os.Chmod(dest, info.Mode())
}
func cloneArticles(in []article.Article) []article.Article {
out := make([]article.Article, len(in))
copy(out, in)
return out
}
func cacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
next.ServeHTTP(w, r)
})
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func getenvBool(k string, d bool) bool {
v := os.Getenv(k)
if v == "" {
return d
}
b, err := strconv.ParseBool(strings.ToLower(v))
if err != nil {
return d
}
return b
}
func getenvInt(k string, d int) int {
v := os.Getenv(k)
if v == "" {
return d
}
n, err := strconv.Atoi(v)
if err != nil {
return d
}
return n
}