This commit is contained in:
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