summaryrefslogtreecommitdiffstats
path: root/internal/admin
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/auth/auth.go159
-rw-r--r--internal/admin/server.go137
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"),
}
}