package server import ( "encoding/json" "fmt" "html/template" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "nebbet.no/internal/builder" "nebbet.no/internal/db" ) type PostHandler struct { DB *db.MetaDB Templates *template.Template PublicDir string } func NewPostHandler(metaDB *db.MetaDB, tmpl *template.Template, publicDir string) *PostHandler { return &PostHandler{ DB: metaDB, Templates: tmpl, PublicDir: publicDir, } } // Serve dispatches to ServeHTML or ServeJSON based on the slug extension. func (h *PostHandler) Serve(c *gin.Context) { slug := c.Param("slug") if strings.HasSuffix(slug, ".json") { h.ServeJSON(c) return } h.ServeHTML(c) } // ServeHTML serves the HTML version of a post. func (h *PostHandler) ServeHTML(c *gin.Context) { slug := c.Param("slug") slug = strings.TrimSuffix(slug, ".html") post, err := h.DB.GetPost(slug) if err != nil { if toSlug, rerr := h.DB.GetRedirect(slug); rerr == nil { c.Redirect(http.StatusMovedPermanently, "/"+toSlug) return } c.AbortWithStatus(http.StatusNotFound) return } cacheFile := filepath.Join(h.PublicDir, slug+".html") // Check cache freshness if isCacheFresh(cacheFile, post.UpdatedAt) { if f, err := os.Open(cacheFile); err == nil { defer f.Close() info, _ := os.Stat(cacheFile) c.Header("Content-Type", "text/html; charset=utf-8") http.ServeContent(c.Writer, c.Request, slug+".html", info.ModTime(), f) return } } // Render post html, err := h.renderPostHTML(post) if err != nil { fmt.Fprintf(os.Stderr, "render post %s: %v\n", slug, err) c.AbortWithStatus(http.StatusInternalServerError) return } // Atomic write: write to temp file, then rename if err := os.MkdirAll(h.PublicDir, 0755); err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } tmpFile := cacheFile + ".tmp" if err := os.WriteFile(tmpFile, []byte(html), 0644); err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } if err := os.Rename(tmpFile, cacheFile); err != nil { os.Remove(tmpFile) c.AbortWithStatus(http.StatusInternalServerError) return } // Serve rendered HTML c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, html) } // ServeJSON handles GET /posts/:slug.json and serves the JSON version of a post. func (h *PostHandler) ServeJSON(c *gin.Context) { slug := c.Param("slug") slug = strings.TrimSuffix(slug, ".json") post, err := h.DB.GetPost(slug) if err != nil { if toSlug, rerr := h.DB.GetRedirect(slug); rerr == nil { c.Redirect(http.StatusMovedPermanently, "/"+toSlug+".json") return } c.AbortWithStatus(http.StatusNotFound) return } cacheFile := filepath.Join(h.PublicDir, slug+".json") // Check cache freshness if isCacheFresh(cacheFile, post.UpdatedAt) { if f, err := os.Open(cacheFile); err == nil { defer f.Close() info, _ := os.Stat(cacheFile) c.Header("Content-Type", "application/json") http.ServeContent(c.Writer, c.Request, slug+".json", info.ModTime(), f) return } } // Generate JSON representation jsonData := map[string]interface{}{ "slug": post.Slug, "title": post.Title, "date": post.Date, "tags": post.Tags, "blocks": json.RawMessage(post.Blocks), } jsonBytes, _ := json.MarshalIndent(jsonData, "", " ") // Atomic write if err := os.MkdirAll(h.PublicDir, 0755); err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } tmpFile := cacheFile + ".tmp" if err := os.WriteFile(tmpFile, jsonBytes, 0644); err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } if err := os.Rename(tmpFile, cacheFile); err != nil { os.Remove(tmpFile) c.AbortWithStatus(http.StatusInternalServerError) return } // Serve JSON c.Header("Content-Type", "application/json") c.Data(http.StatusOK, "application/json", jsonBytes) } // renderPostHTML renders a post to HTML using the base template. func (h *PostHandler) renderPostHTML(post *db.PostRecord) (string, error) { htmlBody, scripts, err := builder.RenderEditorJS(post.Blocks) if err != nil { return "", fmt.Errorf("render editorjs: %w", err) } pageData := builder.PageData{ Title: post.Title, Content: template.HTML(htmlBody), ComponentScripts: scripts, Date: post.Date, Tags: post.Tags, Path: "/" + post.Slug, } var buf strings.Builder if err := h.Templates.ExecuteTemplate(&buf, "base.html", pageData); err != nil { return "", fmt.Errorf("template: %w", err) } return buf.String(), nil } // isCacheFresh checks if the cache file is newer than the post's updated_at timestamp. func isCacheFresh(cacheFile string, updatedAtMicros int64) bool { info, err := os.Stat(cacheFile) if err != nil { return false } cacheModTime := info.ModTime().UnixMicro() return cacheModTime >= updatedAtMicros }