// 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 admin import ( "embed" "encoding/json" "fmt" "html/template" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/gin-gonic/gin" "nebbet.no/internal/admin/auth" "nebbet.no/internal/db" "nebbet.no/internal/server" ) //go:embed templates/*.html var adminTemplates embed.FS // 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. // Authentication is skipped when AuthFile is empty or the file doesn't exist. AuthFile string // MetaDB provides access to post records MetaDB *db.MetaDB // SearchDB provides full-text search indexing SearchDB *db.SearchDB // PublicDir is the directory where cache files are stored PublicDir string engine *gin.Engine tmpl *template.Template } // Post holds the metadata and content for form display and submission. type Post struct { Slug string Title string Date string Tags []string Draft bool Blocks string // raw EditorJS JSON } // NewServer creates a new admin server with Gin routing and auth middleware. func NewServer(authFile string, metaDB *db.MetaDB, searchDB *db.SearchDB, publicDir string) *Server { s := &Server{ AuthFile: authFile, MetaDB: metaDB, SearchDB: searchDB, PublicDir: publicDir, } s.engine = gin.Default() s.engine.SetTrustedProxies([]string{"192.168.1.2"}) s.tmpl = mustParseTemplates() s.engine.SetHTMLTemplate(s.tmpl) // Admin post routes (protected) admin := s.engine.Group("/admin") admin.Use(s.authMiddleware()) { admin.GET("/", s.handleList) admin.GET("/new", s.handleNew) admin.POST("/", s.handleNewPost) admin.GET("/:slug", s.handleEdit) admin.POST("/:slug", s.handleEdit) admin.DELETE("/:slug", s.handleDelete) } // Protected assets route for admin dependencies (EditorJS, etc.) assets := s.engine.Group("/assets/admin") assets.Use(s.authMiddleware()) { adminAssetMIMEs := server.AllowedMIMEs{ ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".wasm": "application/wasm", ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", } assets.GET("/*filepath", server.FileHandler("assets/admin", adminAssetMIMEs)) } return s } func (s *Server) Engine() *gin.Engine { return s.engine } 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.New(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.MetaDB.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.MetaDB.GetPost(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.MetaDB.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.SearchDB.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.MetaDB.GetPost(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.MetaDB.GetPost(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.MetaDB.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.SearchDB.DeletePage("/" + slug) targetSlug = updated.Slug } else { if err := s.MetaDB.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.SearchDB.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.MetaDB.DeletePost(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.SearchDB.DeletePage("/" + slug) c.Redirect(http.StatusSeeOther, "/admin/") } // 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 nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) var validSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) func slugify(title string) string { s := strings.ToLower(title) s = nonAlnum.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if s == "" { s = fmt.Sprintf("post-%d", time.Now().Unix()) } return s } // mustParseTemplates loads admin templates from the embedded filesystem. func mustParseTemplates() *template.Template { funcs := template.FuncMap{ "splitTags": func(tags []string) []string { return tags }, } // Parse templates from embedded filesystem tmpl, err := template.New("admin").Funcs(funcs).ParseFS(adminTemplates, "templates/*.html") if err != nil { panic(fmt.Sprintf("failed to parse admin templates: %v", err)) } return tmpl } // 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, " ") }