refactor: server.go in separate Handler-Dateien aufgeteilt
server.go (2357 -> 391 Zeilen) enthaelt nur noch Server-Struct, Konstruktor, Router, Middleware und Hilfsfunktionen. Neue Dateien: - auth_handlers.go: Login, Logout, Me - search_handlers.go: Suche, Mail-Anzeige, Anhaenge, Raw-Download - admin_handlers.go: User-CRUD, SMTP/Storage-Stats, Services, Security - import_handlers.go: IMAP + POP3 Account-Verwaltung und Import - dashboard_handlers.go: System-Stats, Audit-Log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,685 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
"github.com/archivmail/internal/auth"
|
||||||
|
"github.com/archivmail/internal/userstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
|
||||||
|
var (
|
||||||
|
users []*userstore.User
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if tenantID != nil {
|
||||||
|
users, err = s.users.ListByTenant(r.Context(), *tenantID)
|
||||||
|
} else {
|
||||||
|
users, err = s.users.List("")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type userResp struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]userResp, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
resp = append(resp, userResp{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Role: u.Role,
|
||||||
|
Active: u.Active,
|
||||||
|
TenantID: u.TenantID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEC-01: Privilege escalation check — caller must not assign a role
|
||||||
|
// at or above their own level.
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if roleLevel(req.Role) >= roleLevel(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient privileges to assign this role")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEC-02: Tenant isolation — non-superadmin users can only create users
|
||||||
|
// within their own tenant.
|
||||||
|
var tenantID *int64
|
||||||
|
if sess.TenantID != nil {
|
||||||
|
tenantID = sess.TenantID
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.Create(userstore.CreateUserRequest{
|
||||||
|
Username: req.Username,
|
||||||
|
Email: req.Email,
|
||||||
|
Password: req.Password,
|
||||||
|
Role: req.Role,
|
||||||
|
TenantID: tenantID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("create user failed", "err", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "user creation failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventUserMgmt,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Detail: "created user: " + user.Username,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
"active": user.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Role *string `json:"role"`
|
||||||
|
Active *bool `json:"active"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
|
||||||
|
// SEC-02: Tenant isolation — load target user and verify same tenant.
|
||||||
|
target, err := s.users.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sess.TenantID != nil {
|
||||||
|
if target.TenantID == nil || *target.TenantID != *sess.TenantID {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEC-01: Privilege escalation check — caller must not assign a role
|
||||||
|
// at or above their own level, and must not modify users at or above
|
||||||
|
// their own level.
|
||||||
|
if roleLevel(target.Role) >= roleLevel(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient privileges to modify this user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient privileges to assign this role")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.users.Update(id, userstore.UpdateUserRequest{
|
||||||
|
Email: req.Email,
|
||||||
|
Role: req.Role,
|
||||||
|
Active: req.Active,
|
||||||
|
Password: req.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("update user failed", "err", err)
|
||||||
|
writeError(w, http.StatusBadRequest, "user update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventUserMgmt,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Detail: fmt.Sprintf("updated user %d", id),
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"id": updated.ID,
|
||||||
|
"username": updated.Username,
|
||||||
|
"email": updated.Email,
|
||||||
|
"role": updated.Role,
|
||||||
|
"active": updated.Active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user info before deletion for audit log and IMAP cleanup
|
||||||
|
target, err := s.users.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEC-02: Tenant isolation — domain_admin can only delete users in their own tenant.
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if sess.TenantID != nil {
|
||||||
|
if target.TenantID == nil || *target.TenantID != *sess.TenantID {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEC-01: Cannot delete users at or above own privilege level.
|
||||||
|
if roleLevel(target.Role) >= roleLevel(sess.Role) {
|
||||||
|
writeError(w, http.StatusForbidden, "insufficient privileges to delete this user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.users.DeleteSafe(id); err != nil {
|
||||||
|
if err.Error() == "userstore: cannot delete last admin" {
|
||||||
|
writeError(w, http.StatusConflict, "cannot delete the last active admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("delete user failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "user deletion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all IMAP accounts that belonged to this user
|
||||||
|
imapDeleted := 0
|
||||||
|
if s.imapStore != nil {
|
||||||
|
if n, err := s.imapStore.DeleteByOwner(r.Context(), target.Username); err != nil {
|
||||||
|
s.logger.Warn("delete user: could not remove IMAP accounts", "user", target.Username, "err", err)
|
||||||
|
} else {
|
||||||
|
imapDeleted = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventUserMgmt,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Detail: fmt.Sprintf(
|
||||||
|
"deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD",
|
||||||
|
id, target.Username, target.Role, imapDeleted,
|
||||||
|
),
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
|
||||||
|
// domain_admin: return only their tenant's email statistics (no global daemon info)
|
||||||
|
if sess != nil && !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
|
||||||
|
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domains := []string{}
|
||||||
|
if tenantID != nil && s.tenantStore != nil {
|
||||||
|
if dd, derr := s.tenantStore.ListDomains(r.Context(), *tenantID); derr == nil {
|
||||||
|
for _, d := range dd {
|
||||||
|
domains = append(domains, d.Domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"enabled": true,
|
||||||
|
"tenant_only": true,
|
||||||
|
"domains": domains,
|
||||||
|
"total_mails": stats["count"],
|
||||||
|
"total_bytes": stats["total_size"],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// superadmin: global daemon status
|
||||||
|
if s.smtpDaemon == nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.smtpDaemon.Status())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
stats, err := s.store.StatsByTenant(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read storage stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"total_mails": stats["count"],
|
||||||
|
"total_bytes": stats["total_size"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Service management ---
|
||||||
|
|
||||||
|
// allowedServices is the whitelist of systemd service names the admin may control.
|
||||||
|
var allowedServices = []string{
|
||||||
|
"archivmail",
|
||||||
|
"archivmail-web",
|
||||||
|
"postgresql@17-main",
|
||||||
|
"postfix",
|
||||||
|
"nginx",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Active string `json:"active"` // active, inactive, failed, unknown
|
||||||
|
Sub string `json:"sub"` // running, dead, exited, ...
|
||||||
|
Enabled string `json:"enabled"` // enabled, disabled, static, unknown
|
||||||
|
Description string `json:"description"`
|
||||||
|
ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllowedService(name string) bool {
|
||||||
|
for _, s := range allowedServices {
|
||||||
|
if s == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemctlShow(name string) ServiceStatus {
|
||||||
|
svc := ServiceStatus{Name: name, DisplayName: name}
|
||||||
|
out, err := exec.Command("systemctl", "show", name+".service",
|
||||||
|
"--property=ActiveState,SubState,UnitFileState,Description",
|
||||||
|
"--no-pager").Output()
|
||||||
|
if err != nil {
|
||||||
|
svc.Active = "unknown"
|
||||||
|
svc.Sub = ""
|
||||||
|
svc.Enabled = "unknown"
|
||||||
|
} else {
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "ActiveState":
|
||||||
|
svc.Active = v
|
||||||
|
case "SubState":
|
||||||
|
svc.Sub = v
|
||||||
|
case "UnitFileState":
|
||||||
|
svc.Enabled = v
|
||||||
|
case "Description":
|
||||||
|
svc.Description = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "archivmail" {
|
||||||
|
blocked := nftAPIBlocked()
|
||||||
|
svc.ExternalBlocked = &blocked
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// nftAPIBlocked reports whether external access to port 8080 is currently blocked.
|
||||||
|
func nftAPIBlocked() bool {
|
||||||
|
out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", "status").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)) == "blocked"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := make([]ServiceStatus, 0, len(allowedServices))
|
||||||
|
for _, name := range allowedServices {
|
||||||
|
result = append(result, systemctlShow(name))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only superadmin may start/stop/restart services
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if sess == nil || !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "superadmin required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if !isAllowedService(name) {
|
||||||
|
writeError(w, http.StatusBadRequest, "unknown service")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedActions := map[string]bool{
|
||||||
|
"start": true, "stop": true, "restart": true,
|
||||||
|
"enable": true, "disable": true,
|
||||||
|
}
|
||||||
|
nftActions := map[string]string{
|
||||||
|
"block_external": "block",
|
||||||
|
"allow_external": "unblock",
|
||||||
|
}
|
||||||
|
|
||||||
|
if nftArg, isNft := nftActions[body.Action]; isNft {
|
||||||
|
if name != "archivmail" {
|
||||||
|
writeError(w, http.StatusBadRequest, "external access control only available for archivmail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", nftArg).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "service." + body.Action,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Detail: name,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, systemctlShow(name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedActions[body.Action] {
|
||||||
|
writeError(w, http.StatusBadRequest, "unknown action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("sudo", "/usr/bin/systemctl", body.Action, name+".service").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: "service." + body.Action,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Detail: name,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, systemctlShow(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security Audit ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type securityCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"` // "ok" | "warning" | "error"
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var checks []securityCheck
|
||||||
|
|
||||||
|
// 1. Firewall (nftables) aktiv?
|
||||||
|
nftOut, err := exec.CommandContext(r.Context(), "nft", "list", "ruleset").Output()
|
||||||
|
nftStr := string(nftOut)
|
||||||
|
firewallActive := err == nil
|
||||||
|
|
||||||
|
if !firewallActive {
|
||||||
|
checks = append(checks, securityCheck{
|
||||||
|
Name: "Firewall (nftables)",
|
||||||
|
Status: "error",
|
||||||
|
Message: "nft konnte nicht ausgeführt werden — Firewall möglicherweise inaktiv",
|
||||||
|
})
|
||||||
|
} else if strings.Contains(nftStr, "policy drop") {
|
||||||
|
checks = append(checks, securityCheck{
|
||||||
|
Name: "Firewall (nftables)",
|
||||||
|
Status: "ok",
|
||||||
|
Message: "Aktiv — Input-Chain policy: drop (Whitelist-Modus)",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{
|
||||||
|
Name: "Firewall (nftables)",
|
||||||
|
Status: "warning",
|
||||||
|
Message: "nftables aktiv, aber Input-Chain policy ist nicht 'drop'",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Port 3000 (Next.js) extern erreichbar?
|
||||||
|
if !firewallActive {
|
||||||
|
checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"})
|
||||||
|
} else if strings.Contains(nftStr, "dport 3000") {
|
||||||
|
checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "warning", Message: "Port 3000 explizit in Firewall-Regeln — prüfen ob gewollt"})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Port 8080 (Go Backend) extern erreichbar?
|
||||||
|
if !firewallActive {
|
||||||
|
checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"})
|
||||||
|
} else if strings.Contains(nftStr, "dport 8080") {
|
||||||
|
checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "warning", Message: "Port 8080 explizit in Firewall-Regeln — prüfen ob gewollt"})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. HTTPS aktiv?
|
||||||
|
if firewallActive && strings.Contains(nftStr, "dport 443") {
|
||||||
|
checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "ok", Message: "Port 443 in Firewall freigegeben"})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "warning", Message: "Kein HTTPS — Verbindungen unverschlüsselt (certbot empfohlen)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. SSH PermitRootLogin + PasswordAuthentication
|
||||||
|
sshConf, err := os.ReadFile("/etc/ssh/sshd_config")
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(string(sshConf), "\n")
|
||||||
|
rootLogin := ""
|
||||||
|
passAuth := ""
|
||||||
|
for _, l := range lines {
|
||||||
|
tl := strings.ToLower(strings.TrimSpace(l))
|
||||||
|
if strings.HasPrefix(tl, "permitrootlogin") && !strings.HasPrefix(tl, "#") {
|
||||||
|
rootLogin = tl
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(tl, "passwordauthentication") && !strings.HasPrefix(tl, "#") {
|
||||||
|
passAuth = tl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(rootLogin, "no") || strings.Contains(rootLogin, "prohibit-password") {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "ok", Message: rootLogin})
|
||||||
|
} else if rootLogin == "" {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: "Nicht explizit gesetzt (Standard: prohibit-password)"})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: rootLogin + " — Passwort-Login für root möglich"})
|
||||||
|
}
|
||||||
|
if strings.Contains(passAuth, "no") {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "ok", Message: "Nur Key-basierte Authentifizierung"})
|
||||||
|
} else if passAuth == "" {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: "Nicht explizit deaktiviert — SSH-Keys empfohlen"})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: passAuth + " — Brute-Force-Risiko"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "SSH Konfiguration", Status: "warning", Message: "/etc/ssh/sshd_config nicht lesbar"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fail2ban
|
||||||
|
f2bOut, err := exec.CommandContext(r.Context(), "systemctl", "is-active", "fail2ban").Output()
|
||||||
|
if err == nil && strings.TrimSpace(string(f2bOut)) == "active" {
|
||||||
|
checks = append(checks, securityCheck{Name: "Fail2ban", Status: "ok", Message: "Aktiv"})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, securityCheck{Name: "Fail2ban", Status: "warning", Message: "Nicht aktiv — kein Brute-Force-Schutz (apt install fail2ban)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"checks": checks,
|
||||||
|
"run_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security Fix ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// allowedFixActions is a strict whitelist — only these actions may be executed.
|
||||||
|
var allowedFixActions = map[string]bool{
|
||||||
|
"install_fail2ban": true,
|
||||||
|
"enable_firewall": true,
|
||||||
|
"fix_ssh_password_auth": true,
|
||||||
|
"fix_ssh_root_login": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowedFixActions[body.Action] {
|
||||||
|
writeError(w, http.StatusBadRequest, "unknown action: "+body.Action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
var msg string
|
||||||
|
var fixErr error
|
||||||
|
|
||||||
|
switch body.Action {
|
||||||
|
|
||||||
|
case "install_fail2ban":
|
||||||
|
// Install fail2ban if not present
|
||||||
|
if _, err := exec.LookPath("fail2ban-client"); err != nil {
|
||||||
|
out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Write a minimal jail.local if not already present
|
||||||
|
jailPath := "/etc/fail2ban/jail.local"
|
||||||
|
if _, err := os.Stat(jailPath); os.IsNotExist(err) {
|
||||||
|
jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n"
|
||||||
|
if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil {
|
||||||
|
s.logger.Error("could not write jail.local", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "security config update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enable and start
|
||||||
|
exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run()
|
||||||
|
out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out))
|
||||||
|
} else {
|
||||||
|
msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet."
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enable_firewall":
|
||||||
|
// Reload rules from /etc/nftables.conf and enable service
|
||||||
|
out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run()
|
||||||
|
msg = "nftables-Regeln neu geladen und Dienst aktiviert."
|
||||||
|
|
||||||
|
case "fix_ssh_password_auth":
|
||||||
|
fixErr = sshConfigSet("PasswordAuthentication", "no")
|
||||||
|
if fixErr == nil {
|
||||||
|
out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
|
||||||
|
} else {
|
||||||
|
msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "fix_ssh_root_login":
|
||||||
|
fixErr = sshConfigSet("PermitRootLogin", "prohibit-password")
|
||||||
|
if fixErr == nil {
|
||||||
|
out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out))
|
||||||
|
} else {
|
||||||
|
msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fixErr != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, fixErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config.
|
||||||
|
// Commented-out lines are left untouched; the active directive is updated or appended.
|
||||||
|
func sshConfigSet(key, value string) error {
|
||||||
|
const path = "/etc/ssh/sshd_config"
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read sshd_config: %w", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
keyLower := strings.ToLower(key)
|
||||||
|
found := false
|
||||||
|
for i, l := range lines {
|
||||||
|
tl := strings.ToLower(strings.TrimSpace(l))
|
||||||
|
if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") {
|
||||||
|
lines[i] = key + " " + value
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
lines = append(lines, key+" "+value)
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginMaxFailures = 5
|
||||||
|
loginWindow = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limiting: block after too many recent failures
|
||||||
|
failures, err := s.users.CountRecentFailures(req.Username, loginWindow)
|
||||||
|
if err == nil && failures >= loginMaxFailures {
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventLogin,
|
||||||
|
Username: req.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: false,
|
||||||
|
Detail: "rate limited",
|
||||||
|
})
|
||||||
|
writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, user, totpRequired, err := s.authMgr.Login(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.users.RecordLoginAttempt(req.Username, s.remoteIP(r))
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventLogin,
|
||||||
|
Username: req.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: false,
|
||||||
|
Detail: classifyLoginError(err),
|
||||||
|
})
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROJ-24: If TOTP is enabled, return a pending token instead of a full session.
|
||||||
|
if totpRequired {
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventLogin,
|
||||||
|
Username: user.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "totp_pending",
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||||
|
"totp_required": true,
|
||||||
|
"session_token": token,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.users.UpdateLastLogin(user.ID)
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventLogin,
|
||||||
|
Username: user.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 8 * 3600,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: s.cfg.SecureCookies,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"user": map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
|
||||||
|
user, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "user lookup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Read token from cookie first, then Bearer header
|
||||||
|
token := ""
|
||||||
|
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
token = c.Value
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = extractBearerToken(r)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
_ = s.authMgr.Logout(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the session cookie
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: s.cfg.SecureCookies,
|
||||||
|
})
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventLogout,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
"github.com/archivmail/internal/storage"
|
||||||
|
"github.com/archivmail/pkg/mailparser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Audit Log handler ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
pageSizeStr := r.URL.Query().Get("page_size")
|
||||||
|
username := r.URL.Query().Get("username")
|
||||||
|
eventType := r.URL.Query().Get("event_type")
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(pageStr)
|
||||||
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, total, err := s.audlog.Query(audit.QueryFilter{
|
||||||
|
Username: username,
|
||||||
|
EventType: eventType,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "audit query failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"total": total,
|
||||||
|
"entries": entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System stats handler ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type diskStat struct {
|
||||||
|
Mount string `json:"mount"`
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
UsedBytes uint64 `json:"used_bytes"`
|
||||||
|
FreeBytes uint64 `json:"free_bytes"`
|
||||||
|
UsedPct float64 `json:"used_pct"`
|
||||||
|
FSType string `json:"fstype"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var excludedFSTypes = map[string]bool{
|
||||||
|
"tmpfs": true, "proc": true, "sysfs": true, "devtmpfs": true,
|
||||||
|
"cgroup": true, "cgroup2": true, "overlay": true, "squashfs": true,
|
||||||
|
"debugfs": true, "tracefs": true, "securityfs": true, "pstore": true,
|
||||||
|
"efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true,
|
||||||
|
"ramfs": true, "devpts": true, "fusectl": true, "configfs": true,
|
||||||
|
"autofs": true, "nsfs": true, "rpc_pipefs": true,
|
||||||
|
"fuse.lxcfs": true, "fuse": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// CPU: /proc/loadavg
|
||||||
|
cpuResp := map[string]interface{}{"load1": 0.0, "load5": 0.0, "load15": 0.0, "num_cpu": runtime.NumCPU()}
|
||||||
|
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
|
parts := strings.Fields(string(data))
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
l1, _ := strconv.ParseFloat(parts[0], 64)
|
||||||
|
l5, _ := strconv.ParseFloat(parts[1], 64)
|
||||||
|
l15, _ := strconv.ParseFloat(parts[2], 64)
|
||||||
|
cpuResp = map[string]interface{}{"load1": l1, "load5": l5, "load15": l15, "num_cpu": runtime.NumCPU()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAM: /proc/meminfo
|
||||||
|
ramResp := map[string]interface{}{"total_bytes": uint64(0), "used_bytes": uint64(0), "free_bytes": uint64(0), "used_pct": 0.0}
|
||||||
|
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
kv := parseMeminfo(string(data))
|
||||||
|
total := kv["MemTotal"] * 1024
|
||||||
|
available := kv["MemAvailable"] * 1024
|
||||||
|
used := total - available
|
||||||
|
var usedPct float64
|
||||||
|
if total > 0 {
|
||||||
|
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
|
||||||
|
}
|
||||||
|
ramResp = map[string]interface{}{
|
||||||
|
"total_bytes": total,
|
||||||
|
"used_bytes": used,
|
||||||
|
"free_bytes": available,
|
||||||
|
"used_pct": usedPct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disks: /proc/mounts + syscall.Statfs
|
||||||
|
var disks []diskStat
|
||||||
|
seenMounts := map[string]bool{} // deduplicate by mountpoint
|
||||||
|
seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts)
|
||||||
|
if data, err := os.ReadFile("/proc/mounts"); err == nil {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
device := fields[0]
|
||||||
|
mount := fields[1]
|
||||||
|
fstype := fields[2]
|
||||||
|
if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenMounts[mount] = true
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(mount, &stat); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total := stat.Blocks * uint64(stat.Bsize)
|
||||||
|
free := stat.Bavail * uint64(stat.Bsize)
|
||||||
|
used := total - free
|
||||||
|
if total == 0 {
|
||||||
|
continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays)
|
||||||
|
}
|
||||||
|
seenDevices[device] = true
|
||||||
|
var usedPct float64
|
||||||
|
if total > 0 {
|
||||||
|
usedPct = math.Round(float64(used)/float64(total)*1000) / 10
|
||||||
|
}
|
||||||
|
disks = append(disks, diskStat{
|
||||||
|
Mount: mount,
|
||||||
|
TotalBytes: total,
|
||||||
|
UsedBytes: used,
|
||||||
|
FreeBytes: free,
|
||||||
|
UsedPct: usedPct,
|
||||||
|
FSType: fstype,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if disks == nil {
|
||||||
|
disks = []diskStat{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive: first & last mail
|
||||||
|
archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil}
|
||||||
|
first, last, err := s.store.FirstAndLastMail()
|
||||||
|
if err == nil {
|
||||||
|
if first != nil {
|
||||||
|
archiveResp["first_mail"] = mailRefToInfo(s.store, first)
|
||||||
|
}
|
||||||
|
if last != nil {
|
||||||
|
archiveResp["last_mail"] = mailRefToInfo(s.store, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"cpu": cpuResp,
|
||||||
|
"ram": ramResp,
|
||||||
|
"disks": disks,
|
||||||
|
"archive": archiveResp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMeminfo(content string) map[string]uint64 {
|
||||||
|
result := make(map[string]uint64)
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
for scanner.Scan() {
|
||||||
|
k, v, ok := strings.Cut(scanner.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(strings.TrimSpace(v))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, err := strconv.ParseUint(fields[0], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
result[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailRefToInfo(store *storage.Store, ref *storage.MailRef) *mailInfo {
|
||||||
|
dateStr := ref.ModTime.UTC().Format(time.RFC3339)
|
||||||
|
raw, err := store.Load(ref.ID)
|
||||||
|
if err != nil {
|
||||||
|
return &mailInfo{ID: ref.ID, Date: dateStr}
|
||||||
|
}
|
||||||
|
pm, err := mailparser.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return &mailInfo{ID: ref.ID, Date: dateStr}
|
||||||
|
}
|
||||||
|
if !pm.Date.IsZero() {
|
||||||
|
dateStr = pm.Date.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return &mailInfo{
|
||||||
|
ID: ref.ID,
|
||||||
|
Date: dateStr,
|
||||||
|
From: pm.From,
|
||||||
|
Subject: pm.Subject,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/auth"
|
||||||
|
imapstore "github.com/archivmail/internal/imap"
|
||||||
|
pop3store "github.com/archivmail/internal/pop3"
|
||||||
|
"github.com/archivmail/internal/userstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── IMAP handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleListImap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
// SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin).
|
||||||
|
isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin)
|
||||||
|
accounts, err := s.imapStore.List(r.Context(), sess.Username, isAdmin)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list IMAP accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if accounts == nil {
|
||||||
|
accounts = []imapstore.Account{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateImap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
TLS string `json:"tls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ExcludedFolders []string `json:"excluded_folders"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name, host, username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = 993
|
||||||
|
}
|
||||||
|
if req.TLS == "" {
|
||||||
|
req.TLS = "ssl"
|
||||||
|
}
|
||||||
|
if req.ExcludedFolders == nil {
|
||||||
|
req.ExcludedFolders = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
acc := imapstore.Account{
|
||||||
|
Owner: sess.Username,
|
||||||
|
Name: req.Name,
|
||||||
|
Host: req.Host,
|
||||||
|
Port: req.Port,
|
||||||
|
TLS: req.TLS,
|
||||||
|
Username: req.Username,
|
||||||
|
ExcludedFolders: req.ExcludedFolders,
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.imapStore.Create(r.Context(), acc, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create IMAP account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteImap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.imapStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.imapStore.Delete(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTestImap(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
TLS string `json:"tls"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Host == "" || req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "host, username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = 993
|
||||||
|
}
|
||||||
|
if req.TLS == "" {
|
||||||
|
req.TLS = "ssl"
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := imapstore.Connect(req.Host, req.Port, req.TLS)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"error": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if err := c.Login(req.Username, req.Password).Wait(); err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"error": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, err := imapstore.ListFolders(c)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"error": fmt.Sprintf("Ordner konnten nicht gelesen werden: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"folders": folders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStartImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil || s.imapImporter == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.imapStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if acc.Status == "running" {
|
||||||
|
writeError(w, http.StatusConflict, "import already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.imapImporter.Run(context.Background(), id)
|
||||||
|
|
||||||
|
// Return current account state (status will switch to "running" shortly)
|
||||||
|
acc.Status = "running"
|
||||||
|
writeJSON(w, http.StatusOK, acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleImapProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.imapStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSyncNow triggers an immediate incremental sync for a single IMAP account.
|
||||||
|
func (s *Server) handleSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil || s.imapScheduler == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.imapStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.imapScheduler.TriggerSync(r.Context(), id); err != nil {
|
||||||
|
s.logger.Error("trigger sync failed", "err", err)
|
||||||
|
writeError(w, http.StatusConflict, "sync already running or failed to start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the account with the updated sync_running flag reflected immediately.
|
||||||
|
acc.SyncRunning = true
|
||||||
|
writeJSON(w, http.StatusOK, acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateImapInterval updates the automatic sync interval for an IMAP account.
|
||||||
|
func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.imapStore == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "IMAP not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.imapStore.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SyncIntervalMin int `json:"sync_interval_min"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 = disabled; otherwise must be between 5 and 1440 minutes.
|
||||||
|
if req.SyncIntervalMin != 0 && (req.SyncIntervalMin < 5 || req.SyncIntervalMin > 1440) {
|
||||||
|
writeError(w, http.StatusBadRequest, "sync_interval_min must be 0 (disabled) or between 5 and 1440")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.imapStore.UpdateSyncInterval(r.Context(), id, req.SyncIntervalMin); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update sync interval")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.SyncIntervalMin = req.SyncIntervalMin
|
||||||
|
writeJSON(w, http.StatusOK, acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POP3 handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.pop3Store == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
// SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin).
|
||||||
|
isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin)
|
||||||
|
accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if accounts == nil {
|
||||||
|
accounts = []pop3store.Account{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreatePop3(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.pop3Store == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
TLS string `json:"tls"`
|
||||||
|
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name, host, username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = 110
|
||||||
|
}
|
||||||
|
if req.TLS == "" {
|
||||||
|
req.TLS = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
acc := pop3store.Account{
|
||||||
|
Owner: sess.Username,
|
||||||
|
Name: req.Name,
|
||||||
|
Host: req.Host,
|
||||||
|
Port: req.Port,
|
||||||
|
TLS: req.TLS,
|
||||||
|
TLSSkipVerify: req.TLSSkipVerify,
|
||||||
|
Username: req.Username,
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.pop3Store.Create(r.Context(), acc, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create POP3 account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeletePop3(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.pop3Store == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.pop3Store.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.pop3Store.Delete(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTestPop3(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
TLS string `json:"tls"`
|
||||||
|
TLSSkipVerify bool `json:"tls_skip_verify"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Host == "" || req.Username == "" || req.Password == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "host, username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 {
|
||||||
|
req.Port = 110
|
||||||
|
}
|
||||||
|
if req.TLS == "" {
|
||||||
|
req.TLS = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := pop3store.Dial(req.Host, req.Port, req.TLS, req.TLSSkipVerify)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"message": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if err := c.Login(req.Username, req.Password); err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"message": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, totalSize, err := c.Stat()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"message": fmt.Sprintf("STAT fehlgeschlagen: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.Quit()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Verbindung erfolgreich: %d E-Mails", count),
|
||||||
|
"message_count": count,
|
||||||
|
"total_size_bytes": totalSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.pop3Store == nil || s.pop3Importer == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.pop3Store.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if acc.Status == "running" {
|
||||||
|
writeError(w, http.StatusConflict, "import already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.pop3Importer.Run(context.Background(), id)
|
||||||
|
|
||||||
|
// Return current account state (status will switch to "running" shortly)
|
||||||
|
acc.Status = "running"
|
||||||
|
writeJSON(w, http.StatusOK, acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.pop3Store == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "POP3 not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := s.pop3Store.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "account not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, acc)
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
"github.com/archivmail/internal/index"
|
||||||
|
"github.com/archivmail/internal/userstore"
|
||||||
|
"github.com/archivmail/pkg/mailparser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
fromFilter := r.URL.Query().Get("from")
|
||||||
|
toFilter := r.URL.Query().Get("to")
|
||||||
|
dateFromStr := r.URL.Query().Get("date_from")
|
||||||
|
dateToStr := r.URL.Query().Get("date_to")
|
||||||
|
sortParam := r.URL.Query().Get("sort") // "relevance", "date_asc", "date_desc"
|
||||||
|
hasAttachStr := r.URL.Query().Get("has_attachment") // "true" or "false"
|
||||||
|
labelIDStr := r.URL.Query().Get("label_id") // PROJ-9: filter by label
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
pageSizeStr := r.URL.Query().Get("page_size")
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(pageStr)
|
||||||
|
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
req := index.SearchRequest{
|
||||||
|
Query: q,
|
||||||
|
Sort: sortParam,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Page: page,
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROJ-9: Parse label_id filter.
|
||||||
|
if labelIDStr != "" {
|
||||||
|
if lid, err := strconv.ParseInt(labelIDStr, 10, 64); err == nil && lid > 0 {
|
||||||
|
req.LabelID = &lid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAttachStr == "true" {
|
||||||
|
v := true
|
||||||
|
req.HasAttachment = &v
|
||||||
|
} else if hasAttachStr == "false" {
|
||||||
|
v := false
|
||||||
|
req.HasAttachment = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain search: @domain.de matches both From AND To fields.
|
||||||
|
// A value starting with '@' triggers OR-search across XF and XT prefixes.
|
||||||
|
if strings.HasPrefix(fromFilter, "@") || strings.HasPrefix(toFilter, "@") {
|
||||||
|
domain := fromFilter
|
||||||
|
if domain == "" {
|
||||||
|
domain = toFilter
|
||||||
|
}
|
||||||
|
req.OwnEmail = domain
|
||||||
|
} else {
|
||||||
|
req.From = fromFilter
|
||||||
|
req.To = toFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if dateFromStr != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, dateFromStr); err == nil {
|
||||||
|
req.DateFrom = &t
|
||||||
|
} else if t, err := time.Parse(time.DateOnly, dateFromStr); err == nil {
|
||||||
|
req.DateFrom = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dateToStr != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, dateToStr); err == nil {
|
||||||
|
req.DateTo = &t
|
||||||
|
} else if t, err := time.Parse(time.DateOnly, dateToStr); err == nil {
|
||||||
|
// end of day for date_to
|
||||||
|
t = t.Add(24*time.Hour - time.Second)
|
||||||
|
req.DateTo = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROJ-21 Phase 4: Use per-tenant index when available; fall back to
|
||||||
|
// global index + post-filter when the tenant index manager is not wired.
|
||||||
|
tenantID := tenantFromCtx(r.Context())
|
||||||
|
searchIdx := s.idx
|
||||||
|
usedTenantIndex := false
|
||||||
|
if s.idxMgr != nil && tenantID != nil {
|
||||||
|
searchIdx = s.idxMgr.ForTenant(tenantID)
|
||||||
|
usedTenantIndex = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := searchIdx.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "search failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback tenant isolation: post-filter when we used the global index
|
||||||
|
// but the user belongs to a tenant. This is the legacy path; the per-tenant
|
||||||
|
// index path above makes this unnecessary.
|
||||||
|
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 {
|
||||||
|
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
|
||||||
|
if idErr == nil {
|
||||||
|
allowed := make(map[string]struct{}, len(allowedIDs))
|
||||||
|
for _, id := range allowedIDs {
|
||||||
|
allowed[id] = struct{}{}
|
||||||
|
}
|
||||||
|
filtered := result.Hits[:0]
|
||||||
|
for _, h := range result.Hits {
|
||||||
|
if _, ok := allowed[h.ID]; ok {
|
||||||
|
filtered = append(filtered, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Hits = filtered
|
||||||
|
result.Total = len(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROJ-9: Post-filter by label_id when the label store is available.
|
||||||
|
if req.LabelID != nil && s.labels != nil && len(result.Hits) > 0 {
|
||||||
|
labelEmailIDs, lErr := s.labels.GetEmailIDsByLabel(r.Context(), *req.LabelID)
|
||||||
|
if lErr == nil {
|
||||||
|
allowed := make(map[string]struct{}, len(labelEmailIDs))
|
||||||
|
for _, id := range labelEmailIDs {
|
||||||
|
allowed[id] = struct{}{}
|
||||||
|
}
|
||||||
|
filtered := result.Hits[:0]
|
||||||
|
for _, h := range result.Hits {
|
||||||
|
if _, ok := allowed[h.ID]; ok {
|
||||||
|
filtered = append(filtered, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.Hits = filtered
|
||||||
|
result.Total = len(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventSearch,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: s.remoteIP(r),
|
||||||
|
Query: q,
|
||||||
|
Success: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enrich hits with metadata (from, subject, date, size, attachments).
|
||||||
|
type enrichedHit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
HasAttachments bool `json:"has_attachments"`
|
||||||
|
LabelIDs []int64 `json:"label_ids,omitempty"` // PROJ-9
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROJ-9: Batch-load label IDs for all hits.
|
||||||
|
var labelMap map[string][]int64
|
||||||
|
if s.labels != nil && len(result.Hits) > 0 {
|
||||||
|
emailIDs := make([]string, len(result.Hits))
|
||||||
|
for i, h := range result.Hits {
|
||||||
|
emailIDs[i] = h.ID
|
||||||
|
}
|
||||||
|
labelMap, _ = s.labels.GetLabelsForEmails(r.Context(), emailIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched := make([]enrichedHit, 0, len(result.Hits))
|
||||||
|
for _, h := range result.Hits {
|
||||||
|
eh := enrichedHit{ID: h.ID, Score: h.Score}
|
||||||
|
if raw, err := s.store.Load(h.ID); err == nil {
|
||||||
|
eh.Size = int64(len(raw))
|
||||||
|
if pm, err := mailparser.Parse(raw); err == nil {
|
||||||
|
eh.From = pm.From
|
||||||
|
if len(pm.To) > 0 {
|
||||||
|
eh.To = strings.Join(pm.To, ", ")
|
||||||
|
}
|
||||||
|
eh.Subject = pm.Subject
|
||||||
|
if !pm.Date.IsZero() {
|
||||||
|
eh.Date = pm.Date.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
eh.HasAttachments = len(pm.Attachments) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if labelMap != nil {
|
||||||
|
eh.LabelIDs = labelMap[h.ID]
|
||||||
|
}
|
||||||
|
enriched = append(enriched, eh)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"total": result.Total,
|
||||||
|
"hits": enriched,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
||||||
|
if !isValidMailID(id) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.store.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "mail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm, err := mailparser.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
|
||||||
|
// Tenant isolation: domain_admin sees only own tenant's mail
|
||||||
|
if sess.TenantID != nil {
|
||||||
|
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
||||||
|
if mailTenant == nil || *mailTenant != *sess.TenantID {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// user role: only own mailbox
|
||||||
|
if sess.Role == userstore.RoleUser {
|
||||||
|
u, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type attachMeta struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
attachments := make([]attachMeta, len(pm.Attachments))
|
||||||
|
for i, a := range pm.Attachments {
|
||||||
|
attachments[i] = attachMeta{
|
||||||
|
Index: i,
|
||||||
|
Filename: a.Filename,
|
||||||
|
ContentType: a.ContentType,
|
||||||
|
Size: a.Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateStr string
|
||||||
|
if !pm.Date.IsZero() {
|
||||||
|
dateStr = pm.Date.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify status
|
||||||
|
vs, _ := s.store.GetVerifyStatus(r.Context(), id)
|
||||||
|
var verifyOK interface{} = nil
|
||||||
|
var verifiedAt interface{} = nil
|
||||||
|
if vs.VerifyOK != nil {
|
||||||
|
verifyOK = *vs.VerifyOK
|
||||||
|
}
|
||||||
|
if vs.VerifiedAt != nil {
|
||||||
|
verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"from": pm.From,
|
||||||
|
"to": strings.Join(pm.To, ", "),
|
||||||
|
"cc": strings.Join(pm.CC, ", "),
|
||||||
|
"subject": pm.Subject,
|
||||||
|
"date": dateStr,
|
||||||
|
"size": len(raw),
|
||||||
|
"body_html": pm.HTMLBody,
|
||||||
|
"body_plain": pm.TextBody,
|
||||||
|
"raw_headers": extractRawHeaders(raw),
|
||||||
|
"attachments": attachments,
|
||||||
|
"verify_ok": verifyOK,
|
||||||
|
"verified_at": verifiedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetAttachment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
||||||
|
if !isValidMailID(id) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indexStr := r.PathValue("index")
|
||||||
|
idx, err := strconv.Atoi(indexStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid attachment index")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.store.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "mail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pm, err := mailparser.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
|
||||||
|
// Tenant isolation
|
||||||
|
if sess.TenantID != nil {
|
||||||
|
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
||||||
|
if mailTenant == nil || *mailTenant != *sess.TenantID {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.Role == userstore.RoleUser {
|
||||||
|
u, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx < 0 || idx >= len(pm.Attachments) {
|
||||||
|
writeError(w, http.StatusNotFound, "attachment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a := pm.Attachments[idx]
|
||||||
|
filename := sanitizeFilename(a.Filename)
|
||||||
|
if filename == "" {
|
||||||
|
filename = fmt.Sprintf("attachment-%d", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", a.ContentType)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(a.Data)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(a.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetRaw(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
||||||
|
if !isValidMailID(id) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := s.store.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "mail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
|
||||||
|
// Tenant isolation
|
||||||
|
if sess.TenantID != nil {
|
||||||
|
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
||||||
|
if mailTenant == nil || *mailTenant != *sess.TenantID {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEC-28: Access check for user role — parse failure must NOT grant access.
|
||||||
|
if sess.Role == userstore.RoleUser {
|
||||||
|
pm, err := mailparser.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil || !mailBelongsToUser(pm, u.Email) {
|
||||||
|
writeError(w, http.StatusForbidden, "access denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "message/rfc822")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16]))
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailBelongsToUser checks if the user's email appears in To or CC.
|
||||||
|
func mailBelongsToUser(pm *mailparser.ParsedMail, userEmail string) bool {
|
||||||
|
email := strings.ToLower(userEmail)
|
||||||
|
for _, to := range pm.To {
|
||||||
|
if strings.ToLower(to) == email {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, cc := range pm.CC {
|
||||||
|
if strings.ToLower(cc) == email {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRawHeaders returns the header section of a raw RFC 2822 email.
|
||||||
|
func extractRawHeaders(raw []byte) string {
|
||||||
|
for i := 0; i < len(raw)-3; i++ {
|
||||||
|
if raw[i] == '\r' && raw[i+1] == '\n' && raw[i+2] == '\r' && raw[i+3] == '\n' {
|
||||||
|
return string(raw[:i])
|
||||||
|
}
|
||||||
|
if raw[i] == '\n' && raw[i+1] == '\n' {
|
||||||
|
return string(raw[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
+18
-1984
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user