From d79e33402902e4674a6230393da6595f42e519f5 Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 18 Mar 2026 11:55:21 +0100 Subject: [PATCH] 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 --- internal/api/admin_handlers.go | 685 ++++++++++ internal/api/auth_handlers.go | 148 ++ internal/api/dashboard_handlers.go | 216 +++ internal/api/import_handlers.go | 547 ++++++++ internal/api/search_handlers.go | 428 ++++++ internal/api/server.go | 2002 +--------------------------- 6 files changed, 2042 insertions(+), 1984 deletions(-) create mode 100644 internal/api/admin_handlers.go create mode 100644 internal/api/auth_handlers.go create mode 100644 internal/api/dashboard_handlers.go create mode 100644 internal/api/import_handlers.go create mode 100644 internal/api/search_handlers.go diff --git a/internal/api/admin_handlers.go b/internal/api/admin_handlers.go new file mode 100644 index 0000000..13674f2 --- /dev/null +++ b/internal/api/admin_handlers.go @@ -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) +} diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go new file mode 100644 index 0000000..56c9cd0 --- /dev/null +++ b/internal/api/auth_handlers.go @@ -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}) +} diff --git a/internal/api/dashboard_handlers.go b/internal/api/dashboard_handlers.go new file mode 100644 index 0000000..175bc43 --- /dev/null +++ b/internal/api/dashboard_handlers.go @@ -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, + } +} diff --git a/internal/api/import_handlers.go b/internal/api/import_handlers.go new file mode 100644 index 0000000..c9d1343 --- /dev/null +++ b/internal/api/import_handlers.go @@ -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) +} diff --git a/internal/api/search_handlers.go b/internal/api/search_handlers.go new file mode 100644 index 0000000..fd640c3 --- /dev/null +++ b/internal/api/search_handlers.go @@ -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) +} diff --git a/internal/api/server.go b/internal/api/server.go index 4c63606..c2ed688 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,22 +1,13 @@ package api import ( - "bufio" "context" "encoding/json" - "fmt" "log/slog" - "math" "net" "net/http" - "os" - "os/exec" - "runtime" - "strconv" "strings" "sync" - "syscall" - "time" "regexp" @@ -32,7 +23,6 @@ import ( "github.com/archivmail/internal/storage" "github.com/archivmail/internal/tenantstore" "github.com/archivmail/internal/userstore" - "github.com/archivmail/pkg/mailparser" ) // SEC-22: Compiled regex for mail ID validation to prevent path traversal. @@ -221,645 +211,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } -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}) -} - -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) 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) 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"], - }) -} - -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, - }) -} - // --- middleware --- const sessionCookieName = "archivmail_session" @@ -901,6 +252,24 @@ func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFun } } +// ── Mail access middleware ──────────────────────────────────────────────── + +// requireMailAccess checks that the caller may read mail content. +// superadmin and domain_admin have read access (tenant-scoped via handleGetMail). +// Auditor and user have access to their own mails. +// The old "admin" role (now domain_admin) previously had no mail access — that +// restriction is removed; domain_admin now needs to be able to read archived mails. +func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess := sessionFromCtx(r.Context()) + if sess == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + next(w, r) + } +} + // --- helpers --- func writeJSON(w http.ResponseWriter, code int, v interface{}) { @@ -1020,1338 +389,3 @@ func isTrustedProxy(ip string, proxies []string) bool { } return false } - -// ── Mail access middleware ──────────────────────────────────────────────── - -// requireMailAccess checks that the caller may read mail content. -// superadmin and domain_admin have read access (tenant-scoped via handleGetMail). -// Auditor and user have access to their own mails. -// The old "admin" role (now domain_admin) previously had no mail access — that -// restriction is removed; domain_admin now needs to be able to read archived mails. -func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - sess := sessionFromCtx(r.Context()) - if sess == nil { - writeError(w, http.StatusUnauthorized, "not authenticated") - return - } - next(w, r) - } -} - -// ── Mail handlers ───────────────────────────────────────────────────────── - -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) -} - -// ── Helpers ─────────────────────────────────────────────────────────────── - -// 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) -} - -// --- 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)) -} - -// ── 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) -} - -// ── 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, - } -} - -// ── 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}) -} - -// ── 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) -} - -// 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) -}