This commit is contained in:
64
.gitea/workflows/v2.yml
Normal file
64
.gitea/workflows/v2.yml
Normal 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 }}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
############################
|
############################
|
||||||
# 1) Go‑Build
|
# 1) Go‑Build
|
||||||
############################
|
############################
|
||||||
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 ./
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -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`
|
||||||
|
|||||||
424
cmd/blog/main.go
424
cmd/blog/main.go
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
go.mod
19
go.mod
@@ -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
47
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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 Front‑Matter (JSON‑Kommentar 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) // Duplikats‑Check
|
}
|
||||||
|
|
||||||
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 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 == "" {
|
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 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) {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 == "" {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
95
internal/store/sqlite.go
Normal 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
11
internal/store/store.go
Normal 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
|
||||||
|
}
|
||||||
8
internal/web/static/style.css
Normal file
8
internal/web/static/style.css
Normal 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; }
|
||||||
@@ -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 }}
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
410
main.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user