a94b1d3e52
- bcrypt cost erhöht von DefaultCost (10) auf 12
- Rate-Limiting: max 5 Fehlversuche in 15 Min → HTTP 429
- last_login_at in DB gespeichert und bei jedem Login aktualisiert
- login_attempts Tabelle für Fehlversuche
- PATCH /api/users/{id}: Passwort-Reset, Rolle, E-Mail, Active
- DELETE /api/users/{id}: Löschen mit Schutz für letzten Admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1319 lines
36 KiB
Go
1319 lines
36 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/archivmail/config"
|
|
"github.com/archivmail/internal/audit"
|
|
"github.com/archivmail/internal/auth"
|
|
imapstore "github.com/archivmail/internal/imap"
|
|
"github.com/archivmail/internal/index"
|
|
"github.com/archivmail/internal/smtpd"
|
|
"github.com/archivmail/internal/storage"
|
|
"github.com/archivmail/internal/userstore"
|
|
"github.com/archivmail/pkg/mailparser"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const sessionKey contextKey = "session"
|
|
|
|
// Server is the archivmail HTTP API server.
|
|
type Server struct {
|
|
cfg config.APIConfig
|
|
store *storage.Store
|
|
idx index.Indexer
|
|
authMgr *auth.Manager
|
|
users *userstore.Store
|
|
audlog *audit.Logger
|
|
logger *slog.Logger
|
|
mux *http.ServeMux
|
|
smtpDaemon *smtpd.Daemon
|
|
imapStore *imapstore.Store
|
|
imapImporter *imapstore.Importer
|
|
}
|
|
|
|
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
|
func (s *Server) SetSMTPDaemon(d *smtpd.Daemon) {
|
|
s.smtpDaemon = d
|
|
}
|
|
|
|
// SetImap wires the IMAP store and importer into the API server after construction.
|
|
func (s *Server) SetImap(store *imapstore.Store, importer *imapstore.Importer) {
|
|
s.imapStore = store
|
|
s.imapImporter = importer
|
|
}
|
|
|
|
// New creates and wires up a new API server.
|
|
func New(
|
|
cfg config.APIConfig,
|
|
store *storage.Store,
|
|
idx index.Indexer,
|
|
authMgr *auth.Manager,
|
|
users *userstore.Store,
|
|
audlog *audit.Logger,
|
|
logger *slog.Logger,
|
|
) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
store: store,
|
|
idx: idx,
|
|
authMgr: authMgr,
|
|
users: users,
|
|
audlog: audlog,
|
|
logger: logger,
|
|
mux: http.NewServeMux(),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("GET /api/health", s.handleHealth)
|
|
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
|
s.mux.HandleFunc("GET /api/auth/me", s.authMiddleware(s.handleMe))
|
|
s.mux.HandleFunc("POST /api/auth/logout", s.authMiddleware(s.handleLogout))
|
|
s.mux.HandleFunc("GET /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListUsers)))
|
|
s.mux.HandleFunc("POST /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateUser)))
|
|
s.mux.HandleFunc("PATCH /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateUser)))
|
|
s.mux.HandleFunc("DELETE /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteUser)))
|
|
s.mux.HandleFunc("GET /api/search", s.authMiddleware(s.handleSearch))
|
|
s.mux.HandleFunc("GET /api/audit", s.authMiddleware(s.requireRole(userstore.RoleAuditor, s.handleAuditLog)))
|
|
s.mux.HandleFunc("GET /api/admin/smtp/status", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSMTPStatus)))
|
|
s.mux.HandleFunc("GET /api/admin/storage/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleStorageStats)))
|
|
s.mux.HandleFunc("GET /api/mails/{id}", s.authMiddleware(s.requireMailAccess(s.handleGetMail)))
|
|
s.mux.HandleFunc("GET /api/mails/{id}/attachments/{index}", s.authMiddleware(s.requireMailAccess(s.handleGetAttachment)))
|
|
s.mux.HandleFunc("GET /api/mails/{id}/raw", s.authMiddleware(s.requireMailAccess(s.handleGetRaw)))
|
|
s.mux.HandleFunc("GET /api/admin/services", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListServices)))
|
|
s.mux.HandleFunc("POST /api/admin/services/{name}/action", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleServiceAction)))
|
|
|
|
s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats)))
|
|
|
|
// Export routes
|
|
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF)))
|
|
s.mux.HandleFunc("POST /api/export/zip", s.authMiddleware(s.requireMailAccess(s.handleExportZIP)))
|
|
|
|
// IMAP routes (accessible to all authenticated users)
|
|
s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap))
|
|
s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap))
|
|
s.mux.HandleFunc("DELETE /api/imap/{id}", s.authMiddleware(s.handleDeleteImap))
|
|
s.mux.HandleFunc("POST /api/imap/test", s.authMiddleware(s.handleTestImap))
|
|
s.mux.HandleFunc("POST /api/imap/{id}/import", s.authMiddleware(s.handleStartImport))
|
|
s.mux.HandleFunc("GET /api/imap/{id}/progress", s.authMiddleware(s.handleImapProgress))
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
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: remoteIP(r),
|
|
Success: false,
|
|
Detail: "rate limited",
|
|
})
|
|
writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later")
|
|
return
|
|
}
|
|
|
|
token, user, err := s.authMgr.Login(req.Username, req.Password)
|
|
if err != nil {
|
|
_ = s.users.RecordLoginAttempt(req.Username, remoteIP(r))
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogin,
|
|
Username: req.Username,
|
|
IPAddress: remoteIP(r),
|
|
Success: false,
|
|
Detail: err.Error(),
|
|
})
|
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
_ = s.users.UpdateLastLogin(user.ID)
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogin,
|
|
Username: user.Username,
|
|
IPAddress: remoteIP(r),
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"token": token,
|
|
"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) {
|
|
token := extractBearerToken(r)
|
|
if err := s.authMgr.Logout(token); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "logout failed")
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogout,
|
|
Username: sess.Username,
|
|
IPAddress: remoteIP(r),
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
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"`
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
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
|
|
}
|
|
|
|
user, err := s.users.Create(userstore.CreateUserRequest{
|
|
Username: req.Username,
|
|
Email: req.Email,
|
|
Password: req.Password,
|
|
Role: req.Role,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
IPAddress: 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
|
|
}
|
|
|
|
updated, err := s.users.Update(id, userstore.UpdateUserRequest{
|
|
Email: req.Email,
|
|
Role: req.Role,
|
|
Active: req.Active,
|
|
Password: req.Password,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
IPAddress: 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
|
|
}
|
|
|
|
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
|
|
}
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
IPAddress: remoteIP(r),
|
|
Detail: fmt.Sprintf("deleted user %d", id),
|
|
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")
|
|
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,
|
|
PageSize: pageSize,
|
|
Page: page,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
result, err := s.idx.Search(req)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "search failed")
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventSearch,
|
|
Username: sess.Username,
|
|
IPAddress: remoteIP(r),
|
|
Query: q,
|
|
Success: true,
|
|
})
|
|
|
|
// Enrich hits with metadata (from, subject, date) by parsing each mail.
|
|
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"`
|
|
}
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
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) {
|
|
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) {
|
|
stats, err := s.store.Stats()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read storage stats")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"total_mails": stats.TotalMails,
|
|
"total_bytes": stats.TotalBytes,
|
|
})
|
|
}
|
|
|
|
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 ---
|
|
|
|
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
token := extractBearerToken(r)
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing authorization header")
|
|
return
|
|
}
|
|
|
|
sess, err := s.authMgr.ValidateToken(token)
|
|
if err != nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), sessionKey, sess)
|
|
next(w, r.WithContext(ctx))
|
|
}
|
|
}
|
|
|
|
func (s *Server) requireRole(role string, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess == nil || !auth.HasRole(sess.Role, role) {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
func extractBearerToken(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimPrefix(h, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sessionFromCtx(ctx context.Context) *auth.Session {
|
|
v := ctx.Value(sessionKey)
|
|
if v == nil {
|
|
return &auth.Session{}
|
|
}
|
|
if s, ok := v.(*auth.Session); ok {
|
|
return s
|
|
}
|
|
return &auth.Session{}
|
|
}
|
|
|
|
func remoteIP(r *http.Request) string {
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
return strings.Split(fwd, ",")[0]
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// ── Mail access middleware ────────────────────────────────────────────────
|
|
|
|
// requireMailAccess blocks admin role (no mail access) and passes user/auditor through.
|
|
func (s *Server) requireMailAccess(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.Role == userstore.RoleAdmin {
|
|
writeError(w, http.StatusForbidden, "admins have no access to mail content")
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// ── Mail handlers ─────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
|
|
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
|
|
}
|
|
|
|
// user role: only own mailbox
|
|
sess := sessionFromCtx(r.Context())
|
|
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")
|
|
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())
|
|
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 := 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")
|
|
|
|
raw, err := s.store.Load(id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "mail not found")
|
|
return
|
|
}
|
|
|
|
// Access check for user role
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.Role == userstore.RoleUser {
|
|
pm, err := mailparser.Parse(raw)
|
|
if err == nil {
|
|
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) {
|
|
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: 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
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: "service." + body.Action,
|
|
Username: sess.Username,
|
|
IPAddress: 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())
|
|
isAdmin := sess.Role == userstore.RoleAdmin
|
|
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 && sess.Role != userstore.RoleAdmin {
|
|
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 && sess.Role != userstore.RoleAdmin {
|
|
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 && sess.Role != userstore.RoleAdmin {
|
|
writeError(w, http.StatusForbidden, "access denied")
|
|
return
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
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{}
|
|
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
|
|
}
|
|
mount := fields[1]
|
|
fstype := fields[2]
|
|
if excludedFSTypes[fstype] || seenMounts[mount] {
|
|
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
|
|
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,
|
|
}
|
|
}
|