summaryrefslogtreecommitdiffstats
path: root/internal/admin/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/server.go')
-rw-r--r--internal/admin/server.go476
1 files changed, 476 insertions, 0 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go
new file mode 100644
index 0000000..858d498
--- /dev/null
+++ b/internal/admin/server.go
@@ -0,0 +1,476 @@
+// 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 = `<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Admin — {{.Title}}</title>
+ <style>` + adminCSS + `</style>
+</head>
+<body>
+<div class="layout">
+ <nav>
+ <span class="brand">Admin</span>
+ <a href="/admin/">Posts</a>
+ <a href="/admin/new">New Post</a>
+ </nav>
+ {{block "content" .}}{{end}}
+</div>
+</body>
+</html>`
+
+const listTemplate = `{{define "list"}}` + baseTemplate + `{{end}}
+{{define "content"}}
+ <h1>Posts</h1>
+ {{if .Posts}}
+ <table>
+ <thead>
+ <tr>
+ <th>Title</th>
+ <th>Date</th>
+ <th>Tags</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Posts}}
+ <tr>
+ <td>{{.Title}}</td>
+ <td>{{.Date}}</td>
+ <td>
+ {{if .Tags}}
+ <div class="tags">
+ {{range (splitTags .Tags)}}<span class="tag">{{.}}</span>{{end}}
+ </div>
+ {{end}}
+ </td>
+ <td>
+ <div class="actions">
+ <a href="/admin/{{.Slug}}/edit" class="btn btn-secondary">Edit</a>
+ <form method="POST" action="/admin/{{.Slug}}/delete"
+ onsubmit="return confirm('Delete {{.Title}}?')">
+ <button type="submit" class="btn btn-danger">Delete</button>
+ </form>
+ </div>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{else}}
+ <div class="empty">No posts yet. <a href="/admin/new">Create one.</a></div>
+ {{end}}
+{{end}}`
+
+const formTemplate = `{{define "form"}}` + baseTemplate + `{{end}}
+{{define "content"}}
+ <h1>{{.Title}}</h1>
+ <form method="POST" action="{{.Action}}">
+ <label for="title">Title</label>
+ <input type="text" id="title" name="title" value="{{.Post.Title}}" required autofocus>
+
+ <label for="date">Date</label>
+ <input type="date" id="date" name="date" value="{{.Post.Date}}">
+
+ <label for="tags">Tags</label>
+ <input type="text" id="tags" name="tags" value="{{.Post.Tags}}"
+ placeholder="tag1, tag2, tag3">
+ <p class="hint">Comma-separated list of tags.</p>
+
+ <label for="content">Content (Markdown)</label>
+ <textarea id="content" name="content">{{.Post.Content}}</textarea>
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-primary">
+ {{if .IsNew}}Create Post{{else}}Save Changes{{end}}
+ </button>
+ <a href="/admin/" class="btn btn-secondary">Cancel</a>
+ </div>
+ </form>
+{{end}}`
+
+const errorTemplate = `{{define "error"}}<!DOCTYPE html>
+<html><head><meta charset="UTF-8"><title>Error</title>
+<style>body{font-family:system-ui;max-width:600px;margin:3rem auto;padding:0 1rem}</style>
+</head><body>
+<h2>Error</h2>
+<p>{{.Message}}</p>
+<p><a href="javascript:history.back()">Go back</a></p>
+</body></html>{{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,
+ ),
+ )
+}