411 lines
9.0 KiB
Go
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
|
|
}
|