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>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user