diff options
| author | ivar <i@oiee.no> | 2026-04-07 00:23:24 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-07 00:23:24 +0200 |
| commit | 85920b8c7a2696115d1f77c046f48f6f00d639f1 (patch) | |
| tree | 14ed2043796eadd6ed5b0a95c55e38e48713d638 /internal/server | |
| download | iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.tar.xz iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.zip | |
Init
Diffstat (limited to 'internal/server')
| -rw-r--r-- | internal/server/adminserver.go | 664 | ||||
| -rw-r--r-- | internal/server/fileserver.go | 111 | ||||
| -rw-r--r-- | internal/server/fileserver_test.go | 44 | ||||
| -rw-r--r-- | internal/server/frontpage.go | 87 | ||||
| -rw-r--r-- | internal/server/posthandler.go | 190 |
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 +} |
