# 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