Files
markdown-hub/internal/api/handlers.go
T
anders f58ac04069 Add LDAP authentication
- LDAP bind + search auth with auto-create local user
- Falls back to local auth if LDAP not configured or fails
- Configurable via MH_LDAP_* environment variables
- Supports ldap:// and ldaps:// with optional TLS skip
- go-ldap/ldap/v3 dependency added
2026-05-27 00:00:12 +02:00

532 lines
15 KiB
Go

package api
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
"markdownhub/internal/auth"
"markdownhub/internal/files"
"markdownhub/internal/git"
)
const maxBodySize = 10 * 1024 * 1024 // 10MB
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func decodeBody(r *http.Request, v interface{}) error {
defer r.Body.Close()
limited := io.LimitReader(r.Body, maxBodySize)
return json.NewDecoder(limited).Decode(v)
}
// Simple rate limiter for login
var loginAttempts = struct {
sync.Mutex
m map[string][]time.Time
}{m: make(map[string][]time.Time)}
func isRateLimited(ip string) bool {
loginAttempts.Lock()
defer loginAttempts.Unlock()
now := time.Now()
cutoff := now.Add(-5 * time.Minute)
// Clean old entries
valid := loginAttempts.m[ip][:0]
for _, t := range loginAttempts.m[ip] {
if t.After(cutoff) {
valid = append(valid, t)
}
}
loginAttempts.m[ip] = valid
return len(valid) >= 10 // max 10 attempts per 5 minutes
}
func recordLoginAttempt(ip string) {
loginAttempts.Lock()
defer loginAttempts.Unlock()
loginAttempts.m[ip] = append(loginAttempts.m[ip], time.Now())
}
func validatePassword(pw string) string {
if len(pw) < 8 {
return "password must be at least 8 characters"
}
return ""
}
// ─── Auth ────────────────────────────────────────────────────────────────────
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if isRateLimited(ip) {
writeJSON(w, 429, map[string]string{"error": "too many attempts, try again later"})
return
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
if err := decodeBody(r, &req); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request"})
return
}
var id, hash string
var isAdmin bool
var totpSecret *string
err := s.db.QueryRow(
"SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", req.Email,
).Scan(&id, &hash, &isAdmin, &totpSecret)
localAuthOK := err == nil && auth.CheckPassword(hash, req.Password)
// Try LDAP if local auth failed
if !localAuthOK {
ldapCfg := auth.LDAPConfigFromEnv()
if ldapCfg != nil {
email, displayName, ldapErr := auth.LDAPAuth(ldapCfg, req.Email, req.Password)
if ldapErr == nil {
// LDAP success — find or create local user
err2 := s.db.QueryRow(
"SELECT id, password_hash, is_admin, totp_secret FROM users WHERE email = ?", email,
).Scan(&id, &hash, &isAdmin, &totpSecret)
if err2 != nil {
// Auto-create user from LDAP
id = uuid.New().String()
username := displayName
if username == "" {
username = req.Email
}
s.db.Exec(
"INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, 0)",
id, username, email, "ldap",
)
files.EnsureUserDir(s.dataDir, id)
totpSecret = nil
}
localAuthOK = true
}
}
}
if !localAuthOK {
recordLoginAttempt(ip)
writeJSON(w, 401, map[string]string{"error": "invalid credentials"})
return
}
// Check TOTP if enabled
if totpSecret != nil && *totpSecret != "" {
if req.TOTPCode == "" {
writeJSON(w, 401, map[string]string{"error": "totp_required"})
return
}
if !auth.ValidateTOTP(*totpSecret, req.TOTPCode) {
writeJSON(w, 401, map[string]string{"error": "invalid TOTP code"})
return
}
}
token, err := auth.CreateToken(id, isAdmin, s.secret)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "token creation failed"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "authToken",
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(72 * time.Hour),
})
writeJSON(w, 200, map[string]interface{}{
"token": token,
"userId": id,
"isAdmin": isAdmin,
})
s.audit(id, "login", req.Email)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "authToken",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
writeJSON(w, 200, map[string]string{"status": "ok"})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := decodeBody(r, &req); err != nil || req.CurrentPassword == "" || req.NewPassword == "" {
writeJSON(w, 400, map[string]string{"error": "current_password and new_password required"})
return
}
if msg := validatePassword(req.NewPassword); msg != "" {
writeJSON(w, 400, map[string]string{"error": msg})
return
}
userID := getUserID(r)
var hash string
s.db.QueryRow("SELECT password_hash FROM users WHERE id = ?", userID).Scan(&hash)
if !auth.CheckPassword(hash, req.CurrentPassword) {
writeJSON(w, 401, map[string]string{"error": "current password is incorrect"})
return
}
newHash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to hash password"})
return
}
s.db.Exec("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?", newHash, userID)
s.audit(userID, "change_password", "")
writeJSON(w, 200, map[string]string{"status": "password changed"})
}
// ─── Users ───────────────────────────────────────────────────────────────────
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
IsAdmin bool `json:"isAdmin"`
}
if err := decodeBody(r, &req); err != nil || req.Email == "" || req.Password == "" || req.Username == "" {
writeJSON(w, 400, map[string]string{"error": "username, email, and password required"})
return
}
if msg := validatePassword(req.Password); msg != "" {
writeJSON(w, 400, map[string]string{"error": msg})
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to hash password"})
return
}
id := uuid.New().String()
_, err = s.db.Exec(
"INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)",
id, req.Username, req.Email, hash, req.IsAdmin,
)
if err != nil {
writeJSON(w, 409, map[string]string{"error": "user already exists"})
return
}
// Create user's file directory
files.EnsureUserDir(s.dataDir, id)
writeJSON(w, 201, map[string]interface{}{"id": id, "username": req.Username, "email": req.Email})
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query("SELECT id, username, email, is_admin, created_at FROM users")
if err != nil {
writeJSON(w, 500, map[string]string{"error": "query failed"})
return
}
defer rows.Close()
var users []map[string]interface{}
for rows.Next() {
var id, username, email, createdAt string
var isAdmin bool
rows.Scan(&id, &username, &email, &isAdmin, &createdAt)
users = append(users, map[string]interface{}{
"id": id, "username": username, "email": email,
"isAdmin": isAdmin, "createdAt": createdAt,
})
}
if users == nil {
users = []map[string]interface{}{}
}
writeJSON(w, 200, users)
}
// ─── Files ───────────────────────────────────────────────────────────────────
func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
tree, err := files.ListTree(s.dataDir, userID)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to list files"})
return
}
if tree == nil {
tree = []files.FileInfo{}
}
writeJSON(w, 200, tree)
}
func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
OwnerID string `json:"owner_id"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
readFrom := userID
// If owner_id specified, check permissions (shared file access)
if req.OwnerID != "" && req.OwnerID != userID {
var level string
err := s.db.QueryRow(`
SELECT p.level FROM permissions p
JOIN files f ON f.id = p.file_id
WHERE f.owner_id = ? AND f.path = ? AND p.user_id = ?`,
req.OwnerID, req.Path, userID).Scan(&level)
if err != nil {
writeJSON(w, 403, map[string]string{"error": "access denied"})
return
}
readFrom = req.OwnerID
}
content, err := files.ReadFile(s.dataDir, readFrom, req.Path)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "file not found"})
return
}
created := files.GetCreatedTime(s.dataDir, readFrom, req.Path)
writeJSON(w, 200, map[string]interface{}{"path": req.Path, "content": content, "created": created})
}
func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
if len(req.Content) > maxBodySize {
writeJSON(w, 413, map[string]string{"error": "file too large (max 10MB)"})
return
}
userID := getUserID(r)
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
writeJSON(w, 500, map[string]string{"error": "write failed"})
return
}
// Auto-commit on save
go func() {
git.InitRepo(s.dataDir, userID)
git.AutoCommit(s.dataDir, userID, req.Path)
}()
writeJSON(w, 200, map[string]string{"status": "ok", "path": req.Path})
}
func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.WriteFile(s.dataDir, userID, req.Path, req.Content); err != nil {
writeJSON(w, 500, map[string]string{"error": "create failed"})
return
}
writeJSON(w, 201, map[string]string{"status": "created", "path": req.Path})
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.CreateFolder(s.dataDir, userID, req.Path); err != nil {
writeJSON(w, 500, map[string]string{"error": "create folder failed"})
return
}
writeJSON(w, 201, map[string]string{"status": "created", "path": req.Path})
}
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" {
writeJSON(w, 400, map[string]string{"error": "path required"})
return
}
userID := getUserID(r)
if err := files.DeleteFile(s.dataDir, userID, req.Path); err != nil {
writeJSON(w, 404, map[string]string{"error": "file not found"})
return
}
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
func (s *Server) handleMoveFile(w http.ResponseWriter, r *http.Request) {
var req struct {
From string `json:"from"`
To string `json:"to"`
}
if err := decodeBody(r, &req); err != nil || req.From == "" || req.To == "" {
writeJSON(w, 400, map[string]string{"error": "from and to required"})
return
}
userID := getUserID(r)
if err := files.MoveFile(s.dataDir, userID, req.From, req.To); err != nil {
writeJSON(w, 500, map[string]string{"error": "move failed"})
return
}
writeJSON(w, 200, map[string]string{"status": "moved"})
}
func (s *Server) handleRenameFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
NewName string `json:"new_name"`
}
if err := decodeBody(r, &req); err != nil || req.Path == "" || req.NewName == "" {
writeJSON(w, 400, map[string]string{"error": "path and new_name required"})
return
}
userID := getUserID(r)
dir := filepath.Dir(req.Path)
newPath := filepath.Join(dir, req.NewName)
if err := files.MoveFile(s.dataDir, userID, req.Path, newPath); err != nil {
writeJSON(w, 500, map[string]string{"error": "rename failed"})
return
}
s.audit(userID, "rename", req.Path+" -> "+newPath)
writeJSON(w, 200, map[string]string{"status": "renamed", "new_path": newPath})
}
func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) {
// TODO: query permissions table for files shared with this user
// For now return empty list
writeJSON(w, 200, []files.FileInfo{})
}
func (s *Server) handleUploadImage(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, 413, map[string]string{"error": "file too large (max 10MB)"})
return
}
file, header, err := r.FormFile("image")
if err != nil {
writeJSON(w, 400, map[string]string{"error": "image field required"})
return
}
defer file.Close()
userID := getUserID(r)
assetsDir := filepath.Join(files.UserDir(s.dataDir, userID), ".assets")
os.MkdirAll(assetsDir, 0755)
filename := header.Filename
dest := filepath.Join(assetsDir, filename)
out, err := os.Create(dest)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "save failed"})
return
}
defer out.Close()
io.Copy(out, file)
url := "/api/files/image/" + filename
writeJSON(w, 200, map[string]string{"url": url, "filename": filename})
}
func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
filename := filepath.Base(r.URL.Path)
if filename == "" || filename == "." {
http.Error(w, "not found", 404)
return
}
p := filepath.Join(files.UserDir(s.dataDir, userID), ".assets", filename)
http.ServeFile(w, r, p)
}
func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
items, err := files.ListTrash(s.dataDir, userID)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to list trash"})
return
}
if items == nil {
items = []files.FileInfo{}
}
writeJSON(w, 200, items)
}
func (s *Server) handleRestoreTrash(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := decodeBody(r, &req); err != nil || req.Name == "" {
writeJSON(w, 400, map[string]string{"error": "name required"})
return
}
userID := getUserID(r)
if err := files.RestoreFromTrash(s.dataDir, userID, req.Name); err != nil {
writeJSON(w, 500, map[string]string{"error": "restore failed"})
return
}
writeJSON(w, 200, map[string]string{"status": "restored"})
}
func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if err := files.EmptyTrash(s.dataDir, userID); err != nil {
writeJSON(w, 500, map[string]string{"error": "empty trash failed"})
return
}
writeJSON(w, 200, map[string]string{"status": "emptied"})
}