diff options
Diffstat (limited to 'internal/server/posthandler.go')
| -rw-r--r-- | internal/server/posthandler.go | 190 |
1 files changed, 190 insertions, 0 deletions
diff --git a/internal/server/posthandler.go b/internal/server/posthandler.go new file mode 100644 index 0000000..303eeaa --- /dev/null +++ b/internal/server/posthandler.go @@ -0,0 +1,190 @@ +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 +} |
