Files
archivmail/internal/api/apikey_handlers.go
T
sysops 3b05e949dd feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen
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>
2026-04-06 10:54:26 +02:00

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"})
}