summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:31:32 +0200
committerivar <i@oiee.no>2026-04-04 16:31:32 +0200
commit275d6fce19a33b68abb22ed41ded58fa65df1834 (patch)
tree34b6ec4a016a24635e0f143b247def7ffd13ed00
parente952d06567369a0511a711c0e5d3f92dce3fb7eb (diff)
downloadnebbet.no-275d6fce19a33b68abb22ed41ded58fa65df1834.tar.xz
nebbet.no-275d6fce19a33b68abb22ed41ded58fa65df1834.zip
feat: support slug editing and rename in admin UI with redirect and cache invalidation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/admin/server.go384
1 files changed, 231 insertions, 153 deletions
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, " ")
+}