Files
b1tsblog/main.go
jbergner b7edfdd544
Some checks failed
release-tag / release-image (push) Failing after 1m48s
v2-Neuerstellung
2026-05-18 11:25:31 +02:00

411 lines
9.0 KiB
Go

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
}