From 275d6fce19a33b68abb22ed41ded58fa65df1834 Mon Sep 17 00:00:00 2001 From: ivar Date: Sat, 4 Apr 2026 16:31:32 +0200 Subject: feat: support slug editing and rename in admin UI with redirect and cache invalidation Co-Authored-By: Claude Sonnet 4.6 --- internal/admin/server.go | 384 ++++++++++++++++++++++++++++------------------- 1 file changed, 231 insertions(+), 153 deletions(-) (limited to 'internal/admin') diff --git a/internal/admin/server.go b/internal/admin/server.go index 59d3ac7..7610504 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -5,6 +5,7 @@ package admin import ( "embed" + "encoding/json" "fmt" "html/template" "net/http" @@ -17,7 +18,8 @@ import ( "github.com/gin-gonic/gin" "nebbet.no/internal/admin/auth" - "nebbet.no/internal/builder" + "nebbet.no/internal/db" + "nebbet.no/internal/server" ) //go:embed templates/*.html @@ -25,110 +27,118 @@ var adminTemplates embed.FS // Server is an http.Handler that serves the admin post-management UI. type Server struct { - // PostsDir is the directory where post markdown files are stored, - // e.g. "content/posts". It is created on first use if it doesn't exist. - PostsDir string // 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 - // Builder is used to rebuild pages after create/edit/delete operations. - Builder *builder.Builder + // 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 of a single Post. +// Post holds the metadata and content for form display and submission. type Post struct { - Slug string - Title string - Date string - Tags string // comma-separated - Content string // raw markdown body + 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(postsDir, authFile string, builder *builder.Builder) *Server { +func NewServer(authFile string, metaDB *db.MetaDB, searchDB *db.SearchDB, publicDir string) *Server { s := &Server{ - PostsDir: postsDir, - AuthFile: authFile, - Builder: builder, + AuthFile: authFile, + MetaDB: metaDB, + SearchDB: searchDB, + PublicDir: publicDir, } - // Initialize Gin engine s.engine = gin.Default() - - // Load templates + s.engine.SetTrustedProxies([]string{"192.168.1.2"}) s.tmpl = mustParseTemplates() s.engine.SetHTMLTemplate(s.tmpl) - // Apply auth middleware to all routes - s.engine.Use(s.authMiddleware()) - - // Register routes under /admin prefix + // Admin post routes (protected) admin := s.engine.Group("/admin") + admin.Use(s.authMiddleware()) { - // List posts admin.GET("/", s.handleList) - // Create form and create post admin.GET("/new", s.handleNew) admin.POST("/", s.handleNewPost) - // Edit form and update post admin.GET("/:slug", s.handleEdit) admin.POST("/:slug", s.handleEdit) - // Delete post 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 } -// Engine returns the Gin engine for this server. func (s *Server) Engine() *gin.Engine { return s.engine } -// authMiddleware returns a Gin middleware that validates Basic Auth credentials. func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - // Skip auth if no auth file is configured if s.AuthFile == "" { - c.Next() + c.AbortWithStatus(http.StatusUnauthorized) return } - // Skip auth if auth file doesn't exist if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) { - c.Next() + c.AbortWithStatus(http.StatusUnauthorized) return } - // Extract Basic Auth credentials username, password, ok := c.Request.BasicAuth() if !ok { c.Header("WWW-Authenticate", `Basic realm="Admin"`) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + c.AbortWithStatus(http.StatusUnauthorized) return } - // Verify credentials a := auth.New(s.AuthFile) valid, err := a.Verify(username, password) + if err != nil || !valid { c.Header("WWW-Authenticate", `Basic realm="Admin"`) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + c.AbortWithStatus(http.StatusUnauthorized) return } - // Auth succeeded, continue c.Next() } } -// ── Handlers ───────────────────────────────────────────────────────────────── - func (s *Server) handleList(c *gin.Context) { - posts, err := s.listPosts() + posts, err := s.MetaDB.ListPosts(true) // includeDrafts=true for admin view if err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", @@ -137,10 +147,24 @@ func (s *Server) handleList(c *gin.Context) { }) 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": posts, + "Posts": formPosts, }) } @@ -148,9 +172,12 @@ func (s *Server) handleNew(c *gin.Context) { c.HTML(http.StatusOK, "base", gin.H{ "Title": "New Post", "ContentTemplate": "form-content", - "Action": "/admin/new", - "Post": Post{Date: time.Now().Format("2006-01-02")}, - "IsNew": true, + "Action": "/admin/", + "Post": Post{ + Date: time.Now().Format("2006-01-02"), + Blocks: "[]", + }, + "IsNew": true, }) } @@ -160,24 +187,35 @@ func (s *Server) handleNewPost(c *gin.Context) { s.renderError(c, "Title is required") return } + if p.Slug == "" { p.Slug = slugify(p.Title) } - - mdPath := filepath.Join(s.PostsDir, p.Slug+".md") - if err := os.MkdirAll(s.PostsDir, 0755); err != nil { - c.HTML(http.StatusInternalServerError, "base", gin.H{ - "Title": "Error", - "ContentTemplate": "error-content", - "Message": err.Error(), - }) + if !validSlug.MatchString(p.Slug) { + s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", p.Slug)) return } - if _, err := os.Stat(mdPath); err == nil { + + // 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 } - if err := writePostFile(mdPath, p); err != nil { + + // 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", @@ -185,14 +223,21 @@ func (s *Server) handleNewPost(c *gin.Context) { }) return } - s.rebuild(mdPath) + + // 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") - mdPath := filepath.Join(s.PostsDir, slug+".md") - p, err := readPostFile(mdPath, slug) + rec, err := s.MetaDB.GetPost(slug) if err != nil { c.HTML(http.StatusNotFound, "base", gin.H{ "Title": "Not Found", @@ -202,14 +247,58 @@ func (s *Server) handleEdit(c *gin.Context) { 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) - updated.Slug = slug // slug is immutable after creation if updated.Title == "" { s.renderError(c, "Title is required") return } - if err := writePostFile(mdPath, updated); err != nil { + + targetSlug := slug // default: slug unchanged + + 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 + } + // Perform rename (creates redirect, collapses chains) + if err := s.MetaDB.RenamePost(slug, updated.Slug); err != nil { + s.renderError(c, "Failed to rename post: "+err.Error()) + return + } + // Invalidate old cache + s.invalidatePostCache(slug) + // Remove old search index entry + _ = s.SearchDB.DeletePage("/" + slug) + targetSlug = updated.Slug + } + + // Update content under the (possibly new) slug + 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 err := s.MetaDB.UpsertPost(record); err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", @@ -217,11 +306,23 @@ func (s *Server) handleEdit(c *gin.Context) { }) return } - s.rebuild(mdPath) + + // 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", @@ -233,8 +334,7 @@ func (s *Server) handleEdit(c *gin.Context) { func (s *Server) handleDelete(c *gin.Context) { slug := c.Param("slug") - mdPath := filepath.Join(s.PostsDir, slug+".md") - if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) { + if err := s.MetaDB.DeletePost(slug); err != nil && !os.IsNotExist(err) { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", @@ -242,40 +342,22 @@ func (s *Server) handleDelete(c *gin.Context) { }) return } - if s.Builder != nil { - _ = s.Builder.RemovePage(mdPath) - } - c.Redirect(http.StatusSeeOther, "/admin/") -} -func (s *Server) rebuild(mdPath string) { - if s.Builder == nil { - return - } - importMap, _ := builder.GenerateImportMap(s.Builder.LibDir) - _ = s.Builder.BuildFile(mdPath, importMap) + // Invalidate cache files + s.invalidatePostCache(slug) + + // Remove from search index + _ = s.SearchDB.DeletePage("/" + slug) + + c.Redirect(http.StatusSeeOther, "/admin/") } -func (s *Server) listPosts() ([]Post, error) { - if err := os.MkdirAll(s.PostsDir, 0755); err != nil { - return nil, err - } - entries, err := os.ReadDir(s.PostsDir) - if err != nil { - return nil, err - } - var posts []Post - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { - continue - } - slug := strings.TrimSuffix(e.Name(), ".md") - p, err := readPostFile(filepath.Join(s.PostsDir, e.Name()), slug) - if err == nil { - posts = append(posts, p) - } - } - return posts, nil +// 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) { @@ -286,67 +368,33 @@ func (s *Server) renderError(c *gin.Context, msg string) { }) } -// postFromForm reads a post from an HTTP form submission. func postFromForm(c *gin.Context) Post { - return Post{ - Title: strings.TrimSpace(c.PostForm("title")), - Date: strings.TrimSpace(c.PostForm("date")), - Tags: strings.TrimSpace(c.PostForm("tags")), - Content: c.PostForm("content"), - } -} - -// readPostFile reads and parses a markdown file into a post struct. -func readPostFile(path, slug string) (Post, error) { - data, err := os.ReadFile(path) - if err != nil { - return Post{}, err - } - p := Post{Slug: slug} - body := string(data) - - // Parse frontmatter manually — keep it simple. - if strings.HasPrefix(body, "---\n") { - end := strings.Index(body[4:], "\n---\n") - if end >= 0 { - fm := body[4 : end+4] - p.Content = strings.TrimSpace(body[end+9:]) - for _, line := range strings.Split(fm, "\n") { - k, v, ok := strings.Cut(line, ":") - if !ok { - continue - } - k = strings.TrimSpace(k) - v = strings.TrimSpace(v) - switch k { - case "title": - p.Title = v - case "date": - p.Date = v - case "tags": - p.Tags = v - } + 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) } } - } else { - p.Content = body } - return p, nil -} -// writePostFile writes a post to disk as a markdown file with frontmatter. -func writePostFile(path string, p Post) error { - date := p.Date - if date == "" { - date = time.Now().Format("2006-01-02") + 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, } - content := fmt.Sprintf("---\ntitle: %s\ndate: %s\ntags: %s\n---\n%s\n", - p.Title, date, p.Tags, p.Content) - return os.WriteFile(path, []byte(content), 0644) } // 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) @@ -361,14 +409,7 @@ func slugify(title string) string { // mustParseTemplates loads admin templates from the embedded filesystem. func mustParseTemplates() *template.Template { funcs := template.FuncMap{ - "splitTags": func(s string) []string { - var tags []string - for _, t := range strings.Split(s, ",") { - t = strings.TrimSpace(t) - if t != "" { - tags = append(tags, t) - } - } + "splitTags": func(tags []string) []string { return tags }, } @@ -379,3 +420,40 @@ func mustParseTemplates() *template.Template { } 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, " ") +} -- cgit v1.3