diff --git a/.gitea/workflows/v2.yml b/.gitea/workflows/v2.yml new file mode 100644 index 0000000..d74a712 --- /dev/null +++ b/.gitea/workflows/v2.yml @@ -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 }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ff1e147..25e66fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ############################ # 1) Go‑Build ############################ -FROM golang:1.25 AS build +FROM golang:1.26 AS build WORKDIR /app COPY go.mod go.sum ./ diff --git a/README.md b/README.md index 865f5c7..5b088b9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,39 @@ -# b1tsblog +# B1TS Blog optimiert -## B1tsBlog +Features: -- Anpassung Datenschutzerklärung -- Windows Server 2025 Domänen-Controller legt Netzwerkprofil auf Öffentlich fest -- TLS-Zertifikat mit SHA3 auf Windows Server 2016 einspielen -- Warum personenbezogene Daten nie in die Betreffzeile einer E-Mail gehören -- Warum meldet der Client, dass die Default Domain Policy nicht gelesen werden kann -- Open Source als Innovationsmotor: Warum Unternehmen auf offene Software setzen -- 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 +- Artikel und Pages aus `.md`, `.markdown`, `.html`, `.htm` +- JSON-Frontmatter in der ersten Zeile, z. B. `` +- SQLite-Datenbank für Aufrufzähler statt JSON-Datei +- Git-Sync mit anschließendem Reload +- Templates werden beim Reload neu geladen +- `BLOG_TRUSTED_HTML` steuert, ob rohe HTML-Inhalte gerendert oder escaped werden -## B1ts Star Citizen Blog +## Start lokal -- Anpassung Datenschutzerklärung -- Invictus 2955 - Das Jahr der Idris-P? \ No newline at end of file +```bash +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` diff --git a/cmd/blog/main.go b/cmd/blog/main.go deleted file mode 100644 index 5d0090a..0000000 --- a/cmd/blog/main.go +++ /dev/null @@ -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, // jetzt‑Zeit 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")) - } - - // LIST‑Seite: base + list.html - tplList = template.Must(layout.Clone()) - template.Must(tplList.Funcs(funcs).ParseFiles(templatesDir + "/list.html")) - - // ARTICLE‑Instanz - 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 Template‑Pool (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) - } -} diff --git a/go.mod b/go.mod index 68b0854..be6452a 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ 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 +) diff --git a/go.sum b/go.sum index 148941c..6162505 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,45 @@ -github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= -github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +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= diff --git a/internal/article/load.go b/internal/article/load.go index 21f6839..25644bc 100644 --- a/internal/article/load.go +++ b/internal/article/load.go @@ -1,8 +1,5 @@ -// internal/article/load.go package article -// internal/article/load.go (gekürzt) - import ( "bytes" "encoding/json" @@ -19,15 +16,24 @@ import ( "github.com/gomarkdown/markdown/html" ) -// LoadDir liest alle *.html und *.md unter root/content, -// parst Front‑Matter (JSON‑Kommentar in der 1. Zeile) und liefert []Article. -func LoadDir(root string) ([]Article, error) { - var out []Article - seen := make(map[string]bool) // Duplikats‑Check +type LoaderOptions struct { + // TrustedHTML erlaubt rohe .html-Inhalte aus deinem Content-Repository. + // Achtung: Nur aktivieren, wenn du dem Repository vertraust. + TrustedHTML bool +} - mdRenderer := html.NewRenderer(html.RendererOptions{ - Flags: html.CommonFlags | html.HrefTargetBlank, - }) +type frontMatter struct { + 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 { if walkErr != nil { @@ -38,37 +44,15 @@ func LoadDir(root string) ([]Article, error) { } ext := strings.ToLower(filepath.Ext(path)) - if ext != ".md" && ext != ".html" { - return nil // uninteressante Datei + if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" { + return nil } - raw, err := os.ReadFile(path) + meta, body, format, err := readContentFile(path, opts) 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 front‑matter", 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) - } - - // Fallback‑Slug aus Dateinamen if meta.Slug == "" { meta.Slug = strings.TrimSuffix(filepath.Base(path), ext) } @@ -77,11 +61,6 @@ func LoadDir(root string) ([]Article, error) { } 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) if err != nil { return fmt.Errorf("%s: parse date: %w", path, err) @@ -93,7 +72,9 @@ func LoadDir(root string) ([]Article, error) { Date: date, Cover: meta.Cover, Description: meta.Description, - Body: template.HTML(body), + Body: body, + Format: format, + SourcePath: path, }) return nil }) @@ -105,34 +86,7 @@ func LoadDir(root string) ([]Article, error) { return out, nil } -// ---------- NEU ---------- -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, "" 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 front‑matter found") - } - return []byte(s), nil -} - -// LoadStatic liest alle .md/.html‑Dateien unter dir und liefert sie als Map[slug]StaticPage. -func LoadStatic(dir string) (map[string]StaticPage, error) { +func LoadStatic(dir string, opts LoaderOptions) (map[string]StaticPage, error) { pages := make(map[string]StaticPage) entries, err := os.ReadDir(dir) @@ -144,38 +98,17 @@ func LoadStatic(dir string) (map[string]StaticPage, error) { if e.IsDir() { continue } - ext := filepath.Ext(e.Name()) - if ext != ".md" && ext != ".html" { - continue // unbekanntes Format + ext := strings.ToLower(filepath.Ext(e.Name())) + if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" { + continue } path := filepath.Join(dir, e.Name()) - raw, err := os.ReadFile(path) + meta, body, format, err := readContentFile(path, opts) if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) + return nil, err } - // Front‑Matter (erste Zeile) herauslösen - parts := bytes.SplitN(raw, []byte("\n"), 2) - if len(parts) < 2 { - return nil, fmt.Errorf("%s: missing front‑matter", 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 == "" { 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) } - body := parts[1] - if ext == ".md" { - body = md.ToHTML(body, nil, nil) // eigener Parser je Aufruf - } - pages[meta.Slug] = StaticPage{ Title: meta.Title, Slug: meta.Slug, Description: meta.Description, - Body: template.HTML(body), + Body: body, + Format: format, + SourcePath: path, } } + 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, ""); 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 +} diff --git a/internal/article/model.go b/internal/article/model.go index 932bf17..9c1e775 100644 --- a/internal/article/model.go +++ b/internal/article/model.go @@ -1,4 +1,3 @@ -// internal/article/model.go package article import ( @@ -6,6 +5,13 @@ import ( "time" ) +type Format string + +const ( + FormatMarkdown Format = "markdown" + FormatHTML Format = "html" +) + type Article struct { Title string Slug string @@ -13,7 +19,9 @@ type Article struct { Cover string Body template.HTML Description string - Counter string + Counter int64 + Format Format + SourcePath string } type ListPage struct { @@ -21,3 +29,12 @@ type ListPage struct { Description string Articles []Article } + +type StaticPage struct { + Title string + Slug string + Description string + Body template.HTML + Format Format + SourcePath string +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go new file mode 100644 index 0000000..b9e6c93 --- /dev/null +++ b/internal/store/sqlite.go @@ -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() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..5fe7279 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 0000000..59526da --- /dev/null +++ b/internal/web/static/style.css @@ -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; } diff --git a/internal/web/templates/article.html b/internal/web/templates/article.html index d9c7a0e..441e8dd 100644 --- a/internal/web/templates/article.html +++ b/internal/web/templates/article.html @@ -1,20 +1,8 @@ -{{ define "title" }}{{ .Title }} – B1tsblog{{ end }} - -{{ define "body" }} -
- {{ if .Cover }} - - {{ end }} -

