3b05e949dd
PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden
PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
199 lines
4.9 KiB
Go
199 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"archivmail/internal/audit"
|
|
"archivmail/internal/auth"
|
|
)
|
|
|
|
// apiKeyCreateRequest is the JSON body for POST /api/admin/apikeys.
|
|
type apiKeyCreateRequest struct {
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
RateLimit int `json:"rate_limit"`
|
|
}
|
|
|
|
// handleCreateAPIKey generates a new API key for the current tenant.
|
|
// POST /api/admin/apikeys
|
|
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
var req apiKeyCreateRequest
|
|
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
|
|
}
|
|
|
|
// Validate role.
|
|
if req.Role == "" {
|
|
req.Role = "user"
|
|
}
|
|
if req.Role != "user" && req.Role != "auditor" {
|
|
writeError(w, http.StatusBadRequest, "role must be 'user' or 'auditor'")
|
|
return
|
|
}
|
|
|
|
if req.RateLimit <= 0 {
|
|
req.RateLimit = 60
|
|
}
|
|
if req.RateLimit > 1000 {
|
|
req.RateLimit = 1000
|
|
}
|
|
|
|
// Generate key.
|
|
rawToken, tokenHash, err := auth.GenerateAPIKey()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate API key")
|
|
return
|
|
}
|
|
|
|
// Determine tenant_id.
|
|
var tid int64
|
|
if tenantID != nil {
|
|
tid = *tenantID
|
|
}
|
|
if tid == 0 {
|
|
writeError(w, http.StatusBadRequest, "API keys require a tenant context")
|
|
return
|
|
}
|
|
|
|
// Insert into DB.
|
|
var keyID int64
|
|
row := s.store.DBQueryRow(r.Context(),
|
|
`INSERT INTO api_keys (tenant_id, name, token_hash, role, rate_limit)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id`,
|
|
tid, req.Name, tokenHash, req.Role, req.RateLimit,
|
|
)
|
|
if err := row.Scan(&keyID); err != nil {
|
|
s.logger.Error("create api key failed", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to create API key")
|
|
return
|
|
}
|
|
|
|
// Audit log.
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
Detail: fmt.Sprintf("created api key %q (id=%d, role=%s)", req.Name, keyID, req.Role),
|
|
Success: true,
|
|
})
|
|
|
|
// Return the raw token ONCE.
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"id": keyID,
|
|
"name": req.Name,
|
|
"role": req.Role,
|
|
"rate_limit": req.RateLimit,
|
|
"token": rawToken,
|
|
"message": "Save this token now. It will not be shown again.",
|
|
})
|
|
}
|
|
|
|
// handleListAPIKeys lists API keys for the current tenant.
|
|
// GET /api/admin/apikeys
|
|
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := tenantFromCtx(r.Context())
|
|
var tid int64
|
|
if tenantID != nil {
|
|
tid = *tenantID
|
|
}
|
|
|
|
rows, err := s.store.DBQuery(r.Context(),
|
|
`SELECT id, name, role, active, rate_limit, created_at, last_used_at
|
|
FROM api_keys
|
|
WHERE tenant_id = $1
|
|
ORDER BY created_at DESC`,
|
|
tid,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list API keys")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type apiKeyResponse struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
Active bool `json:"active"`
|
|
RateLimit int `json:"rate_limit"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastUsedAt *string `json:"last_used_at"`
|
|
}
|
|
|
|
keys := make([]apiKeyResponse, 0)
|
|
for rows.Next() {
|
|
var k apiKeyResponse
|
|
var createdAt time.Time
|
|
var lastUsedAt *time.Time
|
|
if err := rows.Scan(&k.ID, &k.Name, &k.Role, &k.Active, &k.RateLimit, &createdAt, &lastUsedAt); err != nil {
|
|
continue
|
|
}
|
|
k.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
|
if lastUsedAt != nil {
|
|
s := lastUsedAt.UTC().Format(time.RFC3339)
|
|
k.LastUsedAt = &s
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"api_keys": keys,
|
|
})
|
|
}
|
|
|
|
// handleDeleteAPIKey deletes an API key belonging to the current tenant.
|
|
// DELETE /api/admin/apikeys/{id}
|
|
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
tenantID := tenantFromCtx(r.Context())
|
|
|
|
idStr := r.PathValue("id")
|
|
keyID, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid key id")
|
|
return
|
|
}
|
|
|
|
var tid int64
|
|
if tenantID != nil {
|
|
tid = *tenantID
|
|
}
|
|
|
|
// Delete only if it belongs to this tenant.
|
|
tag, err := s.store.DBExec(r.Context(),
|
|
`DELETE FROM api_keys WHERE id = $1 AND tenant_id = $2`,
|
|
keyID, tid,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete API key")
|
|
return
|
|
}
|
|
if tag == 0 {
|
|
writeError(w, http.StatusNotFound, "API key not found")
|
|
return
|
|
}
|
|
|
|
// Audit log.
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
Detail: fmt.Sprintf("deleted api key id=%d", keyID),
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|