summaryrefslogtreecommitdiffstats
path: root/internal/server
diff options
context:
space:
mode:
Diffstat (limited to 'internal/server')
-rw-r--r--internal/server/adminserver.go664
-rw-r--r--internal/server/fileserver.go111
-rw-r--r--internal/server/fileserver_test.go44
-rw-r--r--internal/server/frontpage.go87
-rw-r--r--internal/server/posthandler.go190
5 files changed, 1096 insertions, 0 deletions
diff --git a/internal/server/adminserver.go b/internal/server/adminserver.go
new file mode 100644
index 0000000..6b46bf3
--- /dev/null
+++ b/internal/server/adminserver.go
@@ -0,0 +1,664 @@
+// Package admin provides an HTTP server for managing posts via a web UI.
+// Admin pages are served dynamically and are never written to the static
+// output directory — they are intentionally excluded from the site generator.
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/gosimple/slug"
+
+ auth "iblog/internal"
+ "iblog/internal/db"
+)
+
+// Server is an http.Handler that serves the admin post-management UI.
+type Server struct {
+ // AuthFile is the path to the htpasswd-compatible passwords file.
+ AuthFile string
+ // DB provides access to all post records and search indexing
+ DB *db.DB
+ // PublicDir is the directory where cache files are stored
+ PublicDir string
+ // DataDir is the writable data directory (parent of AuthFile)
+ DataDir string
+
+ adminAssets fs.FS
+ siteAssets fs.FS
+ configured atomic.Bool
+
+ engine *gin.Engine
+}
+
+// Post holds the metadata and content for form display and submission.
+type Post struct {
+ Slug string
+ Id string
+ Title string
+ Date string
+ Tags []string
+ Draft bool
+ Blocks string // raw EditorJS JSON
+}
+
+// NewAdminServer creates a new admin server with Gin routing and auth middleware.
+// tmpl must be provided before any routes are registered so SetHTMLTemplate is
+// called at the right time (Gin requires this to be thread-safe).
+func NewAdminServer(authFile string, database *db.DB, publicDir string, adminAssets, siteAssets fs.FS, tmpl *template.Template) *Server {
+ s := &Server{
+ AuthFile: authFile,
+ DB: database,
+ PublicDir: publicDir,
+ DataDir: filepath.Dir(authFile),
+ adminAssets: adminAssets,
+ siteAssets: siteAssets,
+ }
+ s.configured.Store(auth.NewAuth(authFile).IsConfigured())
+
+ s.engine = gin.Default()
+ s.engine.SetTrustedProxies(nil)
+ s.engine.SetHTMLTemplate(tmpl)
+
+ // Redirect to /setup when the site has not been configured yet.
+ // Skip the setup route itself and all asset paths so the form loads properly.
+ s.engine.Use(func(c *gin.Context) {
+ if !s.configured.Load() {
+ p := c.Request.URL.Path
+ if p != "/setup" && !strings.HasPrefix(p, "/assets/") {
+ c.Redirect(http.StatusFound, "/setup")
+ c.Abort()
+ return
+ }
+ }
+ c.Next()
+ })
+
+ // Setup routes — no auth, only accessible before the site is configured.
+ s.engine.GET("/setup", s.handleSetupGet)
+ s.engine.POST("/setup", s.handleSetupPost)
+
+ // Silent auth probe: returns 200 if authenticated, 403 if not.
+ // Must not send WWW-Authenticate so the browser never shows a login dialog.
+ s.engine.GET("/admin/ping", func(c *gin.Context) {
+ if s.AuthFile == "" {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+ username, password, ok := c.Request.BasicAuth()
+ if !ok {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+ a := auth.NewAuth(s.AuthFile)
+ valid, err := a.Verify(username, password)
+ if err != nil || !valid {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+ c.Status(http.StatusOK)
+ })
+
+ // Admin post routes (protected)
+ admin := s.engine.Group("/admin")
+ admin.Use(s.authMiddleware())
+ {
+ admin.GET("/", s.handleList)
+ admin.GET("/settings", s.handleSettingsGet)
+ admin.POST("/settings", s.handleSettingsPost)
+ admin.GET("/new", s.handleNew)
+ admin.POST("/", s.handleNewPost)
+ admin.GET("/:slug", s.handleEdit)
+ admin.POST("/:slug", s.handleEdit)
+ admin.DELETE("/:slug", s.handleDelete)
+ }
+
+ // Asset serving: /assets/admin/* requires auth and is served from embedded adminAssets.
+ // All other /assets/* are public and served from embedded siteAssets.
+ {
+ adminMW := s.authMiddleware()
+ adminHandler := EmbedHandler(s.adminAssets, "assets")
+ siteHandler := EmbedHandler(s.siteAssets, "assets")
+ s.engine.GET("/assets/*filepath", func(c *gin.Context) {
+ fp := c.Param("filepath")
+ if strings.HasPrefix(fp, "/admin") {
+ adminMW(c)
+ if c.IsAborted() {
+ return
+ }
+ adminHandler(c)
+ return
+ }
+ // Logo override: serve user-uploaded logo before falling back to embedded.
+ if fp == "/static/image.png" {
+ s.handleLogo(c)
+ return
+ }
+ siteHandler(c)
+ })
+ }
+
+ return s
+}
+
+func (s *Server) Engine() *gin.Engine {
+ return s.engine
+}
+
+// RegisterUploadRoute registers handler under POST /admin/upload/image
+// behind the admin Basic Auth middleware.
+func (s *Server) RegisterUploadRoute(handler gin.HandlerFunc) {
+ admin := s.engine.Group("/admin")
+ admin.Use(s.authMiddleware())
+ admin.POST("/upload/image", handler)
+}
+
+func (s *Server) authMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if s.AuthFile == "" {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ username, password, ok := c.Request.BasicAuth()
+ if !ok {
+ c.Header("WWW-Authenticate", `Basic realm="Admin"`)
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ a := auth.NewAuth(s.AuthFile)
+ valid, err := a.Verify(username, password)
+
+ if err != nil || !valid {
+ c.Header("WWW-Authenticate", `Basic realm="Admin"`)
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+
+ c.Next()
+ }
+}
+
+func (s *Server) handleList(c *gin.Context) {
+ posts, err := s.DB.ListPosts(true) // includeDrafts=true for admin view
+ if err != nil {
+ c.HTML(http.StatusInternalServerError, "base", gin.H{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": "Failed to list posts: " + err.Error(),
+ })
+ return
+ }
+
+ // Convert PostRecord to Post for template
+ var formPosts []Post
+ for _, p := range posts {
+ formPosts = append(formPosts, Post{
+ Slug: p.Slug,
+ Title: p.Title,
+ Date: p.Date,
+ Tags: p.Tags,
+ Draft: p.Draft,
+ Blocks: p.Blocks,
+ })
+ }
+
+ c.HTML(http.StatusOK, "base", gin.H{
+ "Title": "Posts",
+ "ContentTemplate": "list-content",
+ "Posts": formPosts,
+ })
+}
+
+func (s *Server) handleNew(c *gin.Context) {
+ c.HTML(http.StatusOK, "base", gin.H{
+ "Title": "New Post",
+ "ContentTemplate": "form-content",
+ "Action": "/admin/",
+ "Post": Post{
+ Date: time.Now().Format("2006-01-02"),
+ Blocks: "[]",
+ },
+ "IsNew": true,
+ })
+}
+
+func (s *Server) handleNewPost(c *gin.Context) {
+ p := postFromForm(c)
+ if p.Title == "" {
+ s.renderError(c, "Title is required")
+ return
+ }
+
+ if p.Slug == "" {
+ p.Slug = slugify(p.Title)
+ }
+ if !validSlug.MatchString(p.Slug) {
+ s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", p.Slug))
+ return
+ }
+
+ // Check if post already exists
+ existing, err := s.DB.GetPostBySlug(p.Slug)
+ if err == nil && existing != nil {
+ s.renderError(c, fmt.Sprintf("Post %q already exists", p.Slug))
+ return
+ }
+
+ // Prepare post record with current timestamp
+ record := db.PostRecord{
+ Slug: p.Slug,
+ Title: p.Title,
+ Date: p.Date,
+ Tags: p.Tags,
+ Draft: p.Draft,
+ Blocks: p.Blocks,
+ UpdatedAt: time.Now().UnixMicro(),
+ }
+
+ // Upsert post
+ if err := s.DB.UpsertPost(record); err != nil {
+ c.HTML(http.StatusInternalServerError, "base", gin.H{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": err.Error(),
+ })
+ return
+ }
+
+ // Index in search database
+ plainText := extractPlainTextFromEditorJS(p.Blocks)
+ _ = s.DB.IndexPage(db.SearchPage{
+ Path: "/" + p.Slug,
+ Title: p.Title,
+ Content: plainText,
+ })
+
+ c.Redirect(http.StatusSeeOther, "/admin/")
+}
+
+func (s *Server) handleEdit(c *gin.Context) {
+ slug := c.Param("slug")
+ rec, err := s.DB.GetPostBySlug(slug)
+ if err != nil {
+ c.HTML(http.StatusNotFound, "base", gin.H{
+ "Title": "Not Found",
+ "ContentTemplate": "error-content",
+ "Message": "Post not found",
+ })
+ return
+ }
+
+ p := Post{
+ Slug: rec.Slug,
+ Title: rec.Title,
+ Date: rec.Date,
+ Tags: rec.Tags,
+ Draft: rec.Draft,
+ Blocks: rec.Blocks,
+ }
+
+ if c.Request.Method == http.MethodPost {
+ updated := postFromForm(c)
+ if updated.Title == "" {
+ s.renderError(c, "Title is required")
+ return
+ }
+
+ targetSlug := slug // default: slug unchanged
+
+ record := db.PostRecord{
+ Slug: targetSlug,
+ Title: updated.Title,
+ Date: updated.Date,
+ Tags: updated.Tags,
+ Draft: updated.Draft,
+ Blocks: updated.Blocks,
+ UpdatedAt: time.Now().UnixMicro(),
+ }
+
+ if updated.Slug != "" && updated.Slug != slug {
+ // Slug rename requested
+ if !validSlug.MatchString(updated.Slug) {
+ s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", updated.Slug))
+ return
+ }
+ // Check target slug is not already taken
+ if existing, err := s.DB.GetPostBySlug(updated.Slug); err == nil && existing != nil {
+ s.renderError(c, fmt.Sprintf("Post %q already exists", updated.Slug))
+ return
+ }
+ // Atomically rename and update content in one transaction
+ record.Slug = updated.Slug
+ if err := s.DB.RenameAndUpsertPost(slug, record); err != nil {
+ s.renderError(c, "Failed to rename post: "+err.Error())
+ return
+ }
+ // Invalidate old cache and remove old search entry
+ s.invalidatePostCache(slug)
+ _ = s.DB.UnindexPage("/" + slug)
+ targetSlug = updated.Slug
+ } else {
+ if err := s.DB.UpsertPost(record); err != nil {
+ c.HTML(http.StatusInternalServerError, "base", gin.H{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": err.Error(),
+ })
+ return
+ }
+ }
+
+ // Invalidate cache for the target slug
+ s.invalidatePostCache(targetSlug)
+
+ // Re-index with target slug
+ plainText := extractPlainTextFromEditorJS(updated.Blocks)
+ _ = s.DB.IndexPage(db.SearchPage{
+ Path: "/" + targetSlug,
+ Title: updated.Title,
+ Content: plainText,
+ })
+
+ c.Redirect(http.StatusSeeOther, "/admin/")
+ return
+ }
+
+ // GET: render form
+ c.HTML(http.StatusOK, "base", gin.H{
+ "Title": "Edit Post",
+ "ContentTemplate": "form-content",
+ "Action": "/admin/" + slug,
+ "Post": p,
+ "IsNew": false,
+ })
+}
+
+func (s *Server) handleDelete(c *gin.Context) {
+ slug := c.Param("slug")
+ if err := s.DB.DeletePostBySlug(slug); err != nil {
+ c.HTML(http.StatusInternalServerError, "base", gin.H{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": err.Error(),
+ })
+ return
+ }
+
+ // Invalidate cache files
+ s.invalidatePostCache(slug)
+
+ // Remove from search index
+ _ = s.DB.UnindexPage("/" + slug)
+
+ c.Redirect(http.StatusSeeOther, "/admin/")
+}
+
+func (s *Server) handleSetupGet(c *gin.Context) {
+ if s.configured.Load() {
+ c.Redirect(http.StatusFound, "/admin/")
+ return
+ }
+ c.HTML(http.StatusOK, "setup.html", gin.H{})
+}
+
+func (s *Server) handleSetupPost(c *gin.Context) {
+ if s.configured.Load() {
+ c.Redirect(http.StatusFound, "/admin/")
+ return
+ }
+
+ username := strings.TrimSpace(c.PostForm("username"))
+ password := c.PostForm("password")
+ confirm := c.PostForm("confirm")
+ siteTitle := strings.TrimSpace(c.PostForm("site_title"))
+ siteDesc := strings.TrimSpace(c.PostForm("site_description"))
+
+ redisplay := func(msg string) {
+ c.HTML(http.StatusBadRequest, "setup.html", gin.H{
+ "Error": msg,
+ "Username": username,
+ "SiteTitle": siteTitle,
+ "SiteDescription": siteDesc,
+ })
+ }
+
+ if username == "" {
+ redisplay("Username is required")
+ return
+ }
+ if password == "" {
+ redisplay("Password is required")
+ return
+ }
+ if password != confirm {
+ redisplay("Passwords do not match")
+ return
+ }
+
+ // Create admin user
+ a := auth.NewAuth(s.AuthFile)
+ if err := a.AddUserWithPassword(username, password); err != nil {
+ redisplay("Failed to create user: " + err.Error())
+ return
+ }
+
+ // Save optional site settings
+ if siteTitle != "" {
+ _ = s.DB.SetSetting("site_title", siteTitle)
+ }
+ if siteDesc != "" {
+ _ = s.DB.SetSetting("site_description", siteDesc)
+ }
+
+ // Save optional logo (ignore errors — setup can proceed without it)
+ _ = s.saveLogoUpload(c)
+
+ s.configured.Store(true)
+ c.Redirect(http.StatusSeeOther, "/admin/")
+}
+
+func (s *Server) handleSettingsGet(c *gin.Context) {
+ title, _ := s.DB.GetSetting("site_title")
+ desc, _ := s.DB.GetSetting("site_description")
+ c.HTML(http.StatusOK, "base", gin.H{
+ "Title": "Settings",
+ "ContentTemplate": "settings-content",
+ "SiteTitle": title,
+ "SiteDescription": desc,
+ })
+}
+
+func (s *Server) handleSettingsPost(c *gin.Context) {
+ siteTitle := strings.TrimSpace(c.PostForm("site_title"))
+ siteDesc := strings.TrimSpace(c.PostForm("site_description"))
+
+ _ = s.DB.SetSetting("site_title", siteTitle)
+ _ = s.DB.SetSetting("site_description", siteDesc)
+
+ if err := s.saveLogoUpload(c); err != nil {
+ title, _ := s.DB.GetSetting("site_title")
+ desc, _ := s.DB.GetSetting("site_description")
+ c.HTML(http.StatusBadRequest, "base", gin.H{
+ "Title": "Settings",
+ "ContentTemplate": "settings-content",
+ "Error": err.Error(),
+ "SiteTitle": title,
+ "SiteDescription": desc,
+ })
+ return
+ }
+
+ title, _ := s.DB.GetSetting("site_title")
+ desc, _ := s.DB.GetSetting("site_description")
+ c.HTML(http.StatusOK, "base", gin.H{
+ "Title": "Settings",
+ "ContentTemplate": "settings-content",
+ "Success": true,
+ "SiteTitle": title,
+ "SiteDescription": desc,
+ })
+}
+
+// saveLogoUpload saves an uploaded logo file to DataDir if one was provided.
+// Returns nil if no file was uploaded (blank is not an error).
+func (s *Server) saveLogoUpload(c *gin.Context) error {
+ file, _, err := c.Request.FormFile("logo")
+ if err != nil {
+ return nil // no file — not an error
+ }
+ defer file.Close()
+
+ sniff := make([]byte, 512)
+ n, _ := file.Read(sniff)
+ mimeType := http.DetectContentType(sniff[:n])
+ extMap := map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp",
+ }
+ ext, ok := extMap[mimeType]
+ if !ok {
+ return fmt.Errorf("unsupported image type: %s", mimeType)
+ }
+
+ logoPath := filepath.Join(s.DataDir, "logo"+ext)
+ out, err := os.Create(logoPath)
+ if err != nil {
+ return fmt.Errorf("could not save logo: %w", err)
+ }
+ defer out.Close()
+ _, err = io.Copy(out, io.MultiReader(bytes.NewReader(sniff[:n]), file))
+ return err
+}
+
+func (s *Server) handleLogo(c *gin.Context) {
+ extTypes := map[string]string{
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".webp": "image/webp",
+ }
+ for ext, ct := range extTypes {
+ p := filepath.Join(s.DataDir, "logo"+ext)
+ if f, err := os.Open(p); err == nil {
+ defer f.Close()
+ info, _ := f.Stat()
+ c.Header("Content-Type", ct)
+ http.ServeContent(c.Writer, c.Request, "image"+ext, info.ModTime(), f)
+ return
+ }
+ }
+ // Fall back to embedded default
+ data, err := fs.ReadFile(s.siteAssets, "assets/static/image.png")
+ if err != nil {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.Data(http.StatusOK, "image/png", data)
+}
+
+// invalidatePostCache removes cache files for a post to force regeneration.
+func (s *Server) invalidatePostCache(slug string) {
+ htmlCache := filepath.Join(s.PublicDir, slug+".html")
+ jsonCache := filepath.Join(s.PublicDir, slug+".json")
+ _ = os.Remove(htmlCache)
+ _ = os.Remove(jsonCache)
+}
+
+func (s *Server) renderError(c *gin.Context, msg string) {
+ c.HTML(http.StatusBadRequest, "base", gin.H{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": msg,
+ })
+}
+
+func postFromForm(c *gin.Context) Post {
+ tagsStr := strings.TrimSpace(c.PostForm("tags"))
+ var tags []string
+ if tagsStr != "" {
+ for _, t := range strings.Split(tagsStr, ",") {
+ t = strings.TrimSpace(t)
+ if t != "" {
+ tags = append(tags, t)
+ }
+ }
+ }
+
+ draft := c.PostForm("draft") != ""
+
+ return Post{
+ Slug: strings.TrimSpace(c.PostForm("slug")),
+ Title: strings.TrimSpace(c.PostForm("title")),
+ Date: strings.TrimSpace(c.PostForm("date")),
+ Tags: tags,
+ Blocks: c.PostForm("blocks"),
+ Draft: draft,
+ }
+}
+
+// slugify converts a title to a URL-safe slug.
+var validSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
+
+func slugify(title string) string {
+ s := slug.Make(title)
+ if s == "" {
+ s = fmt.Sprintf("post-%d", time.Now().Unix())
+ }
+ return s
+}
+
+// extractPlainTextFromEditorJS extracts plain text from EditorJS blocks for indexing.
+// It's a simple extraction that pulls text from paragraph and header blocks.
+func extractPlainTextFromEditorJS(blocksJSON string) string {
+ if blocksJSON == "" || blocksJSON == "[]" {
+ return ""
+ }
+
+ type block struct {
+ Type string `json:"type"`
+ Data json.RawMessage `json:"data"`
+ }
+
+ type doc struct {
+ Blocks []block `json:"blocks"`
+ }
+
+ var d doc
+ if err := json.Unmarshal([]byte(blocksJSON), &d); err != nil {
+ return ""
+ }
+
+ var texts []string
+ for _, b := range d.Blocks {
+ switch b.Type {
+ case "paragraph", "header":
+ var data struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(b.Data, &data); err == nil && data.Text != "" {
+ texts = append(texts, data.Text)
+ }
+ }
+ }
+
+ return strings.Join(texts, " ")
+}
diff --git a/internal/server/fileserver.go b/internal/server/fileserver.go
new file mode 100644
index 0000000..d231519
--- /dev/null
+++ b/internal/server/fileserver.go
@@ -0,0 +1,111 @@
+package server
+
+import (
+ "io/fs"
+ "mime"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AllowedMIMEs maps file extensions (including dot) to Content-Type values.
+// A nil map means any extension is allowed; MIME type is detected automatically.
+type AllowedMIMEs map[string]string
+
+// FileHandler returns a Gin HandlerFunc that serves files from the given root directory.
+// The wildcard param name is *filepath.
+func FileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ filepath := c.Param("filepath")
+ serveFile(c, root, filepath, allowed)
+ }
+}
+
+// PublicFileHandler returns a Gin HandlerFunc that serves files using the request URL path.
+// Used for NoRoute fallback to serve public static files.
+func PublicFileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ filepath := c.Request.URL.Path
+ serveFile(c, root, filepath, allowed)
+ }
+}
+
+func serveFile(c *gin.Context, root, requestPath string, allowed AllowedMIMEs) {
+ // Clean the path to prevent directory traversal
+ clean := path.Clean(requestPath)
+ if strings.Contains(clean, "..") {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Remove leading slash for filepath.Join
+ clean = strings.TrimPrefix(clean, "/")
+
+ // Map empty path or directory to index.html (clean URLs)
+ if clean == "" || clean == "." {
+ clean = "index.html"
+ }
+
+ fullPath := filepath.Join(root, clean)
+
+ // Check file existence; if directory, try index.html inside it
+ info, err := os.Stat(fullPath)
+ if err == nil && info.IsDir() {
+ fullPath = filepath.Join(fullPath, "index.html")
+ info, err = os.Stat(fullPath)
+ }
+ if err != nil {
+ // Try appending .html for clean URLs (e.g. /about -> /about.html)
+ htmlPath := fullPath + ".html"
+ if info2, err2 := os.Stat(htmlPath); err2 == nil && !info2.IsDir() {
+ fullPath = htmlPath
+ info = info2
+ err = nil
+ }
+ }
+ if err != nil || info.IsDir() {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Determine MIME type
+ ext := filepath.Ext(fullPath)
+ var mimeType string
+ if allowed != nil {
+ var ok bool
+ mimeType, ok = allowed[ext]
+ if !ok {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ } else {
+ mimeType = mime.TypeByExtension(ext)
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+ }
+
+ // Serve the file with proper MIME type
+ c.Header("Content-Type", mimeType)
+ c.File(fullPath)
+}
+
+// EmbedHandler serves files from an embedded (or any) fs.FS.
+// root is the subdirectory within fsys to serve from.
+// The gin wildcard param must be named *filepath.
+func EmbedHandler(fsys fs.FS, root string) gin.HandlerFunc {
+ sub, err := fs.Sub(fsys, root)
+ if err != nil {
+ panic("EmbedHandler: " + err.Error())
+ }
+ fileServer := http.FileServer(http.FS(sub))
+ return func(c *gin.Context) {
+ req := c.Request.Clone(c.Request.Context())
+ req.URL.Path = c.Param("filepath")
+ fileServer.ServeHTTP(c.Writer, req)
+ }
+}
diff --git a/internal/server/fileserver_test.go b/internal/server/fileserver_test.go
new file mode 100644
index 0000000..a6ef926
--- /dev/null
+++ b/internal/server/fileserver_test.go
@@ -0,0 +1,44 @@
+package server_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+
+ "github.com/gin-gonic/gin"
+ "iblog/internal/server"
+)
+
+func init() { gin.SetMode(gin.TestMode) }
+
+func TestEmbedHandler_ServesFile(t *testing.T) {
+ fsys := fstest.MapFS{
+ "root/hello.js": {Data: []byte(`console.log("hi")`)},
+ }
+ r := gin.New()
+ r.GET("/assets/*filepath", server.EmbedHandler(fsys, "root"))
+
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/assets/hello.js", nil))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if got := w.Body.String(); got != `console.log("hi")` {
+ t.Fatalf("unexpected body: %q", got)
+ }
+}
+
+func TestEmbedHandler_NotFound(t *testing.T) {
+ fsys := fstest.MapFS{}
+ r := gin.New()
+ r.GET("/assets/*filepath", server.EmbedHandler(fsys, "root"))
+
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/assets/missing.js", nil))
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", w.Code)
+ }
+}
diff --git a/internal/server/frontpage.go b/internal/server/frontpage.go
new file mode 100644
index 0000000..faca83c
--- /dev/null
+++ b/internal/server/frontpage.go
@@ -0,0 +1,87 @@
+package server
+
+import (
+ "net/http"
+ "strings"
+
+ "iblog/internal/db"
+
+ "github.com/gin-gonic/gin"
+)
+
+type FrontpageHandler struct {
+ DB *db.DB
+}
+
+type frontpageData struct {
+ Posts []db.PostRecord
+ Query string
+ ActiveTag string
+ SiteTitle string
+ SiteDescription string
+}
+
+func NewFrontpageHandler(database *db.DB) *FrontpageHandler {
+ return &FrontpageHandler{DB: database}
+}
+
+func (h *FrontpageHandler) Serve(c *gin.Context) {
+ query := strings.TrimSpace(c.Query("q"))
+ tag := strings.TrimSpace(c.Query("tag"))
+
+ title, _ := h.DB.GetSetting("site_title")
+ desc, _ := h.DB.GetSetting("site_description")
+
+ data := frontpageData{
+ Query: query,
+ ActiveTag: tag,
+ SiteTitle: title,
+ SiteDescription: desc,
+ }
+
+ if query != "" {
+ results, err := h.DB.Search(query)
+ if err == nil {
+ data.Posts = searchResultsToPosts(results, h.DB)
+ }
+ } else {
+ posts, err := h.DB.ListPosts(false) // exclude drafts
+ if err == nil {
+ if tag != "" {
+ posts = filterByTag(posts, tag)
+ }
+ data.Posts = posts
+ }
+ }
+
+ c.HTML(http.StatusOK, "index.html", data)
+}
+
+func filterByTag(posts []db.PostRecord, tag string) []db.PostRecord {
+ var filtered []db.PostRecord
+ for _, p := range posts {
+ for _, t := range p.Tags {
+ if strings.EqualFold(t, tag) {
+ filtered = append(filtered, p)
+ break
+ }
+ }
+ }
+ return filtered
+}
+
+func searchResultsToPosts(results []db.SearchResult, database *db.DB) []db.PostRecord {
+ var posts []db.PostRecord
+ for _, r := range results {
+ slug := strings.TrimPrefix(r.Path, "/")
+ if slug == r.Path {
+ continue // not a post
+ }
+ post, err := database.GetPostBySlug(slug)
+ if err != nil || post.Draft {
+ continue
+ }
+ posts = append(posts, *post)
+ }
+ return posts
+}
diff --git a/internal/server/posthandler.go b/internal/server/posthandler.go
new file mode 100644
index 0000000..f3a885c
--- /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"
+
+ "iblog/internal/builder"
+ "iblog/internal/db"
+
+ "github.com/gin-gonic/gin"
+)
+
+type PostHandler struct {
+ DB *db.DB
+ Templates *template.Template
+ PublicDir string
+}
+
+func NewPostHandler(database *db.DB, tmpl *template.Template, publicDir string) *PostHandler {
+ return &PostHandler{
+ DB: database,
+ 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.GetPostBySlug(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.Data(http.StatusOK, "text/html; charset=utf-8", []byte(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.GetPostBySlug(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, "post.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
+}