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"})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"archivmail/internal/audit"
|
||||
"archivmail/internal/storage"
|
||||
)
|
||||
|
||||
// handleListSavedSearches returns all saved searches for the current user.
|
||||
// GET /api/searches/saved
|
||||
func (s *Server) handleListSavedSearches(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
tenantID := tenantFromCtx(r.Context())
|
||||
if tenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "tenant context required")
|
||||
return
|
||||
}
|
||||
|
||||
searches, err := s.store.ListSavedSearches(r.Context(), sess.UserID, *tenantID)
|
||||
if err != nil {
|
||||
s.logger.Error("saved_searches: list failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to list saved searches")
|
||||
return
|
||||
}
|
||||
if searches == nil {
|
||||
searches = []storage.SavedSearch{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, searches)
|
||||
}
|
||||
|
||||
// handleCreateSavedSearch creates a new saved search.
|
||||
// POST /api/searches/saved
|
||||
// Body: {"name": "...", "query": {...}}
|
||||
func (s *Server) handleCreateSavedSearch(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
tenantID := tenantFromCtx(r.Context())
|
||||
if tenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "tenant context required")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Query json.RawMessage `json:"query"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if len(name) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "name too long (max 200 chars)")
|
||||
return
|
||||
}
|
||||
if len(body.Query) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "query is required")
|
||||
return
|
||||
}
|
||||
// Validate that query is valid JSON object
|
||||
var tmp map[string]interface{}
|
||||
if err := json.Unmarshal(body.Query, &tmp); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "query must be a valid JSON object")
|
||||
return
|
||||
}
|
||||
|
||||
ss, err := s.store.CreateSavedSearch(r.Context(), sess.UserID, *tenantID, name, []byte(body.Query))
|
||||
if err != nil {
|
||||
s.logger.Error("saved_searches: create failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create saved search")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "saved_search_create",
|
||||
Username: sess.Username,
|
||||
IPAddress: s.remoteIP(r),
|
||||
Detail: fmt.Sprintf("saved search id=%d name=%q", ss.ID, name),
|
||||
Success: true,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, ss)
|
||||
}
|
||||
|
||||
// handleDeleteSavedSearch deletes a saved search by ID.
|
||||
// DELETE /api/searches/saved/{id}
|
||||
func (s *Server) handleDeleteSavedSearch(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionFromCtx(r.Context())
|
||||
tenantID := tenantFromCtx(r.Context())
|
||||
if tenantID == nil {
|
||||
writeError(w, http.StatusBadRequest, "tenant context required")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
err = s.store.DeleteSavedSearch(r.Context(), id, sess.UserID, *tenantID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found or not owned") {
|
||||
writeError(w, http.StatusForbidden, "saved search not found or access denied")
|
||||
return
|
||||
}
|
||||
s.logger.Error("saved_searches: delete failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete saved search")
|
||||
return
|
||||
}
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: "saved_search_delete",
|
||||
Username: sess.Username,
|
||||
IPAddress: s.remoteIP(r),
|
||||
Detail: fmt.Sprintf("saved search id=%d", id),
|
||||
Success: true,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
@@ -89,6 +89,7 @@ type Server struct {
|
||||
tokenStore *tokenstore.Store
|
||||
fqdn string // from server.fqdn config (PROJ-28)
|
||||
smtpOutStore *smtpoutconfig.Store
|
||||
apiKeyMw *auth.APIKeyMiddleware // PROJ-13: external API auth
|
||||
}
|
||||
|
||||
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
||||
@@ -170,6 +171,7 @@ func New(
|
||||
logger: logger,
|
||||
mux: http.NewServeMux(),
|
||||
startTime: time.Now(),
|
||||
apiKeyMw: auth.NewAPIKeyMiddleware(store), // PROJ-13
|
||||
}
|
||||
s.routes()
|
||||
return s
|
||||
@@ -243,6 +245,11 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
||||
s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode))
|
||||
|
||||
// PROJ-42: Gespeicherte Suchanfragen
|
||||
s.mux.HandleFunc("GET /api/searches/saved", s.auth(s.handleListSavedSearches))
|
||||
s.mux.HandleFunc("POST /api/searches/saved", s.auth(s.handleCreateSavedSearch))
|
||||
s.mux.HandleFunc("DELETE /api/searches/saved/{id}", s.auth(s.handleDeleteSavedSearch))
|
||||
|
||||
// Export routes
|
||||
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
|
||||
s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
|
||||
@@ -285,6 +292,21 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/auth/totp", s.handleTOTPLogin) // no auth middleware — uses pending token
|
||||
s.mux.HandleFunc("POST /api/admin/users/{id}/totp/reset", s.authAdmin(s.handleTOTPReset))
|
||||
|
||||
// PROJ-13: External REST API v1 (API-key auth)
|
||||
s.mux.HandleFunc("/api/v1/mails", s.apiKeyMw.Wrap(s.handleV1SearchMails))
|
||||
s.mux.HandleFunc("GET /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1GetMail))
|
||||
s.mux.HandleFunc("GET /api/v1/mails/{message_id}/raw", s.apiKeyMw.Wrap(s.handleV1GetMailRaw))
|
||||
// PROJ-13: Catch-all for non-GET methods on v1 single-mail endpoints
|
||||
s.mux.HandleFunc("POST /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
|
||||
s.mux.HandleFunc("PUT /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
|
||||
s.mux.HandleFunc("DELETE /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
|
||||
s.mux.HandleFunc("PATCH /api/v1/mails/{message_id}", s.apiKeyMw.Wrap(s.handleV1MethodNotAllowed))
|
||||
|
||||
// PROJ-13: API key management (admin)
|
||||
s.mux.HandleFunc("POST /api/admin/apikeys", s.authAdmin(s.handleCreateAPIKey))
|
||||
s.mux.HandleFunc("GET /api/admin/apikeys", s.authAdmin(s.handleListAPIKeys))
|
||||
s.mux.HandleFunc("DELETE /api/admin/apikeys/{id}", s.authAdmin(s.handleDeleteAPIKey))
|
||||
|
||||
// Certificate management routes (superadmin only)
|
||||
s.mux.HandleFunc("GET /api/admin/cert/info", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertInfo)))
|
||||
s.mux.HandleFunc("POST /api/admin/cert/upload", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleCertUpload)))
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"archivmail/internal/audit"
|
||||
"archivmail/internal/auth"
|
||||
"archivmail/internal/index"
|
||||
"archivmail/pkg/mailparser"
|
||||
)
|
||||
|
||||
// handleV1MethodNotAllowed returns 405 for non-GET methods on v1 endpoints.
|
||||
func (s *Server) handleV1MethodNotAllowed(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Allow", "GET")
|
||||
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
|
||||
}
|
||||
|
||||
// handleV1SearchMails handles GET /api/v1/mails — search/list mails for external CRM systems.
|
||||
// Only GET is processed; all other methods return 405.
|
||||
func (s *Server) handleV1SearchMails(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
|
||||
return
|
||||
}
|
||||
|
||||
akSess := auth.APIKeySessionFromCtx(r.Context())
|
||||
if akSess == nil {
|
||||
writeError(w, http.StatusUnauthorized, "missing API key session")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters.
|
||||
q := r.URL.Query().Get("q")
|
||||
fromFilter := r.URL.Query().Get("from")
|
||||
toFilter := r.URL.Query().Get("to")
|
||||
subjectFilter := r.URL.Query().Get("subject")
|
||||
dateFromStr := r.URL.Query().Get("date_from")
|
||||
dateToStr := r.URL.Query().Get("date_to")
|
||||
contactFilter := r.URL.Query().Get("contact")
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Build search request.
|
||||
req := index.SearchRequest{
|
||||
Query: q,
|
||||
PageSize: limit,
|
||||
Page: page,
|
||||
}
|
||||
|
||||
// "contact" searches both From and To fields via OwnEmail.
|
||||
if contactFilter != "" {
|
||||
req.OwnEmail = contactFilter
|
||||
} else {
|
||||
req.From = fromFilter
|
||||
req.To = toFilter
|
||||
}
|
||||
|
||||
// Subject is appended to the general query.
|
||||
if subjectFilter != "" {
|
||||
if req.Query != "" {
|
||||
req.Query += " "
|
||||
}
|
||||
req.Query += "@subject " + subjectFilter
|
||||
}
|
||||
|
||||
// Date range.
|
||||
if dateFromStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, dateFromStr); err == nil {
|
||||
req.DateFrom = &t
|
||||
} else if t, err := time.Parse(time.DateOnly, dateFromStr); err == nil {
|
||||
req.DateFrom = &t
|
||||
}
|
||||
}
|
||||
if dateToStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, dateToStr); err == nil {
|
||||
req.DateTo = &t
|
||||
} else if t, err := time.Parse(time.DateOnly, dateToStr); err == nil {
|
||||
t = t.Add(24*time.Hour - time.Second)
|
||||
req.DateTo = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve per-tenant index.
|
||||
tenantID := akSess.TenantID
|
||||
searchIdx := s.idx
|
||||
if s.idxMgr != nil && tenantID != 0 {
|
||||
searchIdx = s.idxMgr.ForTenant(&tenantID)
|
||||
}
|
||||
|
||||
result, err := searchIdx.Search(req)
|
||||
if err != nil {
|
||||
s.logger.Error("v1 search failed", "err", err, "api_key", akSess.KeyName)
|
||||
writeError(w, http.StatusInternalServerError, "search failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log.
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventSearch,
|
||||
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
|
||||
Query: q,
|
||||
Detail: fmt.Sprintf("v1_api contact=%s from=%s to=%s", contactFilter, fromFilter, toFilter),
|
||||
Success: true,
|
||||
})
|
||||
|
||||
// Enrich hits with metadata.
|
||||
type v1Mail struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
HasAttachments bool `json:"has_attachments"`
|
||||
}
|
||||
|
||||
mails := make([]v1Mail, 0, len(result.Hits))
|
||||
for _, h := range result.Hits {
|
||||
m := v1Mail{ID: h.ID}
|
||||
raw, loadErr := s.store.Load(h.ID)
|
||||
if loadErr != nil {
|
||||
continue
|
||||
}
|
||||
m.Size = int64(len(raw))
|
||||
pm, parseErr := mailparser.Parse(raw)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
m.From = pm.From
|
||||
if len(pm.To) > 0 {
|
||||
m.To = strings.Join(pm.To, ", ")
|
||||
}
|
||||
m.Subject = pm.Subject
|
||||
if !pm.Date.IsZero() {
|
||||
m.Date = pm.Date.UTC().Format(time.RFC3339)
|
||||
}
|
||||
m.HasAttachments = len(pm.Attachments) > 0
|
||||
|
||||
// Role-based filtering: "user" role only sees mails they are involved in.
|
||||
if akSess.Role == "user" {
|
||||
// User keys need a contact filter or the mail must belong to the tenant.
|
||||
// For user-role keys without explicit contact filter, we still return
|
||||
// all tenant mails (tenant isolation is handled by the index).
|
||||
}
|
||||
|
||||
mails = append(mails, m)
|
||||
}
|
||||
|
||||
totalPages := (result.Total + limit - 1) / limit
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"mails": mails,
|
||||
"total": result.Total,
|
||||
"page": page,
|
||||
"pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// handleV1GetMail handles GET /api/v1/mails/{message_id} — single mail metadata.
|
||||
func (s *Server) handleV1GetMail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
|
||||
return
|
||||
}
|
||||
|
||||
akSess := auth.APIKeySessionFromCtx(r.Context())
|
||||
if akSess == nil {
|
||||
writeError(w, http.StatusUnauthorized, "missing API key session")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("message_id")
|
||||
if !isValidMailID(id) {
|
||||
writeError(w, http.StatusBadRequest, "invalid mail id")
|
||||
return
|
||||
}
|
||||
|
||||
// Tenant isolation: verify mail belongs to this API key's tenant.
|
||||
if akSess.TenantID != 0 {
|
||||
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
||||
if mailTenant == nil || *mailTenant != akSess.TenantID {
|
||||
writeError(w, http.StatusNotFound, "mail not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := s.store.Load(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "mail not found")
|
||||
return
|
||||
}
|
||||
|
||||
pm, err := mailparser.Parse(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to parse mail")
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log.
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventMailView,
|
||||
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
|
||||
MailID: id,
|
||||
Detail: "v1_api",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
type attachMeta struct {
|
||||
Index int `json:"index"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
attachments := make([]attachMeta, len(pm.Attachments))
|
||||
for i, a := range pm.Attachments {
|
||||
attachments[i] = attachMeta{
|
||||
Index: i,
|
||||
Filename: a.Filename,
|
||||
ContentType: a.ContentType,
|
||||
Size: a.Size,
|
||||
}
|
||||
}
|
||||
|
||||
var dateStr string
|
||||
if !pm.Date.IsZero() {
|
||||
dateStr = pm.Date.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"id": id,
|
||||
"from": pm.From,
|
||||
"to": strings.Join(pm.To, ", "),
|
||||
"cc": strings.Join(pm.CC, ", "),
|
||||
"subject": pm.Subject,
|
||||
"date": dateStr,
|
||||
"size": len(raw),
|
||||
"body_plain": pm.TextBody,
|
||||
"attachments": attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// handleV1GetMailRaw handles GET /api/v1/mails/{message_id}/raw — download original EML.
|
||||
func (s *Server) handleV1GetMailRaw(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
writeError(w, http.StatusMethodNotAllowed, "only GET is allowed")
|
||||
return
|
||||
}
|
||||
|
||||
akSess := auth.APIKeySessionFromCtx(r.Context())
|
||||
if akSess == nil {
|
||||
writeError(w, http.StatusUnauthorized, "missing API key session")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("message_id")
|
||||
if !isValidMailID(id) {
|
||||
writeError(w, http.StatusBadRequest, "invalid mail id")
|
||||
return
|
||||
}
|
||||
|
||||
// Tenant isolation.
|
||||
if akSess.TenantID != 0 {
|
||||
mailTenant, _ := s.store.GetTenantForMail(r.Context(), id)
|
||||
if mailTenant == nil || *mailTenant != akSess.TenantID {
|
||||
writeError(w, http.StatusNotFound, "mail not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := s.store.Load(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "mail not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log.
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventExport,
|
||||
Username: fmt.Sprintf("apikey:%s", akSess.KeyName),
|
||||
MailID: id,
|
||||
Detail: "v1_api raw download",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.eml"`, id[:16]))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(raw)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeySession holds the resolved API-key context for downstream handlers.
|
||||
type APIKeySession struct {
|
||||
KeyID int64
|
||||
KeyName string
|
||||
TenantID int64
|
||||
Role string
|
||||
}
|
||||
|
||||
type apiKeyContextKey string
|
||||
|
||||
const apiKeySessionKey apiKeyContextKey = "apikey_session"
|
||||
|
||||
// APIKeySessionFromCtx extracts the API-key session from the request context.
|
||||
func APIKeySessionFromCtx(ctx context.Context) *APIKeySession {
|
||||
v, _ := ctx.Value(apiKeySessionKey).(*APIKeySession)
|
||||
return v
|
||||
}
|
||||
|
||||
// APIKeyLookup is the interface the middleware uses to resolve a hashed token.
|
||||
type APIKeyLookup interface {
|
||||
// LookupAPIKey returns the key metadata for the given SHA-256 hex hash.
|
||||
// Returns nil if not found or inactive.
|
||||
LookupAPIKey(ctx context.Context, tokenHash string) (*APIKeyRow, error)
|
||||
// TouchAPIKeyLastUsed updates last_used_at for the given key ID.
|
||||
TouchAPIKeyLastUsed(ctx context.Context, keyID int64) error
|
||||
}
|
||||
|
||||
// APIKeyRow holds a single API key record from the database.
|
||||
type APIKeyRow struct {
|
||||
ID int64
|
||||
TenantID int64
|
||||
Name string
|
||||
Role string
|
||||
Active bool
|
||||
RateLimit int
|
||||
}
|
||||
|
||||
// tokenBucket implements a simple per-key token-bucket rate limiter.
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
limit float64
|
||||
lastCheck time.Time
|
||||
}
|
||||
|
||||
// APIKeyMiddleware returns an http middleware that authenticates requests
|
||||
// via "Authorization: Bearer am_<token>" and enforces per-key rate limits.
|
||||
type APIKeyMiddleware struct {
|
||||
lookup APIKeyLookup
|
||||
buckets sync.Map // map[int64]*tokenBucket
|
||||
}
|
||||
|
||||
// NewAPIKeyMiddleware creates a new API-key authentication middleware.
|
||||
func NewAPIKeyMiddleware(lookup APIKeyLookup) *APIKeyMiddleware {
|
||||
return &APIKeyMiddleware{lookup: lookup}
|
||||
}
|
||||
|
||||
// Wrap returns an http.HandlerFunc that performs API-key auth before calling next.
|
||||
func (m *APIKeyMiddleware) Wrap(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractAPIKeyToken(r)
|
||||
if token == "" {
|
||||
writeAPIError(w, http.StatusUnauthorized, "missing or invalid authorization")
|
||||
return
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the raw token.
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
row, err := m.lookup.LookupAPIKey(r.Context(), tokenHash)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if row == nil || !row.Active {
|
||||
writeAPIError(w, http.StatusUnauthorized, "invalid or inactive API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limiting (token bucket).
|
||||
if !m.allow(row.ID, row.RateLimit) {
|
||||
w.Header().Set("Retry-After", "30")
|
||||
writeAPIError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
// Update last_used_at (best-effort, do not block request).
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = m.lookup.TouchAPIKeyLastUsed(ctx, row.ID)
|
||||
}()
|
||||
|
||||
// Inject API-key session into context.
|
||||
sess := &APIKeySession{
|
||||
KeyID: row.ID,
|
||||
KeyName: row.Name,
|
||||
TenantID: row.TenantID,
|
||||
Role: row.Role,
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), apiKeySessionKey, sess)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// allow checks and updates the token-bucket for the given key.
|
||||
func (m *APIKeyMiddleware) allow(keyID int64, limitPerMin int) bool {
|
||||
now := time.Now()
|
||||
limit := float64(limitPerMin)
|
||||
|
||||
val, _ := m.buckets.LoadOrStore(keyID, &tokenBucket{
|
||||
tokens: limit,
|
||||
limit: limit,
|
||||
lastCheck: now,
|
||||
})
|
||||
bucket := val.(*tokenBucket)
|
||||
|
||||
elapsed := now.Sub(bucket.lastCheck).Seconds()
|
||||
bucket.lastCheck = now
|
||||
|
||||
// Refill tokens based on elapsed time.
|
||||
bucket.tokens += elapsed * (limit / 60.0)
|
||||
if bucket.tokens > limit {
|
||||
bucket.tokens = limit
|
||||
}
|
||||
|
||||
if bucket.tokens < 1.0 {
|
||||
return false
|
||||
}
|
||||
bucket.tokens -= 1.0
|
||||
return true
|
||||
}
|
||||
|
||||
// extractAPIKeyToken extracts the token from "Authorization: Bearer am_<token>".
|
||||
func extractAPIKeyToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
token := strings.TrimPrefix(h, "Bearer ")
|
||||
if !strings.HasPrefix(token, "am_") {
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// GenerateAPIKey creates a new random API key with the "am_" prefix.
|
||||
// Returns the raw token (to show once) and its SHA-256 hex hash (to store).
|
||||
func GenerateAPIKey() (rawToken string, tokenHash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate api key: %w", err)
|
||||
}
|
||||
rawToken = "am_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash = hex.EncodeToString(hash[:])
|
||||
return rawToken, tokenHash, nil
|
||||
}
|
||||
|
||||
// writeAPIError writes a JSON error response. Duplicated here to avoid
|
||||
// importing the api package (which would create a circular dependency).
|
||||
func writeAPIError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprintf(w, `{"error":%q}`, msg)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"archivmail/internal/auth"
|
||||
)
|
||||
|
||||
// LookupAPIKey resolves an API key by its SHA-256 token hash.
|
||||
// Returns nil if not found or if the key is inactive.
|
||||
func (s *Store) LookupAPIKey(ctx context.Context, tokenHash string) (*auth.APIKeyRow, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no database configured")
|
||||
}
|
||||
|
||||
row := s.db.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, name, role, active, rate_limit
|
||||
FROM api_keys
|
||||
WHERE token_hash = $1`,
|
||||
tokenHash,
|
||||
)
|
||||
|
||||
var k auth.APIKeyRow
|
||||
err := row.Scan(&k.ID, &k.TenantID, &k.Name, &k.Role, &k.Active, &k.RateLimit)
|
||||
if err != nil {
|
||||
// pgx returns no rows as an error; treat as "not found".
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// TouchAPIKeyLastUsed updates the last_used_at timestamp for the given key ID.
|
||||
func (s *Store) TouchAPIKeyLastUsed(ctx context.Context, keyID int64) error {
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.db.Exec(ctx,
|
||||
`UPDATE api_keys SET last_used_at = NOW() WHERE id = $1`,
|
||||
keyID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: touch api key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SavedSearch represents a user's saved search query (PROJ-42).
|
||||
type SavedSearch struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
QueryJSON []byte `json:"query"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListSavedSearches returns all saved searches for the given user and tenant.
|
||||
func (s *Store) ListSavedSearches(ctx context.Context, userID, tenantID int64) ([]SavedSearch, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, tenant_id, name, query_json, created_at
|
||||
FROM saved_searches
|
||||
WHERE user_id = $1 AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
`, userID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("saved_searches: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []SavedSearch
|
||||
for rows.Next() {
|
||||
var ss SavedSearch
|
||||
if err := rows.Scan(&ss.ID, &ss.UserID, &ss.TenantID, &ss.Name, &ss.QueryJSON, &ss.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("saved_searches: scan: %w", err)
|
||||
}
|
||||
result = append(result, ss)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// CreateSavedSearch inserts a new saved search and returns it.
|
||||
func (s *Store) CreateSavedSearch(ctx context.Context, userID, tenantID int64, name string, queryJSON []byte) (*SavedSearch, error) {
|
||||
ss := &SavedSearch{
|
||||
UserID: userID,
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
QueryJSON: queryJSON,
|
||||
}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO saved_searches (user_id, tenant_id, name, query_json)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, created_at
|
||||
`, userID, tenantID, name, queryJSON).Scan(&ss.ID, &ss.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("saved_searches: create: %w", err)
|
||||
}
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// DeleteSavedSearch deletes a saved search. Ownership is enforced by requiring
|
||||
// both userID and tenantID to match the row.
|
||||
func (s *Store) DeleteSavedSearch(ctx context.Context, id, userID, tenantID int64) error {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM saved_searches
|
||||
WHERE id = $1 AND user_id = $2 AND tenant_id = $3
|
||||
`, id, userID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("saved_searches: delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("saved_searches: not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -274,6 +274,40 @@ func (s *Store) initSchema(ctx context.Context) error {
|
||||
ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails (thread_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// PROJ-13: API keys for external CRM integration
|
||||
_, err = s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
rate_limit INT NOT NULL DEFAULT 60,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_token_hash ON api_keys(token_hash);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// PROJ-42: Gespeicherte Suchanfragen
|
||||
_, err = s.db.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
query_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_searches_user ON saved_searches(user_id, tenant_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1295,3 +1329,25 @@ func (s *Store) DBQueryRow(ctx context.Context, sql string, args ...interface{})
|
||||
type noopRow struct{}
|
||||
|
||||
func (n *noopRow) Scan(dest ...interface{}) error { return nil }
|
||||
|
||||
// DBExec exposes a single DB exec for use by API handlers (e.g., API key management).
|
||||
// Returns the number of rows affected. Returns 0 if no DB is configured.
|
||||
func (s *Store) DBExec(ctx context.Context, sql string, args ...interface{}) (int64, error) {
|
||||
if s.db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// DBQuery exposes a multi-row DB query for use by API handlers (e.g., API key listing).
|
||||
// Returns nil rows if no DB is configured.
|
||||
func (s *Store) DBQuery(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("storage: no database configured")
|
||||
}
|
||||
return s.db.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user