030752157b
- labelTenantID() returns *int64 (nil for superadmin) statt int64(0) → verhindert FK-Constraint-Fehler bei tenant_id = 0 - CreateAdminLabel/CreateLabelRule: nil-Check, 400 wenn kein Tenant - GET /api/admin/labels Route + handleGetAdminLabels Handler ergänzt - from_domain in condition_field Allowlist für Label-Regeln hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
14 KiB
Go
446 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/archivmail/internal/auth"
|
|
"github.com/archivmail/internal/labelstore"
|
|
)
|
|
|
|
// ── PROJ-9: Labels API Handlers ───────────────────────────────────────────
|
|
|
|
// SetLabels wires the label store into the API server and registers label routes.
|
|
func (s *Server) SetLabels(store *labelstore.Store) {
|
|
s.labels = store
|
|
|
|
// User label routes (all authenticated users)
|
|
s.mux.HandleFunc("GET /api/labels", s.auth(s.handleGetLabels))
|
|
s.mux.HandleFunc("POST /api/labels", s.auth(s.handleCreateLabel))
|
|
s.mux.HandleFunc("PATCH /api/labels/{id}", s.auth(s.handleUpdateLabel))
|
|
s.mux.HandleFunc("DELETE /api/labels/{id}", s.auth(s.handleDeleteLabel))
|
|
|
|
// Email-label assignment routes
|
|
s.mux.HandleFunc("POST /api/mails/{id}/labels", s.auth(s.handleAssignLabel))
|
|
s.mux.HandleFunc("DELETE /api/mails/{id}/labels/{label_id}", s.auth(s.handleRemoveLabel))
|
|
|
|
// Admin label routes (domain_admin and above)
|
|
s.mux.HandleFunc("GET /api/admin/labels", s.authAdmin(s.handleGetAdminLabels))
|
|
s.mux.HandleFunc("POST /api/admin/labels", s.authAdmin(s.handleAdminCreateLabel))
|
|
s.mux.HandleFunc("GET /api/admin/label-rules", s.authAdmin(s.handleGetLabelRules))
|
|
s.mux.HandleFunc("POST /api/admin/label-rules", s.authAdmin(s.handleCreateLabelRule))
|
|
s.mux.HandleFunc("DELETE /api/admin/label-rules/{id}", s.authAdmin(s.handleDeleteLabelRule))
|
|
}
|
|
|
|
// handleGetLabels returns labels visible to the authenticated user
|
|
// (own labels + global/admin labels for their tenant).
|
|
// GET /api/labels
|
|
func (s *Server) handleGetLabels(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
tenantID := s.labelTenantIDInt64(sess)
|
|
labels, err := s.labels.GetLabelsForUser(r.Context(), sess.UserID, tenantID)
|
|
if err != nil {
|
|
s.logger.Error("get labels failed", "err", err, "user_id", sess.UserID)
|
|
writeError(w, http.StatusInternalServerError, "failed to load labels")
|
|
return
|
|
}
|
|
if labels == nil {
|
|
labels = []labelstore.Label{}
|
|
}
|
|
writeJSON(w, http.StatusOK, labels)
|
|
}
|
|
|
|
// handleCreateLabel creates a user-owned label.
|
|
// POST /api/labels
|
|
func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.Color == "" {
|
|
req.Color = "#6366f1"
|
|
}
|
|
if !isValidColor(req.Color) {
|
|
writeError(w, http.StatusBadRequest, "invalid color format")
|
|
return
|
|
}
|
|
|
|
tenantIDPtr := s.labelTenantID(sess)
|
|
if tenantIDPtr == nil {
|
|
writeError(w, http.StatusBadRequest, "superadmin cannot create personal labels without a tenant")
|
|
return
|
|
}
|
|
ownerID := sess.UserID
|
|
label, err := s.labels.CreateLabel(r.Context(), req.Name, req.Color, &ownerID, *tenantIDPtr)
|
|
if err != nil {
|
|
s.logger.Error("create label failed", "err", err, "user_id", sess.UserID)
|
|
writeError(w, http.StatusInternalServerError, "failed to create label")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, label)
|
|
}
|
|
|
|
// handleUpdateLabel updates a user's own label.
|
|
// PATCH /api/labels/{id}
|
|
func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
labelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid label id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.Color == "" {
|
|
req.Color = "#6366f1"
|
|
}
|
|
if !isValidColor(req.Color) {
|
|
writeError(w, http.StatusBadRequest, "invalid color format")
|
|
return
|
|
}
|
|
|
|
if err := s.labels.UpdateLabel(r.Context(), labelID, req.Name, req.Color, sess.UserID); err != nil {
|
|
s.logger.Error("update label failed", "err", err, "label_id", labelID, "user_id", sess.UserID)
|
|
writeError(w, http.StatusNotFound, "label not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// handleDeleteLabel deletes a user's own label.
|
|
// DELETE /api/labels/{id}
|
|
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
labelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid label id")
|
|
return
|
|
}
|
|
|
|
if err := s.labels.DeleteLabel(r.Context(), labelID, sess.UserID); err != nil {
|
|
s.logger.Error("delete label failed", "err", err, "label_id", labelID, "user_id", sess.UserID)
|
|
writeError(w, http.StatusNotFound, "label not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// handleAssignLabel assigns a label to an email.
|
|
// POST /api/mails/{id}/labels
|
|
func (s *Server) handleAssignLabel(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
mailID := r.PathValue("id")
|
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
|
if !isValidMailID(mailID) {
|
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
LabelID int64 `json:"label_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.LabelID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "label_id is required")
|
|
return
|
|
}
|
|
|
|
if err := s.labels.AssignLabel(r.Context(), mailID, req.LabelID, "user"); err != nil {
|
|
s.logger.Error("assign label failed", "err", err, "mail_id", mailID, "label_id", req.LabelID)
|
|
writeError(w, http.StatusInternalServerError, "failed to assign label")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// handleRemoveLabel removes a label assignment from an email.
|
|
// DELETE /api/mails/{id}/labels/{label_id}
|
|
func (s *Server) handleRemoveLabel(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.UserID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
mailID := r.PathValue("id")
|
|
// SEC-22: Validate mail ID format to prevent path traversal.
|
|
if !isValidMailID(mailID) {
|
|
writeError(w, http.StatusBadRequest, "invalid mail id")
|
|
return
|
|
}
|
|
|
|
labelID, err := strconv.ParseInt(r.PathValue("label_id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid label id")
|
|
return
|
|
}
|
|
|
|
if err := s.labels.RemoveLabel(r.Context(), mailID, labelID); err != nil {
|
|
s.logger.Error("remove label failed", "err", err, "mail_id", mailID, "label_id", labelID)
|
|
writeError(w, http.StatusInternalServerError, "failed to remove label")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// ── Admin Label Handlers ──────────────────────────────────────────────────
|
|
|
|
// handleGetAdminLabels returns all global labels (owner_id IS NULL) for the admin's tenant.
|
|
// Superadmins (no tenant) see labels where tenant_id IS NULL.
|
|
// GET /api/admin/labels
|
|
func (s *Server) handleGetAdminLabels(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
tenantID := s.labelTenantID(sess)
|
|
|
|
labels, err := s.labels.GetAdminLabels(r.Context(), tenantID)
|
|
if err != nil {
|
|
s.logger.Error("get admin labels failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to load labels")
|
|
return
|
|
}
|
|
if labels == nil {
|
|
labels = []labelstore.Label{}
|
|
}
|
|
writeJSON(w, http.StatusOK, labels)
|
|
}
|
|
|
|
// handleAdminCreateLabel creates a global label (no owner) for a tenant.
|
|
// POST /api/admin/labels
|
|
func (s *Server) handleAdminCreateLabel(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.Color == "" {
|
|
req.Color = "#6366f1"
|
|
}
|
|
if !isValidColor(req.Color) {
|
|
writeError(w, http.StatusBadRequest, "invalid color format")
|
|
return
|
|
}
|
|
|
|
tenantID := s.labelTenantID(sess)
|
|
label, err := s.labels.CreateAdminLabel(r.Context(), req.Name, req.Color, tenantID)
|
|
if err != nil {
|
|
s.logger.Error("admin create label failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to create label")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, label)
|
|
}
|
|
|
|
// handleGetLabelRules returns all auto-label rules for the admin's tenant.
|
|
// GET /api/admin/label-rules
|
|
func (s *Server) handleGetLabelRules(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
tenantID := s.labelTenantIDInt64(sess)
|
|
|
|
rules, err := s.labels.GetLabelRules(r.Context(), tenantID)
|
|
if err != nil {
|
|
s.logger.Error("get label rules failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to load label rules")
|
|
return
|
|
}
|
|
if rules == nil {
|
|
rules = []labelstore.LabelRule{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rules)
|
|
}
|
|
|
|
// handleCreateLabelRule creates an auto-label rule.
|
|
// POST /api/admin/label-rules
|
|
func (s *Server) handleCreateLabelRule(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
var req struct {
|
|
ConditionField string `json:"condition_field"`
|
|
ConditionValue string `json:"condition_value"`
|
|
LabelID int64 `json:"label_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.ConditionField == "" || req.ConditionValue == "" || req.LabelID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "condition_field, condition_value, and label_id are required")
|
|
return
|
|
}
|
|
|
|
// Validate allowed condition fields.
|
|
allowed := map[string]bool{
|
|
"from": true, "to": true, "subject": true, "domain": true,
|
|
"from_domain": true, "has_attachment": true,
|
|
}
|
|
if !allowed[req.ConditionField] {
|
|
writeError(w, http.StatusBadRequest, "invalid condition_field")
|
|
return
|
|
}
|
|
|
|
tenantIDPtr := s.labelTenantID(sess)
|
|
if tenantIDPtr == nil {
|
|
writeError(w, http.StatusBadRequest, "superadmin cannot create label rules without a tenant")
|
|
return
|
|
}
|
|
rule, err := s.labels.CreateLabelRule(r.Context(), req.ConditionField, req.ConditionValue, req.LabelID, *tenantIDPtr)
|
|
if err != nil {
|
|
s.logger.Error("create label rule failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to create label rule")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, rule)
|
|
}
|
|
|
|
// handleDeleteLabelRule deletes an auto-label rule.
|
|
// DELETE /api/admin/label-rules/{id}
|
|
func (s *Server) handleDeleteLabelRule(w http.ResponseWriter, r *http.Request) {
|
|
if s.labels == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "labels not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
ruleID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid rule id")
|
|
return
|
|
}
|
|
|
|
tenantID := s.labelTenantIDInt64(sess)
|
|
if err := s.labels.DeleteLabelRule(r.Context(), ruleID, tenantID); err != nil {
|
|
s.logger.Error("delete label rule failed", "err", err, "rule_id", ruleID)
|
|
writeError(w, http.StatusNotFound, "rule not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
// labelTenantID extracts the tenant ID from the session for label operations.
|
|
// Returns nil for superadmins without a tenant (stored as NULL in DB).
|
|
func (s *Server) labelTenantID(sess *auth.Session) *int64 {
|
|
return sess.TenantID
|
|
}
|
|
|
|
// labelTenantIDInt64 returns tenant_id as int64 (0 for superadmin with no tenant).
|
|
// Only use for read queries where a zero result is acceptable (returns empty list).
|
|
func (s *Server) labelTenantIDInt64(sess *auth.Session) int64 {
|
|
if sess.TenantID != nil {
|
|
return *sess.TenantID
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// isValidColor validates a hex color string like "#6366f1".
|
|
func isValidColor(c string) bool {
|
|
if len(c) != 7 || c[0] != '#' {
|
|
return false
|
|
}
|
|
for _, b := range c[1:] {
|
|
if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|