summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-03 14:22:23 +0200
committerivar <i@oiee.no>2026-04-03 14:22:23 +0200
commit33310be68544d3381ac6e9899790f4a106e17e8f (patch)
tree385102a7216ffba17d054f11032dae4447371d52 /docs/superpowers/plans
parenta8914a8f18c345e934bce93b37845a9dfe0ad73e (diff)
downloadnebbet.no-33310be68544d3381ac6e9899790f4a106e17e8f.tar.xz
nebbet.no-33310be68544d3381ac6e9899790f4a106e17e8f.zip
refactor: convert admin handlers to Gin context-based signatures
- Remove old ServeHTTP method (no longer needed with Gin routing) - Update all 6 handler methods to use *gin.Context instead of http.ResponseWriter, *http.Request - Convert handler signatures: handleList, handleNew, handleNewPost, handleEdit, handleDelete - Remove render() helper (use c.HTML() directly) - Update renderError() to accept gin.Context instead of http.ResponseWriter - Update postFromForm() to extract form data from gin.Context using c.PostForm() - Update main.go to use adminSrv.NewServer() and adminSrv.Engine() - All handlers now use Gin methods: c.HTML(), c.PostForm(), c.Param(), c.Redirect() - Path parameters now extracted via c.Param("slug") instead of function arguments - HTTP status codes and error handling fully migrated to Gin patterns Build verified: go build ./cmd/nebbet succeeds Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-04-03-gin-migration-plan.md739
1 files changed, 739 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-04-03-gin-migration-plan.md b/docs/superpowers/plans/2026-04-03-gin-migration-plan.md
new file mode 100644
index 0000000..6b95cac
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-03-gin-migration-plan.md
@@ -0,0 +1,739 @@
+# Gin Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Migrate the nebbet admin server from `net/http` to Gin with RESTful routes and middleware-based auth.
+
+**Architecture:** Replace the manual `ServeHTTP` handler with Gin's declarative routing. The `Server` struct gains a `*gin.Engine` field. Auth becomes reusable middleware. Route handlers update their signatures to use `*gin.Context`. Routes move under `/admin` namespace with RESTful patterns (GET/POST for forms, DELETE for removal).
+
+**Tech Stack:** Go, Gin, existing auth/template system (unchanged)
+
+---
+
+## File Structure
+
+**Files to modify:**
+- `go.mod` — Add Gin dependency
+- `internal/admin/server.go` — Core changes: NewServer(), middleware, handler rewrites
+- `cmd/nebbet/main.go` — Update cmdServe() to use NewServer()
+
+---
+
+### Task 1: Add Gin Dependency
+
+**Files:**
+- Modify: `go.mod`
+
+- [ ] **Step 1: Add Gin to go.mod**
+
+Run:
+```bash
+cd /Users/ivarlovlie/p/nebbet.no && go get github.com/gin-gonic/gin
+```
+
+Expected output: Gin module downloaded and added to go.mod
+
+- [ ] **Step 2: Verify go.mod was updated**
+
+Run:
+```bash
+grep "github.com/gin-gonic/gin" go.mod
+```
+
+Expected output: A line like `github.com/gin-gonic/gin v1.X.X` (version may vary)
+
+- [ ] **Step 3: Run go mod tidy**
+
+Run:
+```bash
+go mod tidy
+```
+
+Expected: No errors, go.sum updated
+
+---
+
+### Task 2: Refactor server.go — Add NewServer() Constructor
+
+**Files:**
+- Modify: `internal/admin/server.go` (top section with Server struct)
+
+- [ ] **Step 1: Update Server struct to include Gin engine**
+
+Replace the struct definition:
+
+```go
+// Server is an HTTP handler for 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
+}
+```
+
+- [ ] **Step 2: Add imports for Gin**
+
+Add to the imports section at the top of server.go:
+
+```go
+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"
+)
+```
+
+- [ ] **Step 3: Add NewServer() constructor function**
+
+Add this after the Server struct definition (before any methods):
+
+```go
+// 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
+}
+```
+
+---
+
+### Task 3: Refactor server.go — Add Auth Middleware
+
+**Files:**
+- Modify: `internal/admin/server.go` (replace checkAuth method)
+
+- [ ] **Step 1: Replace checkAuth with middleware function**
+
+Remove the old `checkAuth()` method and add this in its place:
+
+```go
+// 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()
+ }
+}
+```
+
+---
+
+### Task 4: Refactor server.go — Remove ServeHTTP and Update Handlers
+
+**Files:**
+- Modify: `internal/admin/server.go` (remove ServeHTTP, update handler signatures)
+
+- [ ] **Step 1: Remove the ServeHTTP method**
+
+Delete the entire `func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)` method (lines 45-70 in original).
+
+- [ ] **Step 2: Update handleList signature and implementation**
+
+Replace:
+```go
+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,
+ })
+}
+```
+
+With:
+```go
+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,
+ })
+}
+```
+
+- [ ] **Step 3: Update handleNew signature and implementation**
+
+Replace:
+```go
+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,
+ })
+}
+```
+
+With:
+```go
+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,
+ })
+}
+```
+
+- [ ] **Step 4: Update handleNewPost signature and implementation**
+
+Replace:
+```go
+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)
+}
+```
+
+With:
+```go
+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/")
+}
+```
+
+- [ ] **Step 5: Update handleEdit signature and implementation**
+
+Replace:
+```go
+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,
+ })
+}
+```
+
+With:
+```go
+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,
+ })
+}
+```
+
+- [ ] **Step 6: Update handleDelete signature and implementation**
+
+Replace:
+```go
+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)
+}
+```
+
+With:
+```go
+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/")
+}
+```
+
+---
+
+### Task 5: Refactor server.go — Update Helper Functions
+
+**Files:**
+- Modify: `internal/admin/server.go` (helper functions section)
+
+- [ ] **Step 1: Update render() helper**
+
+Replace:
+```go
+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)
+ }
+}
+```
+
+With:
+```go
+func (s *Server) render(c *gin.Context, name string, data gin.H) {
+ if err := s.tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ }
+}
+```
+
+- [ ] **Step 2: Update renderError() helper**
+
+Replace:
+```go
+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,
+ })
+}
+```
+
+With:
+```go
+func (s *Server) renderError(c *gin.Context, msg string) {
+ c.HTML(http.StatusBadRequest, "base", gin.H{
+ "Title": "Error",
+ "ContentTemplate": "error-content",
+ "Message": msg,
+ })
+}
+```
+
+- [ ] **Step 3: Update postFromForm() to accept *gin.Context**
+
+Replace:
+```go
+// 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"),
+ }
+}
+```
+
+With:
+```go
+// 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"),
+ }
+}
+```
+
+---
+
+### Task 6: Update cmd/nebbet/main.go
+
+**Files:**
+- Modify: `cmd/nebbet/main.go` (cmdServe function)
+
+- [ ] **Step 1: Update import to include Gin**
+
+Add `"github.com/gin-gonic/gin"` to the imports section:
+
+```go
+import (
+ "flag"
+ "fmt"
+ "net/http"
+ "os"
+
+ "github.com/gin-gonic/gin"
+
+ "nebbet.no/internal/admin"
+ "nebbet.no/internal/admin/auth"
+ "nebbet.no/internal/builder"
+ "nebbet.no/internal/db"
+)
+```
+
+- [ ] **Step 2: Rewrite cmdServe() function**
+
+Replace the entire `cmdServe()` function with:
+
+```go
+func cmdServe(args []string) {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ port := fs.String("port", "8080", "port to listen on")
+ _ = fs.Parse(args)
+
+ b := mustBuilder()
+ defer b.MetaDB.Close()
+ defer b.SearchDB.Close()
+
+ // Initial build.
+ if err := b.BuildAll(); err != nil {
+ fmt.Fprintln(os.Stderr, "build error:", err)
+ os.Exit(1)
+ }
+
+ // Watch in background.
+ go func() {
+ if err := b.Watch(); err != nil {
+ fmt.Fprintln(os.Stderr, "watch error:", err)
+ }
+ }()
+
+ // Create admin server with Gin
+ adminSrv := admin.NewServer(postsDir, passwordFile, b)
+ engine := adminSrv.Engine()
+
+ // Serve dependencies (lib/)
+ engine.Static("/lib", "lib")
+
+ // Serve static site from public/
+ engine.Static("/", outputDir)
+
+ addr := ":" + *port
+ fmt.Printf("listening on http://localhost%s\n", addr)
+ fmt.Printf(" public site: http://localhost%s/\n", addr)
+ fmt.Printf(" admin UI: http://localhost%s/admin/\n", addr)
+ if err := engine.Run(addr); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+```
+
+---
+
+### Task 7: Test the Migration
+
+**Files:**
+- Test: Manual testing (no automated tests needed for this migration)
+
+- [ ] **Step 1: Build the project**
+
+Run:
+```bash
+cd /Users/ivarlovlie/p/nebbet.no && go build ./cmd/nebbet
+```
+
+Expected: Binary builds without errors
+
+- [ ] **Step 2: Start the server**
+
+Run:
+```bash
+go run ./cmd/nebbet serve --port 8080
+```
+
+Expected output:
+```
+listening on http://localhost:8080
+ public site: http://localhost:8080/
+ admin UI: http://localhost:8080/admin/
+```
+
+- [ ] **Step 3: Test list posts endpoint**
+
+Open browser: `http://localhost:8080/admin/`
+
+Expected: Posts list page loads (may show empty if no posts)
+
+- [ ] **Step 4: Create a new post**
+
+- Navigate to `http://localhost:8080/admin/new`
+- Fill in: Title, Date, Tags, Content
+- Click "Create" (or submit button)
+
+Expected: Redirects to `/admin/`, new post appears in list
+
+- [ ] **Step 5: Edit a post**
+
+- Click edit button on a post in the list
+- Modify a field (e.g., title)
+- Submit
+
+Expected: Redirects to `/admin/`, post shows updated content
+
+- [ ] **Step 6: Delete a post**
+
+- On the edit page, click delete button
+- Confirm deletion
+
+Expected: Redirects to `/admin/`, post no longer in list
+
+- [ ] **Step 7: Test static file serving**
+
+- Navigate to `http://localhost:8080/`
+- Check that public site loads
+- Navigate to `http://localhost:8080/lib/` to verify lib/ is served
+
+Expected: Both static file paths work
+
+- [ ] **Step 8: Test auth**
+
+- Stop the server (Ctrl+C)
+- In another terminal, create a user if needed:
+
+```bash
+go run ./cmd/nebbet user add testuser
+# Enter a password when prompted
+```
+
+- Start the server again
+- Visit `http://localhost:8080/admin/`
+- When prompted for auth, enter invalid credentials
+
+Expected: 401 Unauthorized error
+
+- Enter valid credentials (testuser + password)
+
+Expected: Admin page loads
+
+---
+
+## Self-Review Checklist
+
+✓ **Spec coverage:**
+- Route migration (Task 2 NewServer) ✓
+- Auth middleware (Task 3) ✓
+- Handler refactoring (Task 4) ✓
+- Helper functions (Task 5) ✓
+- CLI integration (Task 6) ✓
+- Testing (Task 7) ✓
+
+✓ **Placeholder scan:** No TBDs, all code is complete and specific
+
+✓ **Type consistency:**
+- Handler signatures consistently `(c *gin.Context)`
+- Form helpers use `postFromForm(c)`
+- Template rendering uses `c.HTML()`
+- No mismatched method names
+
+✓ **Function signatures match:** All handlers in NewServer() route definitions match their actual signatures