4ef5897e68
- storage/quota.go: SQL-Bug gefixt (emails.size → size_bytes, email_refs JOIN)
- tenantstore/quota.go: GetUsage nutzt jetzt email_refs JOIN für korrekte Tenant-Isolation
- smtpd: ErrQuotaExceeded → SMTP 452 statt 554 (MTA-retry statt permanent reject)
- admin_handlers: handleCreateUser prüft max_users-Quota → HTTP 402 bei Überschreitung
- quota_handlers: handleGetTenantUsage gibt jetzt warnings-Feld mit soft-limit-Prozenten zurück
- server.go: spec-konforme Alias-Route GET /api/admin/tenants/{id}/usage registriert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
98 lines
2.5 KiB
Go
98 lines
2.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrQuotaExceeded is returned when a tenant's storage or email quota is exceeded.
|
|
var ErrQuotaExceeded = errors.New("storage: tenant quota exceeded")
|
|
|
|
const quotaCacheTTL = 60 * time.Second
|
|
|
|
type quotaEntry struct {
|
|
exceeded bool
|
|
expiry time.Time
|
|
}
|
|
|
|
type quotaCache struct {
|
|
mu sync.Mutex
|
|
entries map[int64]quotaEntry
|
|
}
|
|
|
|
var qCache = "aCache{entries: make(map[int64]quotaEntry)}
|
|
|
|
// CheckQuota checks whether a tenant has exceeded its storage or email quota.
|
|
// Returns ErrQuotaExceeded if exceeded. Results are cached for 60 seconds.
|
|
// If tenantID is nil (no-tenant mail), quota is not enforced.
|
|
func (s *Store) CheckQuota(ctx context.Context, tenantID *int64) error {
|
|
if s.db == nil || tenantID == nil {
|
|
return nil
|
|
}
|
|
|
|
id := *tenantID
|
|
|
|
// Check cache first
|
|
qCache.mu.Lock()
|
|
if e, ok := qCache.entries[id]; ok && time.Now().Before(e.expiry) {
|
|
exceeded := e.exceeded
|
|
qCache.mu.Unlock()
|
|
if exceeded {
|
|
return ErrQuotaExceeded
|
|
}
|
|
return nil
|
|
}
|
|
qCache.mu.Unlock()
|
|
|
|
// Query quota limits and current usage in one statement.
|
|
// Use email_refs for correct tenant-aware email counting (cross-tenant dedup).
|
|
// size_bytes is the column name in the emails table.
|
|
var maxStorageBytes *int64
|
|
var maxEmails *int64
|
|
var currentBytes int64
|
|
var currentEmails int64
|
|
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT t.max_storage_bytes, t.max_emails,
|
|
COALESCE(SUM(e.size_bytes), 0) AS total_bytes,
|
|
COUNT(r.id) AS total_emails
|
|
FROM tenants t
|
|
LEFT JOIN email_refs r ON r.tenant_id = t.id
|
|
LEFT JOIN emails e ON e.id = r.email_id
|
|
WHERE t.id = $1
|
|
GROUP BY t.id
|
|
`, id).Scan(&maxStorageBytes, &maxEmails, ¤tBytes, ¤tEmails)
|
|
if err != nil {
|
|
// Tenant not found or DB error — allow the mail to pass through
|
|
return nil
|
|
}
|
|
|
|
exceeded := false
|
|
if maxStorageBytes != nil && currentBytes >= *maxStorageBytes {
|
|
exceeded = true
|
|
}
|
|
if maxEmails != nil && currentEmails >= *maxEmails {
|
|
exceeded = true
|
|
}
|
|
|
|
// Write to cache
|
|
qCache.mu.Lock()
|
|
qCache.entries[id] = quotaEntry{exceeded: exceeded, expiry: time.Now().Add(quotaCacheTTL)}
|
|
qCache.mu.Unlock()
|
|
|
|
if exceeded {
|
|
return ErrQuotaExceeded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InvalidateQuotaCache removes the cached quota entry for a tenant.
|
|
// Call this after quota changes to ensure fresh values on the next check.
|
|
func InvalidateQuotaCache(tenantID int64) {
|
|
qCache.mu.Lock()
|
|
delete(qCache.entries, tenantID)
|
|
qCache.mu.Unlock()
|
|
}
|