diff options
| author | ivar <i@oiee.no> | 2026-04-04 16:34:46 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-04 16:34:46 +0200 |
| commit | a6355e7a6530af3335c4cd8af05f1e9c8b978169 (patch) | |
| tree | c9d920d1e996ef1c42d3455825731598df6b56c2 /docs | |
| parent | 8a093aacd162d3fd9f142b53aab9edfa061fd66a (diff) | |
| download | nebbet.no-a6355e7a6530af3335c4cd8af05f1e9c8b978169.tar.xz nebbet.no-a6355e7a6530af3335c4cd8af05f1e9c8b978169.zip | |
.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-04-03-gin-migration-plan.md | 739 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-04-03-gin-migration-design.md | 198 |
2 files changed, 0 insertions, 937 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 deleted file mode 100644 index 6b95cac..0000000 --- a/docs/superpowers/plans/2026-04-03-gin-migration-plan.md +++ /dev/null @@ -1,739 +0,0 @@ -# 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 diff --git a/docs/superpowers/specs/2026-04-03-gin-migration-design.md b/docs/superpowers/specs/2026-04-03-gin-migration-design.md deleted file mode 100644 index 41163a3..0000000 --- a/docs/superpowers/specs/2026-04-03-gin-migration-design.md +++ /dev/null @@ -1,198 +0,0 @@ ---- -name: Gin Migration Design -description: Migrate admin server from net/http to Gin framework with RESTful routes and middleware-based auth -type: implementation -date: 2026-04-03 ---- - -# Gin Migration Design - -## Overview - -Migrate the nebbet admin server from Go's `net/http` package to the Gin web framework. The migration will: -- Replace manual route handling with Gin's declarative routing -- Reorganize routes to follow RESTful conventions -- Extract auth into reusable middleware -- Preserve all existing functionality (auth, template rendering, post management) -- Keep UI behavior identical from the user's perspective - -## Goals - -1. **Cleaner routing:** Replace manual `ServeHTTP` switch with Gin's route declarations -2. **Better framework:** Leverage Gin for middleware, error handling, and future extensibility -3. **RESTful design:** Modernize route structure while keeping templates server-rendered - -## Non-Goals - -- Rewriting the frontend to a JS framework -- Adding new features beyond the migration -- Changing auth format or password file structure -- Changing how posts are stored or built - -## Architecture - -### Current State - -The `admin.Server` implements `http.Handler` with manual routing in `ServeHTTP()`: -``` -GET/POST /admin → list posts -GET/POST /admin/new → create form / create post -GET/POST /admin/{slug}/edit → edit form / update post -POST /admin/{slug}/delete → delete post -``` - -Auth is checked manually via `checkAuth()` for every request. - -### Target State - -Replace with Gin routing and middleware: -- `Server` struct holds a `*gin.Engine` -- `NewServer()` initializes Gin with routes and middleware -- Auth middleware wraps all admin routes -- Handlers call `gin.Context` instead of `http.ResponseWriter` - -**New route structure (RESTful, under `/admin` namespace):** -``` -GET /admin/ → list posts -GET /admin/new → create form -POST /admin/ → create post -GET /admin/:slug → edit form -POST /admin/:slug → update post -DELETE /admin/:slug → delete post -``` - -## Implementation Details - -### File: `internal/admin/server.go` - -#### Server Struct -- Add field: `engine *gin.Engine` -- Keep existing fields: `PostsDir`, `AuthFile`, `Builder`, `tmpl` - -#### Constructor: `NewServer(postsDir, authFile string, builder *builder.Builder) *Server` -- Create and configure Gin engine -- Register middleware (auth) -- Register all routes -- Return `*Server` - -#### Auth Middleware -- Extracted from `checkAuth()` into a middleware function -- Signature: `func (s *Server) authMiddleware() gin.HandlerFunc` -- Logic: - - Skip auth if `AuthFile` is empty or doesn't exist - - Extract Basic Auth credentials - - Call `auth.Verify(username, password)` - - Send 401 + `WWW-Authenticate` header on failure - - Call `c.Next()` to proceed on success - -#### Route Handlers -Keep existing handler functions, update signatures: -- `handleList(c *gin.Context)` — render post list -- `handleNew(c *gin.Context)` — GET shows form, POST creates post -- `handleNewPost(c *gin.Context)` — handle POST /new (merge with `handleNew` or keep separate) -- `handleEdit(c *gin.Context)` — GET shows form, POST updates post -- `handleDelete(c *gin.Context)` — DELETE removes post - -For GET requests that show forms, continue using `c.HTML()` with the template and data map. -For POST requests, validate form data, write to disk, rebuild, and redirect to `/`. - -#### Helper Functions -Keep existing: -- `listPosts()` — read `.md` files from PostsDir -- `render(c *gin.Context, name string, data map[string]any)` — render template (adapt to use `c.HTML()`) -- `renderError(c *gin.Context, msg string)` — render error template -- `postFromForm(c *gin.Context) Post` — extract form values from `c.PostForm()` -- `readPostFile()`, `writePostFile()`, `slugify()` — unchanged -- `mustParseTemplates()` — unchanged - -### File: `cmd/nebbet/main.go` - -#### Changes in `cmdServe()` -Instead of: -```go -adminSrv := &admin.Server{...} -mux := http.NewServeMux() -mux.Handle("/admin/", http.StripPrefix("/admin", adminSrv)) -mux.Handle("/lib/", ...) -mux.Handle("/", ...) -http.ListenAndServe(addr, mux) -``` - -Change to: -```go -adminSrv := admin.NewServer(postsDir, passwordFile, b) -engine := adminSrv.Engine() // or keep internal, add to main router - -// Either: -// 1. Use adminSrv.Engine directly (if it handles all routes) -// 2. Create a main Gin router and nest adminSrv's routes - -// Handle /lib/ and static files as Gin routes or separate handlers -``` - -Exact approach depends on whether we keep `/lib` and public site serving separate or consolidate into one Gin router. **Recommended:** single Gin router for consistency. - -## Route Mapping - -| Old Route | New Route | Method | Action | -|-----------|-----------|--------|--------| -| `/admin/` | `/admin/` | GET | List posts | -| `/admin/new` | `/admin/new` | GET | Show create form | -| `/admin/new` | `/admin/` | POST | Create post | -| `/admin/{slug}/edit` | `/admin/{slug}` | GET | Show edit form | -| `/admin/{slug}/edit` | `/admin/{slug}` | POST | Update post | -| `/admin/{slug}/delete` | `/admin/{slug}` | DELETE | Delete post | - -**HTML Form Handling:** Standard HTML forms only support GET/POST. For DELETE: -- **Option A:** Keep POST with custom handling (check method override or hidden field) -- **Option B:** Use POST `/admin/{slug}?method=delete` and parse in handler - -Recommended: keep as POST for form compatibility, or use POST with Gin's `c.PostForm("_method")` check. - -## Dependencies - -- Add `github.com/gin-gonic/gin` to `go.mod` -- No changes to other dependencies - -## Backwards Compatibility - -- **URLs:** Admin routes change (listed above). Bookmarks will break, but it's an internal admin interface. -- **Auth:** No changes. Password file format and Basic Auth remain the same. -- **Posts:** No changes. Markdown files, frontmatter, build process unchanged. -- **Public site:** No changes. Static files served the same way. - -## Testing Strategy - -- **Manual:** Create/edit/delete posts via the admin UI, verify rebuilds work -- **Auth:** Test Basic Auth with valid/invalid credentials -- **Forms:** Verify all form fields (title, date, tags, content) are captured and saved correctly -- **Errors:** Verify error handling for missing posts, invalid input, file write failures - -## Implementation Order - -1. Add Gin dependency to `go.mod` -2. Create `NewServer()` constructor and auth middleware in `server.go` -3. Register routes in `NewServer()` -4. Convert handler signatures to `*gin.Context` -5. Update `render()` and helper functions to work with Gin -6. Update `cmd/nebbet/main.go` to use `NewServer()` -7. Verify all routes work locally -8. Test admin UI end-to-end - -## Risks & Mitigation - -| Risk | Mitigation | -|------|-----------| -| Breaking the admin UI during migration | Test each route in a browser after updating | -| Form submission issues (e.g., multipart/form-data) | Use `c.PostForm()` and test file uploads if used | -| Auth middleware interfering with static files | Apply middleware only to admin routes, not `/lib` or `/` | -| Template rendering errors | Keep template loading and execution the same | - -## Success Criteria - -- ✓ All admin routes work with Gin -- ✓ Auth middleware blocks unauthorized access -- ✓ Create/edit/delete posts works end-to-end -- ✓ Posts rebuild after create/edit/delete -- ✓ No changes to password file format or post storage -- ✓ Static file serving (`/lib`, public site) unchanged |
