feat(PROJ-24): Mandanten-Logo Upload
- DB: logo_data (BYTEA) + logo_content_type Spalten in tenants-Tabelle - Backend: SetLogo/GetLogo/DeleteLogo im tenantstore - API: Logo-Endpunkte für superadmin (beliebiger Mandant) und domain_admin (eigener Mandant), max. 2 MB, PNG/JPEG/GIF/WebP/SVG - Frontend: Logo-Dialog in Mandantentabelle (superadmin), Logo-Upload-Sektion im LDAP-Tab (domain_admin) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -12,6 +16,8 @@ import (
|
||||
"github.com/archivmail/internal/userstore"
|
||||
)
|
||||
|
||||
const maxLogoSize = 2 * 1024 * 1024 // 2 MB
|
||||
|
||||
// ── Server extension fields and wiring ──────────────────────────────────────
|
||||
|
||||
// ldapStore and tenantStore are added to the Server struct via SetLDAP / SetTenants.
|
||||
@@ -41,6 +47,16 @@ func (s *Server) SetTenants(store *tenantstore.Store) {
|
||||
s.mux.HandleFunc("POST /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleAddTenantDomain)))
|
||||
s.mux.HandleFunc("DELETE /api/tenants/{id}/domains/{did}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleRemoveTenantDomain)))
|
||||
s.mux.HandleFunc("GET /api/tenants/{id}/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantUsers)))
|
||||
|
||||
// Logo routes: any auth can read; admin can write
|
||||
s.mux.HandleFunc("GET /api/tenants/{id}/logo", s.auth(s.handleGetTenantLogo))
|
||||
s.mux.HandleFunc("POST /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadTenantLogo)))
|
||||
s.mux.HandleFunc("DELETE /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteTenantLogo)))
|
||||
|
||||
// Logo routes for domain_admin (own tenant)
|
||||
s.mux.HandleFunc("GET /api/tenant/logo", s.authAdmin(s.handleGetOwnTenantLogo))
|
||||
s.mux.HandleFunc("POST /api/tenant/logo", s.authAdmin(s.handleUploadOwnTenantLogo))
|
||||
s.mux.HandleFunc("DELETE /api/tenant/logo", s.authAdmin(s.handleDeleteOwnTenantLogo))
|
||||
}
|
||||
|
||||
// ── LDAP handlers ────────────────────────────────────────────────────────────
|
||||
@@ -215,6 +231,50 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create default users for the new tenant.
|
||||
type defaultUserCreds struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
type createTenantResponse struct {
|
||||
*tenantstore.Tenant
|
||||
DefaultUsers []defaultUserCreds `json:"default_users"`
|
||||
}
|
||||
|
||||
resp := createTenantResponse{Tenant: tenant}
|
||||
for _, spec := range []struct {
|
||||
suffix string
|
||||
role string
|
||||
}{
|
||||
{suffix: "admin", role: userstore.RoleDomainAdmin},
|
||||
{suffix: "auditor", role: userstore.RoleAuditor},
|
||||
} {
|
||||
pw, pwErr := tenantRandomPassword()
|
||||
if pwErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate password")
|
||||
return
|
||||
}
|
||||
username := req.Slug + "-" + spec.suffix
|
||||
email := fmt.Sprintf("%s@%s.local", username, req.Slug)
|
||||
u, uErr := s.users.Create(userstore.CreateUserRequest{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: pw,
|
||||
Role: spec.role,
|
||||
TenantID: &tenant.ID,
|
||||
})
|
||||
if uErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create default user")
|
||||
return
|
||||
}
|
||||
resp.DefaultUsers = append(resp.DefaultUsers, defaultUserCreds{
|
||||
Username: u.Username,
|
||||
Password: pw,
|
||||
Role: spec.role,
|
||||
})
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "tenant_created",
|
||||
@@ -224,7 +284,7 @@ func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
Detail: "Mandant erstellt: " + req.Name,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, tenant)
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTenant(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -744,3 +804,204 @@ func parseTenantID(r *http.Request) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
}
|
||||
|
||||
// tenantRandomPassword generates a cryptographically random 16-byte hex password.
|
||||
func tenantRandomPassword() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
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("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
|
||||
}
|
||||
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
|
||||
}
|
||||
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,
|
||||
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("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,
|
||||
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"
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"image/svg+xml": true,
|
||||
}
|
||||
if !allowed[contentType] {
|
||||
writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp, svg)")
|
||||
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,
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user