package api import ( "fmt" "io" "net/http" "strconv" "archivmail/internal/audit" ) // ── Logo handlers (admin: any tenant) ─────────────────────────────────────── func (s *Server) handleGetTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } // Tenant-scope enforcement (IDOR fix, PROJ-61): non-global sessions may only // read their own tenant's logo. Global admins (sess.TenantID == nil) see all. sess := sessionFromCtx(r.Context()) if !tenantAccessAllowed(sess, &id) { writeError(w, http.StatusForbidden, "access denied") return } data, contentType, err := s.tenantStore.GetLogo(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load logo") return } if data == nil { writeError(w, http.StatusNotFound, "no logo set") return } if contentType == "" { contentType = "image/png" } w.Header().Set("Content-Type", contentType) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "public, max-age=86400") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (s *Server) handleUploadTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } // Defense-in-depth tenant scope check (PROJ-63): no-op for global admins. if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) { writeError(w, http.StatusForbidden, "access denied") return } s.saveTenantLogo(w, r, id) } func (s *Server) handleDeleteTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } // Defense-in-depth tenant scope check (PROJ-63): no-op for global admins. if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) { writeError(w, http.StatusForbidden, "access denied") return } if err := s.tenantStore.DeleteLogo(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete logo") return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_logo_deleted", Username: sess.Username, TenantID: sess.TenantID, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-Logo gelöscht (tenant " + strconv.FormatInt(id, 10) + ")", }) w.WriteHeader(http.StatusNoContent) } // ── Logo handlers (domain_admin: own tenant) ───────────────────────────────── func (s *Server) handleGetOwnTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } data, contentType, err := s.tenantStore.GetLogo(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load logo") return } if data == nil { writeError(w, http.StatusNotFound, "no logo set") return } if contentType == "" { contentType = "image/png" } w.Header().Set("Content-Type", contentType) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "public, max-age=86400") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (s *Server) handleUploadOwnTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } s.saveTenantLogo(w, r, *sess.TenantID) } func (s *Server) handleDeleteOwnTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } if err := s.tenantStore.DeleteLogo(r.Context(), *sess.TenantID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete logo") return } s.audlog.Log(audit.Entry{ EventType: "tenant_logo_deleted", Username: sess.Username, TenantID: sess.TenantID, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-Logo gelöscht", }) w.WriteHeader(http.StatusNoContent) } // saveTenantLogo is the shared multipart upload logic for logo handlers. func (s *Server) saveTenantLogo(w http.ResponseWriter, r *http.Request, tenantID int64) { if err := r.ParseMultipartForm(maxLogoSize); err != nil { writeError(w, http.StatusBadRequest, "failed to parse multipart form") return } file, header, err := r.FormFile("logo") if err != nil { writeError(w, http.StatusBadRequest, "logo file required") return } defer file.Close() contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = "image/png" } // SVG intentionally NOT allowed (PROJ-61): SVG can carry embedded // JavaScript and would be served same-origin as image/svg+xml → stored XSS. allowed := map[string]bool{ "image/png": true, "image/jpeg": true, "image/jpg": true, "image/gif": true, "image/webp": true, } if !allowed[contentType] { writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp)") return } data, err := io.ReadAll(io.LimitReader(file, maxLogoSize+1)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read logo") return } if int64(len(data)) > maxLogoSize { writeError(w, http.StatusBadRequest, "logo too large (max 2 MB)") return } if err := s.tenantStore.SetLogo(r.Context(), tenantID, data, contentType); err != nil { writeError(w, http.StatusInternalServerError, "failed to save logo") return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_logo_uploaded", Username: sess.Username, TenantID: sess.TenantID, IPAddress: s.remoteIP(r), Success: true, Detail: fmt.Sprintf("Mandant-Logo hochgeladen (%d bytes, %s, tenant %d)", len(data), contentType, tenantID), }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }