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 }