← Zurück zur Übersicht

+{{ define "content" }} +

{{ .Title }}

- - -
- {{ .Body }} -
- -

← Zurück zur Übersicht

+

{{ .Date.Format "02.01.2006" }} · {{ .Counter }} Aufrufe · {{ .Format }}

+ {{ if .Cover }}{{ end }} +
{{ .Body }}
{{ end }} - -{{ define "article" }}{{ template "layout" . }}{{ end }} diff --git a/internal/web/templates/list.html b/internal/web/templates/list.html index 1444f02..51394ed 100644 --- a/internal/web/templates/list.html +++ b/internal/web/templates/list.html @@ -1,19 +1,16 @@ -{{ define "body" }} - + {{ end }} diff --git a/internal/web/templates/page.html b/internal/web/templates/page.html index 2b4e51f..1f71783 100644 --- a/internal/web/templates/page.html +++ b/internal/web/templates/page.html @@ -1,12 +1,6 @@ -{{ define "title" }}{{ .Title }} – B1tsblog{{ end }} - -{{ define "body" }} -
-

← Zurück

-

{{ .Title }}

-
{{ .Body }}
-

← Zurück

-
+{{ define "content" }} +
+

{{ .Title }}

+
{{ .Body }}
+
{{ end }} - -{{ define "page" }}{{ template "layout" . }}{{ end }} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a6f569c --- /dev/null +++ b/main.go @@ -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 +}