security: Add proper authentication, RBAC, and tenant isolation
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// MaildirMessage represents an email read directly from Maildir
|
||||
type MaildirMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Date string `json:"date"`
|
||||
Body string `json:"body"`
|
||||
Preview string `json:"preview"`
|
||||
Read bool `json:"read"`
|
||||
Attachments int `json:"attachments"`
|
||||
}
|
||||
|
||||
// getMaildirPath returns the path to the Maildir for a user
|
||||
func getMaildirPath(email string) string {
|
||||
// Try docker container path first (when running inside container)
|
||||
basePath := "/mail"
|
||||
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||
// Fallback to host path
|
||||
basePath = "/opt/mailu/mail"
|
||||
if _, err := os.Stat(basePath); os.IsNotExist(err) {
|
||||
// Try to find mail via docker volume
|
||||
basePath = "/var/lib/docker/volumes"
|
||||
}
|
||||
}
|
||||
return filepath.Join(basePath, email)
|
||||
}
|
||||
|
||||
// parseMaildirFile parses a single mail file
|
||||
func parseMaildirFile(path string) (*MaildirMessage, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
msg := &MaildirMessage{
|
||||
Read: strings.Contains(filepath.Base(path), ",S=") || !strings.Contains(path, "/new/"),
|
||||
Attachments: 0,
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
lines := strings.Split(content, "\n")
|
||||
inBody := false
|
||||
var bodyLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if !inBody {
|
||||
if line == "" {
|
||||
inBody = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Subject: ") {
|
||||
msg.Subject = strings.TrimPrefix(line, "Subject: ")
|
||||
} else if strings.HasPrefix(line, "From: ") {
|
||||
msg.From = strings.TrimPrefix(line, "From: ")
|
||||
} else if strings.HasPrefix(line, "To: ") {
|
||||
msg.To = append(msg.To, strings.TrimPrefix(line, "To: "))
|
||||
} else if strings.HasPrefix(line, "Date: ") {
|
||||
msg.Date = strings.TrimPrefix(line, "Date: ")
|
||||
}
|
||||
} else {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
msg.Body = body
|
||||
msg.Preview = truncateString(stripHTML(body), 200)
|
||||
|
||||
// Generate UID from filename
|
||||
filename := filepath.Base(path)
|
||||
msg.UID = hashString(filename)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// listMaildirMessages lists all messages in a Maildir
|
||||
func listMaildirMessages(maildir string, limit int) ([]MaildirMessage, error) {
|
||||
var messages []MaildirMessage
|
||||
|
||||
// Read cur/ and new/ directories
|
||||
for _, subdir := range []string{"cur", "new"} {
|
||||
path := filepath.Join(maildir, subdir)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue // Directory may not exist
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
msg, err := parseMaildirFile(filepath.Join(path, entry.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
messages = append(messages, *msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Date > messages[j].Date
|
||||
})
|
||||
|
||||
if limit > 0 && len(messages) > limit {
|
||||
messages = messages[:limit]
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetMailInboxDirect returns emails directly from Maildir
|
||||
func GetMailInboxDirect(w http.ResponseWriter, r *http.Request) {
|
||||
// Get user from context or use default
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
|
||||
maildir := getMaildirPath(email)
|
||||
if _, err := os.Stat(maildir); os.IsNotExist(err) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "Maildir not found for user: " + email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
messages, err := listMaildirMessages(maildir, limit)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"messages": messages,
|
||||
"total": len(messages),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMailMessageDirect returns a single email from Maildir
|
||||
func GetMailMessageDirect(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
maildir := getMaildirPath(email)
|
||||
|
||||
// Search for message with matching UID
|
||||
for _, subdir := range []string{"cur", "new"} {
|
||||
path := filepath.Join(maildir, subdir)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Join(path, entry.Name())
|
||||
if hashString(entry.Name()) == uint32(uid) {
|
||||
msg, err := parseMaildirFile(filename)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to read message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// MarkMailAsReadDirect marks a message as read
|
||||
func MarkMailAsReadDirect(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := chi.URLParam(r, "uid")
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid uid"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
maildir := getMaildirPath(email)
|
||||
|
||||
// Move from new/ to cur/
|
||||
for _, entry := range []string{"new", "cur"} {
|
||||
path := filepath.Join(maildir, entry)
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if hashString(e.Name()) == uint32(uid) {
|
||||
oldPath := filepath.Join(path, e.Name())
|
||||
newPath := filepath.Join(maildir, "cur", e.Name())
|
||||
|
||||
if entry == "new" {
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
http.Error(w, `{"error":"failed to mark as read"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func stripHTML(s string) string {
|
||||
// Simple HTML stripping
|
||||
result := strings.ReplaceAll(s, "<br>", "\n")
|
||||
result = strings.ReplaceAll(result, "<br/>", "\n")
|
||||
result = strings.ReplaceAll(result, "<p>", "\n")
|
||||
result = strings.ReplaceAll(result, "</p>", "")
|
||||
|
||||
// Remove tags
|
||||
for {
|
||||
start := strings.Index(result, "<")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(result[start:], ">")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
result = result[:start] + result[start+end+1:]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func hashString(s string) uint32 {
|
||||
var h uint32 = 5381
|
||||
for i := 0; i < len(s); i++ {
|
||||
h = ((h << 5) + h) + uint32(s[i])
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GetMailUnreadCountDirect returns unread count
|
||||
func GetMailUnreadCountDirect(w http.ResponseWriter, r *http.Request) {
|
||||
email := "erik@landvex.com" // TODO: Get from JWT context
|
||||
maildir := getMaildirPath(email)
|
||||
|
||||
count := 0
|
||||
newPath := filepath.Join(maildir, "new")
|
||||
entries, err := os.ReadDir(newPath)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user