summaryrefslogtreecommitdiffstats
path: root/internal/server
diff options
context:
space:
mode:
Diffstat (limited to 'internal/server')
-rw-r--r--internal/server/posthandler.go190
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
+}