From aa23774abb90c168c9ba2559d6bf381bc9fc55ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 10:44:10 +0000 Subject: Add post management admin UI and switch to modernc.org/sqlite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom CGO sqlite driver with modernc.org/sqlite (registered as "sqlite3"); a local compat shim under compat/sqlite/ provides the same import path and WAL-mode behaviour using system libsqlite3 while network access is unavailable — swapping to the real pure-Go package later only requires removing the replace directive and running go get. - Add internal/admin/server.go: HTTP handler for /admin/ that serves a server-side-rendered post management UI (list, new, edit, delete). Posts are stored as Markdown files under content/posts/ and rebuilt via the existing Builder after every write. Basic auth is checked against the .passwords file when it exists. - Add cmd/nebbet/main.go: unified CLI with build, watch, serve (HTTP server with admin + file server + watch), and user subcommands. - Update builder.BuildAll to skip content/admin/ — admin pages are served dynamically and must never appear in the static output directory. - Mark content/admin/index.md as draft so the old static placeholder is not built even if the admin skip logic is bypassed. - Fix .gitignore: use /nebbet (root-only) so the pattern no longer accidentally ignores the cmd/nebbet/ source directory. https://claude.ai/code/session_01WLuSGxJhNs2cFM2zJzSsTx --- internal/admin/server.go | 476 ++++++++++++++++++++++++++++++++++++++++++++ internal/builder/builder.go | 5 + internal/db/meta.go | 4 +- internal/db/search.go | 4 +- 4 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 internal/admin/server.go (limited to 'internal') 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 = ` + + + + + Admin — {{.Title}} + + + +
+ + {{block "content" .}}{{end}} +
+ +` + +const listTemplate = `{{define "list"}}` + baseTemplate + `{{end}} +{{define "content"}} +

Posts

+ {{if .Posts}} + + + + + + + + + + + {{range .Posts}} + + + + + + + {{end}} + +
TitleDateTags
{{.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, + ), + ) +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 40be377..59bb71b 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -62,6 +62,11 @@ func (b *Builder) BuildAll() error { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { return err } + // Skip content/admin/ — served dynamically by the admin HTTP server. + rel, _ := filepath.Rel(b.ContentDir, path) + if strings.HasPrefix(filepath.ToSlash(rel), "admin/") || filepath.ToSlash(rel) == "admin" { + return nil + } return b.BuildFile(path, importMap) }) } diff --git a/internal/db/meta.go b/internal/db/meta.go index 4857234..33e0da3 100644 --- a/internal/db/meta.go +++ b/internal/db/meta.go @@ -6,7 +6,7 @@ import ( "strings" "time" - _ "nebbet.no/internal/sqlitedrv" + _ "modernc.org/sqlite" ) type MetaDB struct { @@ -23,7 +23,7 @@ type PageMeta struct { } func OpenMeta(path string) (*MetaDB, error) { - db, err := sql.Open("sqlite", path) + db, err := sql.Open("sqlite3", path) if err != nil { return nil, err } diff --git a/internal/db/search.go b/internal/db/search.go index b2c9b49..545645e 100644 --- a/internal/db/search.go +++ b/internal/db/search.go @@ -3,7 +3,7 @@ package db import ( "database/sql" - _ "nebbet.no/internal/sqlitedrv" + _ "modernc.org/sqlite" ) type SearchDB struct { @@ -23,7 +23,7 @@ type SearchResult struct { } func OpenSearch(path string) (*SearchDB, error) { - db, err := sql.Open("sqlite", path) + db, err := sql.Open("sqlite3", path) if err != nil { return nil, err } -- cgit v1.3