// 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" "nebbet.no/internal/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 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 } // 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) } } // checkAuth performs HTTP Basic authentication against the passwords file. // Returns true if the request is authorised (or if auth is disabled). func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool { if s.AuthFile == "" { return true } if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) { return true // no passwords file → no auth required } 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, "list", map[string]any{"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, "form", map[string]any{ "Title": "New Post", "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, "form", map[string]any{ "Title": "Edit Post", "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) } // ── Helpers ─────────────────────────────────────────────────────────────────── 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, "error", map[string]any{"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 } // ── Templates ───────────────────────────────────────────────────────────────── const adminCSS = ` * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; } .layout { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; } nav { display: flex; align-items: center; gap: 1.5rem; padding: 0.75rem 0; border-bottom: 1px solid #ddd; margin-bottom: 2rem; } nav a { text-decoration: none; color: #555; font-size: 0.9rem; } nav a:hover { color: #000; } nav .brand { font-weight: 700; color: #000; font-size: 1rem; } h1 { font-size: 1.4rem; margin-bottom: 1.25rem; } h2 { font-size: 1.1rem; margin-bottom: 0.75rem; } table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); } th { background: #f0f0f0; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; color: #666; padding: 0.6rem 0.9rem; text-align: left; } td { padding: 0.7rem 0.9rem; border-top: 1px solid #eee; vertical-align: middle; } tr:hover td { background: #fafafa; } .tags { display: flex; flex-wrap: wrap; gap: 0.3rem; } .tag { background: #e8f0fe; color: #2d5fc4; font-size: 0.75rem; padding: 0.1rem 0.45rem; border-radius: 3px; } .actions { display: flex; gap: 0.5rem; } a.btn, button.btn { display: inline-block; padding: 0.35rem 0.75rem; font-size: 0.82rem; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; font-family: inherit; } .btn-primary { background: #2d5fc4; color: #fff; } .btn-primary:hover { background: #1e4bad; } .btn-secondary { background: #e0e0e0; color: #333; } .btn-secondary:hover { background: #ccc; } .btn-danger { background: #dc2626; color: #fff; } .btn-danger:hover { background: #b91c1c; } .empty { text-align: center; padding: 3rem; color: #999; font-size: 0.9rem; } form { background: #fff; padding: 1.5rem; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,.08); max-width: 760px; } label { display: block; font-size: 0.85rem; font-weight: 600; color: #444; margin-bottom: 0.3rem; margin-top: 1rem; } label:first-child { margin-top: 0; } input[type=text], input[type=date], textarea { width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.9rem; font-family: inherit; } input[type=text]:focus, input[type=date]:focus, textarea:focus { outline: none; border-color: #2d5fc4; box-shadow: 0 0 0 2px rgba(45,95,196,.15); } textarea { font-family: 'Menlo', 'Consolas', monospace; font-size: 0.85rem; height: 340px; resize: vertical; line-height: 1.5; } .hint { font-size: 0.75rem; color: #888; margin-top: 0.25rem; } .form-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; } .alert { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; } ` const baseTemplate = ` Admin — {{.Title}}
{{block "content" .}}{{end}}
` const listTemplate = `{{define "list"}}` + baseTemplate + `{{end}} {{define "content"}}

Posts

{{if .Posts}} {{range .Posts}} {{end}}
Title Date Tags
{{.Title}} {{.Date}} {{if .Tags}}
{{range (splitTags .Tags)}}{{.}}{{end}}
{{end}}
Edit
{{else}}
No posts yet. Create one.
{{end}} {{end}}` const formTemplate = `{{define "form"}}` + baseTemplate + `{{end}} {{define "content"}}

{{.Title}}

Comma-separated list of tags.

Cancel
{{end}}` const errorTemplate = `{{define "error"}} Error

Error

{{.Message}}

Go back

{{end}}` 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 }, } return template.Must( template.New("admin").Funcs(funcs).Parse( listTemplate + formTemplate + errorTemplate, ), ) }