// 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 } // ServeHTTP implements http.Handler. Expected to be mounted with a stripped // prefix, e.g.: http.StripPrefix("/admin", srv) func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(w, r) { return } if s.tmpl == nil { s.tmpl = mustParseTemplates() } path := strings.TrimRight(r.URL.Path, "/") switch { case path == "" || path == "/": s.handleList(w, r) case path == "/new": s.handleNew(w, r) case strings.HasSuffix(path, "/edit"): slug := strings.TrimPrefix(strings.TrimSuffix(path, "/edit"), "/") s.handleEdit(w, r, slug) case strings.HasSuffix(path, "/delete"): slug := strings.TrimPrefix(strings.TrimSuffix(path, "/delete"), "/") s.handleDelete(w, r, slug) default: http.NotFound(w, r) } } func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool { if s.AuthFile == "" { return false } if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) { return false } a := auth.New(s.AuthFile) username, password, ok := r.BasicAuth() if ok { if valid, err := a.Verify(username, password); err == nil && valid { return true } } w.Header().Set("WWW-Authenticate", `Basic realm="Admin"`) http.Error(w, "Unauthorised", http.StatusUnauthorized) return false } // ── Handlers ───────────────────────────────────────────────────────────────── func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { posts, err := s.listPosts() if err != nil { http.Error(w, "Failed to list posts: "+err.Error(), http.StatusInternalServerError) return } s.render(w, "base", map[string]any{ "Title": "Posts", "ContentTemplate": "list-content", "Posts": posts, }) } func (s *Server) handleNew(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { s.handleNewPost(w, r) return } s.render(w, "base", map[string]any{ "Title": "New Post", "ContentTemplate": "form-content", "Action": "/admin/new", "Post": Post{Date: time.Now().Format("2006-01-02")}, "IsNew": true, }) } func (s *Server) handleNewPost(w http.ResponseWriter, r *http.Request) { p := postFromForm(r) if p.Title == "" { s.renderError(w, "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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } if _, err := os.Stat(mdPath); err == nil { s.renderError(w, fmt.Sprintf("Post %q already exists", p.Slug)) return } if err := writePostFile(mdPath, p); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.rebuild(mdPath) http.Redirect(w, r, "/admin/", http.StatusSeeOther) } func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request, slug string) { mdPath := filepath.Join(s.PostsDir, slug+".md") p, err := readPostFile(mdPath, slug) if err != nil { http.NotFound(w, r) return } if r.Method == http.MethodPost { updated := postFromForm(r) updated.Slug = slug // slug is immutable after creation if updated.Title == "" { s.renderError(w, "Title is required") return } if err := writePostFile(mdPath, updated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } s.rebuild(mdPath) http.Redirect(w, r, "/admin/", http.StatusSeeOther) return } s.render(w, "base", map[string]any{ "Title": "Edit Post", "ContentTemplate": "form-content", "Action": "/admin/" + slug + "/edit", "Post": p, "IsNew": false, }) } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, slug string) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } mdPath := filepath.Join(s.PostsDir, slug+".md") if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) { http.Error(w, err.Error(), http.StatusInternalServerError) return } if s.Builder != nil { _ = s.Builder.RemovePage(mdPath) } http.Redirect(w, r, "/admin/", http.StatusSeeOther) } 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) render(w http.ResponseWriter, name string, data map[string]any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *Server) renderError(w http.ResponseWriter, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) _ = s.tmpl.ExecuteTemplate(w, "base", map[string]any{ "Title": "Error", "ContentTemplate": "error-content", "Message": msg, }) } // postFromForm reads a post from an HTTP form submission. func postFromForm(r *http.Request) Post { _ = r.ParseForm() return Post{ Title: strings.TrimSpace(r.FormValue("title")), Date: strings.TrimSpace(r.FormValue("date")), Tags: strings.TrimSpace(r.FormValue("tags")), Content: r.FormValue("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 }