diff options
Diffstat (limited to 'internal/admin')
| -rw-r--r-- | internal/admin/auth/auth.go | 159 | ||||
| -rw-r--r-- | internal/admin/server.go | 137 |
2 files changed, 232 insertions, 64 deletions
diff --git a/internal/admin/auth/auth.go b/internal/admin/auth/auth.go new file mode 100644 index 0000000..b0de7d9 --- /dev/null +++ b/internal/admin/auth/auth.go @@ -0,0 +1,159 @@ +// Package auth manages a htpasswd-compatible password file (bcrypt entries). +// The file format is one "username:$2a$..." entry per line. +// nginx auth_basic accepts this file directly via auth_basic_user_file. +package auth + +import ( + "bufio" + "fmt" + "os" + "strings" + "syscall" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +type Auth struct { + path string +} + +func New(path string) *Auth { return &Auth{path: path} } + +func (a *Auth) AddUser(username string) error { + users, err := a.read() + if err != nil && !os.IsNotExist(err) { + return err + } + if _, exists := users[username]; exists { + return fmt.Errorf("user %q already exists", username) + } + pw, err := readPassword("Password: ") + if err != nil { + return err + } + confirm, err := readPassword("Confirm: ") + if err != nil { + return err + } + if pw != confirm { + return fmt.Errorf("passwords do not match") + } + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + return err + } + users[username] = string(hash) + return a.write(users) +} + +func (a *Auth) ChangePassword(username string) error { + users, err := a.read() + if err != nil { + return err + } + if _, exists := users[username]; !exists { + return fmt.Errorf("user %q not found", username) + } + pw, err := readPassword("New password: ") + if err != nil { + return err + } + confirm, err := readPassword("Confirm: ") + if err != nil { + return err + } + if pw != confirm { + return fmt.Errorf("passwords do not match") + } + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + return err + } + users[username] = string(hash) + return a.write(users) +} + +func (a *Auth) DeleteUser(username string) error { + users, err := a.read() + if err != nil { + return err + } + if _, exists := users[username]; !exists { + return fmt.Errorf("user %q not found", username) + } + delete(users, username) + return a.write(users) +} + +func (a *Auth) ListUsers() ([]string, error) { + users, err := a.read() + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + names := make([]string, 0, len(users)) + for k := range users { + names = append(names, k) + } + return names, nil +} + +func (a *Auth) Verify(username, password string) (bool, error) { + users, err := a.read() + if err != nil { + return false, err + } + hash, ok := users[username] + if !ok { + return false, nil + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + if err == bcrypt.ErrMismatchedHashAndPassword { + return false, nil + } + return err == nil, err +} + +func (a *Auth) read() (map[string]string, error) { + f, err := os.Open(a.path) + if err != nil { + return nil, err + } + defer f.Close() + users := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + user, hash, ok := strings.Cut(line, ":") + if ok { + users[user] = hash + } + } + return users, scanner.Err() +} + +func (a *Auth) write(users map[string]string) error { + f, err := os.OpenFile(a.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + w := bufio.NewWriter(f) + for user, hash := range users { + fmt.Fprintf(w, "%s:%s\n", user, hash) + } + return w.Flush() +} + +func readPassword(prompt string) (string, error) { + fmt.Print(prompt) + b, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + return string(b), err +} diff --git a/internal/admin/server.go b/internal/admin/server.go index 410560f..33413ee 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -122,37 +122,37 @@ func (s *Server) authMiddleware() gin.HandlerFunc { // ── Handlers ───────────────────────────────────────────────────────────────── -func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleList(c *gin.Context) { posts, err := s.listPosts() if err != nil { - http.Error(w, "Failed to list posts: "+err.Error(), http.StatusInternalServerError) + c.HTML(http.StatusInternalServerError, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": "Failed to list posts: " + err.Error(), + }) return } - s.render(w, "base", map[string]any{ - "Title": "Posts", - "ContentTemplate": "list-content", - "Posts": posts, + c.HTML(http.StatusOK, "base", gin.H{ + "Title": "Posts", + "ContentTemplate": "list-content", + "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, "base", map[string]any{ - "Title": "New Post", - "ContentTemplate": "form-content", - "Action": "/admin/new", - "Post": Post{Date: time.Now().Format("2006-01-02")}, - "IsNew": true, +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, }) } -func (s *Server) handleNewPost(w http.ResponseWriter, r *http.Request) { - p := postFromForm(r) +func (s *Server) handleNewPost(c *gin.Context) { + p := postFromForm(c) if p.Title == "" { - s.renderError(w, "Title is required") + s.renderError(c, "Title is required") return } if p.Slug == "" { @@ -161,67 +161,86 @@ func (s *Server) handleNewPost(w http.ResponseWriter, r *http.Request) { mdPath := filepath.Join(s.PostsDir, p.Slug+".md") if err := os.MkdirAll(s.PostsDir, 0755); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + 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(w, fmt.Sprintf("Post %q already exists", p.Slug)) + s.renderError(c, fmt.Sprintf("Post %q already exists", p.Slug)) return } if err := writePostFile(mdPath, p); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.HTML(http.StatusInternalServerError, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": err.Error(), + }) return } s.rebuild(mdPath) - http.Redirect(w, r, "/admin/", http.StatusSeeOther) + c.Redirect(http.StatusSeeOther, "/admin/") } -func (s *Server) handleEdit(w http.ResponseWriter, r *http.Request, slug string) { +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 { - http.NotFound(w, r) + c.HTML(http.StatusNotFound, "base", gin.H{ + "Title": "Not Found", + "ContentTemplate": "error-content", + "Message": "Post not found", + }) return } - if r.Method == http.MethodPost { - updated := postFromForm(r) + + if c.Request.Method == http.MethodPost { + updated := postFromForm(c) updated.Slug = slug // slug is immutable after creation if updated.Title == "" { - s.renderError(w, "Title is required") + s.renderError(c, "Title is required") return } if err := writePostFile(mdPath, updated); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.HTML(http.StatusInternalServerError, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": err.Error(), + }) return } s.rebuild(mdPath) - http.Redirect(w, r, "/admin/", http.StatusSeeOther) + c.Redirect(http.StatusSeeOther, "/admin/") return } - s.render(w, "base", map[string]any{ - "Title": "Edit Post", - "ContentTemplate": "form-content", - "Action": "/admin/" + slug + "/edit", - "Post": p, - "IsNew": false, + c.HTML(http.StatusOK, "base", gin.H{ + "Title": "Edit Post", + "ContentTemplate": "form-content", + "Action": "/admin/" + slug, + "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 - } +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) { - http.Error(w, err.Error(), http.StatusInternalServerError) + c.HTML(http.StatusInternalServerError, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": err.Error(), + }) return } if s.Builder != nil { _ = s.Builder.RemovePage(mdPath) } - http.Redirect(w, r, "/admin/", http.StatusSeeOther) + c.Redirect(http.StatusSeeOther, "/admin/") } func (s *Server) rebuild(mdPath string) { @@ -254,31 +273,21 @@ func (s *Server) listPosts() ([]Post, error) { 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, "base", map[string]any{ - "Title": "Error", - "ContentTemplate": "error-content", - "Message": msg, +func (s *Server) renderError(c *gin.Context, msg string) { + c.HTML(http.StatusBadRequest, "base", gin.H{ + "Title": "Error", + "ContentTemplate": "error-content", + "Message": msg, }) } // postFromForm reads a post from an HTTP form submission. -func postFromForm(r *http.Request) Post { - _ = r.ParseForm() +func postFromForm(c *gin.Context) Post { return Post{ - Title: strings.TrimSpace(r.FormValue("title")), - Date: strings.TrimSpace(r.FormValue("date")), - Tags: strings.TrimSpace(r.FormValue("tags")), - Content: r.FormValue("content"), + Title: strings.TrimSpace(c.PostForm("title")), + Date: strings.TrimSpace(c.PostForm("date")), + Tags: strings.TrimSpace(c.PostForm("tags")), + Content: c.PostForm("content"), } } |
