// 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 ( "fmt" "html/template" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/gin-gonic/gin" "nebbet.no/internal/admin/auth" "nebbet.no/internal/builder" ) // 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 engine *gin.Engine tmpl *template.Template } // Post holds the metadata and content of a single Post. type Post struct { Slug string Title string Date string Tags string // comma-separated Content string // raw markdown body } // NewServer creates a new admin server with Gin routing and auth middleware. func NewServer(postsDir, authFile string, builder *builder.Builder) *Server { s := &Server{ PostsDir: postsDir, AuthFile: authFile, Builder: builder, } // Initialize Gin engine s.engine = gin.Default() // Load templates s.tmpl = mustParseTemplates() // Apply auth middleware to all routes s.engine.Use(s.authMiddleware()) // Register routes under /admin prefix admin := s.engine.Group("/admin") { // 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) } 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() return } // Skip auth if auth file doesn't exist if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) { c.Next() 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"}) 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"}) return } // Auth succeeded, continue c.Next() } } // ── Handlers ───────────────────────────────────────────────────────────────── func (s *Server) handleList(c *gin.Context) { posts, err := s.listPosts() if err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": "Failed to list posts: " + err.Error(), }) return } c.HTML(http.StatusOK, "base", gin.H{ "Title": "Posts", "ContentTemplate": "list-content", "Posts": posts, }) } 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, }) } 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) } 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(), }) return } if _, err := os.Stat(mdPath); err == nil { s.renderError(c, fmt.Sprintf("Post %q already exists", p.Slug)) return } if err := writePostFile(mdPath, p); err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": err.Error(), }) return } s.rebuild(mdPath) 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) if err != nil { c.HTML(http.StatusNotFound, "base", gin.H{ "Title": "Not Found", "ContentTemplate": "error-content", "Message": "Post not found", }) return } 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 { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": err.Error(), }) return } s.rebuild(mdPath) c.Redirect(http.StatusSeeOther, "/admin/") return } 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") mdPath := filepath.Join(s.PostsDir, slug+".md") if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": err.Error(), }) 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) } 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 } func (s *Server) renderError(c *gin.Context, msg string) { c.HTML(http.StatusBadRequest, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": msg, }) } // 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 } } } } 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") } 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]+`) 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 templates/admin/ directory. 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) } } return tags }, } // Load all .html files from templates/admin/ directory tmpl, err := template.New("admin").Funcs(funcs).ParseGlob("templates/admin/*.html") if err != nil { panic(fmt.Sprintf("failed to parse admin templates: %v", err)) } return tmpl }