549 lines
22 KiB
Go
549 lines
22 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/metazla/meta-core/internal/config"
|
|
"github.com/metazla/meta-core/internal/events"
|
|
"github.com/metazla/meta-core/internal/identity"
|
|
"github.com/metazla/meta-core/internal/leader"
|
|
"github.com/metazla/meta-core/internal/meshdisco"
|
|
"github.com/metazla/meta-core/internal/mounts"
|
|
"github.com/metazla/meta-core/internal/schema"
|
|
"github.com/metazla/meta-core/internal/storage"
|
|
"github.com/metazla/meta-core/internal/watcher"
|
|
"github.com/metazla/meta-core/internal/watchers"
|
|
"github.com/metazla/meta-core/internal/webdav"
|
|
)
|
|
|
|
// Server is the HTTP API server for meta-core
|
|
type Server struct {
|
|
config *config.Config
|
|
leaderProvider *leader.LeaderInfoProvider
|
|
storage *storage.Client
|
|
mountsManager *mounts.Manager
|
|
mountsHandlers *mounts.Handlers
|
|
mountPoller *mounts.Poller
|
|
mountStatsPoller *mounts.StatsPoller
|
|
watcherDispatcher *watcher.Dispatcher
|
|
fileWatcher *watcher.Watcher
|
|
watcherHandlers *watcher.Handlers
|
|
watchersManager *watchers.Manager
|
|
watchersPoller *watchers.Poller
|
|
watchersHandlers *watchers.Handlers
|
|
metaPublisher *events.MetaPublisher
|
|
schemaIndexer *schema.Indexer
|
|
webdavHandler *webdav.Handler
|
|
search *searchIndex
|
|
// challenges backs proof-of-possession on reveal/delete. In-process, so a
|
|
// restart invalidates the ones in flight — see identity/challenge.go.
|
|
challenges *identity.ChallengeStore
|
|
// mesh is the UDP discovery node (beacon v2). nil when discovery
|
|
// is disabled or failed to bind, in which case /api/neighbors reports an
|
|
// empty list rather than erroring — a dashboard nav is decoration and
|
|
// must not take the API down with it.
|
|
mesh *meshdisco.Node
|
|
router *mux.Router
|
|
server *http.Server
|
|
// stats caches the keyspace-walk half of GET /api/stats so a polling
|
|
// dashboard never puts a multi-second Redis walk on the request path.
|
|
stats *statsCache
|
|
}
|
|
|
|
// SetMeshNode attaches the UDP discovery node. Separate from NewServer so the
|
|
// server's constructor signature (and its tests) stay untouched.
|
|
func (s *Server) SetMeshNode(n *meshdisco.Node) {
|
|
s.mesh = n
|
|
}
|
|
|
|
// NewServer creates a new API server
|
|
func NewServer(
|
|
cfg *config.Config,
|
|
leaderProvider *leader.LeaderInfoProvider,
|
|
stor *storage.Client,
|
|
) *Server {
|
|
s := &Server{
|
|
config: cfg,
|
|
leaderProvider: leaderProvider,
|
|
storage: stor,
|
|
search: &searchIndex{},
|
|
router: mux.NewRouter(),
|
|
stats: &statsCache{},
|
|
}
|
|
|
|
// Initialize file watcher/scanner and dispatcher
|
|
var scanFunc mounts.ScanFunc
|
|
if cfg.EnableFileWatcher {
|
|
s.watcherDispatcher = watcher.NewDispatcher()
|
|
// Wire storage client for Redis Stream publishing
|
|
if stor != nil {
|
|
s.watcherDispatcher.SetStorageClient(stor)
|
|
}
|
|
fileWatcher, err := watcher.NewWatcher(cfg, s.watcherDispatcher)
|
|
if err != nil {
|
|
log.Printf("[API] Warning: failed to initialize file watcher: %v", err)
|
|
} else {
|
|
s.fileWatcher = fileWatcher
|
|
// Wire storage so the watcher can persist (path,size,mtime,cid)
|
|
// tuples to Redis as it computes midhashes.
|
|
if stor != nil {
|
|
fileWatcher.SetStorageClient(stor)
|
|
}
|
|
s.watcherHandlers = watcher.NewHandlers(fileWatcher, s.watcherDispatcher)
|
|
// Create scan function from watcher for mounts
|
|
scanFunc = fileWatcher.ScanMountPath
|
|
}
|
|
|
|
// Initialize watchers manager (polling-based file watching)
|
|
watchersManager, err := watchers.NewManager(cfg)
|
|
if err != nil {
|
|
log.Printf("[API] Warning: failed to initialize watchers manager: %v", err)
|
|
} else {
|
|
s.watchersManager = watchersManager
|
|
|
|
// Create watchers poller
|
|
if s.fileWatcher != nil {
|
|
s.watchersPoller = watchers.NewPoller(watchersManager, s.fileWatcher, s.watcherDispatcher)
|
|
// Pass republish callback to handlers (handles nil metaPublisher case)
|
|
s.watchersHandlers = watchers.NewHandlers(watchersManager, s.watchersPoller, s.RepublishMetadata)
|
|
|
|
// Hydrate the in-memory state registry from Redis tuples so
|
|
// the first scan tick after boot can skip ComputeMidHash256
|
|
// for files whose (size, mtime) match.
|
|
configs := watchersManager.List()
|
|
roots := make([]watcher.WatcherRoot, 0, len(configs))
|
|
for _, c := range configs {
|
|
roots = append(roots, watcher.WatcherRoot{ID: c.ID, Root: c.Path})
|
|
}
|
|
if loaded, skipped, err := s.fileWatcher.HydrateStateFromStorage(roots); err != nil {
|
|
log.Printf("[API] Warning: failed to hydrate watcher state: %v", err)
|
|
} else {
|
|
log.Printf("[API] Hydrated watcher state from Redis: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize mounts manager
|
|
mountsManager, err := mounts.NewManager(cfg)
|
|
if err != nil {
|
|
log.Printf("[API] Warning: failed to initialize mounts manager: %v", err)
|
|
} else {
|
|
s.mountsManager = mountsManager
|
|
s.mountsHandlers = mounts.NewHandlers(mountsManager, scanFunc)
|
|
|
|
// Create and configure mount poller
|
|
s.mountPoller = mounts.NewPoller(mountsManager, scanFunc)
|
|
mountsManager.SetPoller(s.mountPoller)
|
|
|
|
// IO stats poller — queries the local rclone RC API every few seconds
|
|
// and exposes live throughput per mount via the same status JSON.
|
|
// Stats are daemon-global (rclone limitation); see stats_poller.go.
|
|
s.mountStatsPoller = mounts.NewStatsPoller(mountsManager)
|
|
mountsManager.SetStatsPoller(s.mountStatsPoller)
|
|
}
|
|
|
|
// Initialize WebDAV handler (caching is handled by nginx proxy_cache)
|
|
s.webdavHandler = webdav.NewHandler(cfg)
|
|
|
|
s.challenges = identity.NewChallengeStore()
|
|
|
|
// One-time migration: fold a pre-multi-account identity.json into the
|
|
// per-account keystore so existing single-identity hosts keep their uid.
|
|
if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil {
|
|
log.Printf("[API] Warning: identity migration failed: %v", err)
|
|
} else if migrated {
|
|
log.Printf("[API] Migrated legacy identity.json into multi-account keystore")
|
|
}
|
|
|
|
s.setupRoutes()
|
|
return s
|
|
}
|
|
|
|
// setupRoutes configures all API routes
|
|
func (s *Server) setupRoutes() {
|
|
// Health and status
|
|
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
|
s.router.HandleFunc("/status", s.handleStatus).Methods("GET")
|
|
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
|
|
s.router.HandleFunc("/urls", s.handleURLs).Methods("GET")
|
|
|
|
// Dashboard headline counters in one request. Cheap half live, keyspace
|
|
// walks served from a background-refreshed cache — see stats.go.
|
|
s.router.HandleFunc("/api/stats", s.handleStats).Methods("GET")
|
|
|
|
// Metadata Editor API routes (must be before /meta/{hash} routes)
|
|
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
|
|
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
|
|
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
|
|
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
|
|
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
|
|
|
|
// User Data Layer signing identity (see internal/identity). Auth-gated
|
|
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
|
|
s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET")
|
|
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
|
|
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
|
|
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
|
|
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST")
|
|
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
|
|
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
|
|
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
|
|
s.router.HandleFunc("/api/identity/sign-batch", s.handleIdentitySignBatch).Methods("POST")
|
|
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
|
|
|
|
// Snapshot (export / import / wipe)
|
|
s.router.HandleFunc("/api/snapshot/export", s.handleSnapshotExport).Methods("GET")
|
|
s.router.HandleFunc("/api/snapshot/import", s.handleSnapshotImport).Methods("POST")
|
|
s.router.HandleFunc("/api/snapshot/wipe", s.handleSnapshotWipe).Methods("POST")
|
|
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
|
|
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataUpdateProperty).Methods("PUT")
|
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
|
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT")
|
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE")
|
|
|
|
// Schema inference
|
|
s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET")
|
|
s.router.HandleFunc("/api/schema/rescan", s.handleSchemaRescan).Methods("POST")
|
|
|
|
// KV Browser API routes
|
|
s.router.HandleFunc("/api/kv/info", s.handleKVInfo).Methods("GET")
|
|
s.router.HandleFunc("/api/kv/keys", s.handleKVKeys).Methods("GET")
|
|
s.router.HandleFunc("/api/kv/tree", s.handleKVTree).Methods("GET")
|
|
s.router.HandleFunc("/api/kv/search", s.handleKVSearch).Methods("GET")
|
|
s.router.HandleFunc("/api/kv/find", s.handleKVFind).Methods("GET")
|
|
s.router.HandleFunc("/api/kv/value", s.handleKVValueGet).Methods("GET")
|
|
s.router.HandleFunc("/api/kv/value", s.handleKVValuePut).Methods("PUT")
|
|
s.router.HandleFunc("/api/kv/value", s.handleKVValueDelete).Methods("DELETE")
|
|
s.router.HandleFunc("/api/kv/key/{key:.*}", s.handleKVGetKey).Methods("GET")
|
|
|
|
// User Data Layer (UDL) routes — per-user signed records (My List,
|
|
// Continue Watching, ratings, profile). See internal/api/udl.go.
|
|
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordGet).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/record", s.handleUDLRecordPut).Methods("PUT")
|
|
s.router.HandleFunc("/api/udl/records", s.handleUDLRecordsPut).Methods("PUT")
|
|
s.router.HandleFunc("/api/udl/user/{uid}/key/{key}", s.handleUDLUserKey).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/user/{uid}/all", s.handleUDLUserAll).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/user/{uid}/count", s.handleUDLUserCount).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET")
|
|
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
|
|
|
|
// Metadata operations - base endpoints
|
|
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
|
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
|
s.router.HandleFunc("/meta/{hash}", s.handlePatchMeta).Methods("PATCH")
|
|
s.router.HandleFunc("/meta/{hash}", s.handleDeleteMeta).Methods("DELETE")
|
|
s.router.HandleFunc("/meta", s.handleListMeta).Methods("GET")
|
|
|
|
// Metadata operations - set operations (must be before property routes)
|
|
s.router.HandleFunc("/meta/{hash}/_add/{key:.*}", s.handleAddToSet).Methods("POST")
|
|
|
|
// Metadata operations - property-level (key can contain slashes)
|
|
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleGetProperty).Methods("GET")
|
|
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handlePutProperty).Methods("PUT")
|
|
s.router.HandleFunc("/meta/{hash}/{key:.*}", s.handleDeleteProperty).Methods("DELETE")
|
|
|
|
// Data operations
|
|
s.router.HandleFunc("/data/{hash}/path", s.handleGetDataPath).Methods("GET")
|
|
s.router.HandleFunc("/data/{hash}", s.handleHeadData).Methods("HEAD")
|
|
|
|
// File operations (by CID)
|
|
s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET")
|
|
s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET")
|
|
s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST")
|
|
// Register one file under /files/plugin by midhash — the watcher's
|
|
// mint + alias + file:events path for a single file. Registered
|
|
// unconditionally: it exists for boxes whose watcher is OFF.
|
|
s.router.HandleFunc("/api/files/register", s.handleRegisterFile).Methods("POST")
|
|
// File-backed roots (filePath+sizeByte+mtimeNano) with sizes, batched
|
|
// server-side, so clients answer "how many files, how big" without one
|
|
// read per hashId. ?summary=1 returns only {count, totalSize}.
|
|
s.router.HandleFunc("/api/files/tuples", s.handleGetFileTuples).Methods("GET")
|
|
|
|
// Public metadata-by-CID resolver. Reverse-index lookup → full document.
|
|
// Auth-bypassed alongside /api/file/{cid} so meta-share peers can query
|
|
// without going through Authelia.
|
|
s.router.HandleFunc("/api/meta/{cid}", s.handleGetMetaByCID).Methods("GET")
|
|
|
|
// Admin: reunify stranded midhash-rooted entries into their UUID roots
|
|
// (the dual-root pattern caused by historical writes that bypassed CID
|
|
// resolution).
|
|
s.router.HandleFunc("/api/admin/migrate-dual-roots", s.handleMigrateDualRoots).Methods("POST")
|
|
// One-shot vocabulary sweep: domain film|tv → screen, workForm backfill
|
|
// (METADATA_KEYS.md §14.17). Idempotent.
|
|
s.router.HandleFunc("/api/admin/migrate-domain-screen", s.handleMigrateDomainScreen).Methods("POST")
|
|
s.router.HandleFunc("/api/admin/migrate-user-cids", s.handleMigrateUserCids).Methods("POST")
|
|
// One-shot sweep of query echoes on literature cards (meta-read
|
|
// reading-model.md §7). Dry-run unless ?apply=true. Idempotent.
|
|
s.router.HandleFunc("/api/admin/sweep-literature-echoes", s.handleSweepLiteratureEchoes).Methods("POST")
|
|
|
|
// SSE event streams. Mediated mirror of the Redis Streams; consumers
|
|
// reach them over HTTP instead of touching Redis directly. Auth-bypass
|
|
// applies but exposure should be inside-only (do NOT proxy through
|
|
// Caddy publicly). See docs/api-mediated-access.md "Auth".
|
|
s.router.HandleFunc("/api/events/files", s.handleEventsFiles).Methods("GET")
|
|
s.router.HandleFunc("/api/events/meta", s.handleEventsMeta).Methods("GET")
|
|
|
|
// beacon v2: neighbours heard over UDP. Supersedes /api/services,
|
|
// which is still served above while consumers migrate.
|
|
s.router.HandleFunc("/neighbors", s.handleListNeighbors).Methods("GET")
|
|
s.router.HandleFunc("/api/neighbors", s.handleListNeighbors).Methods("GET")
|
|
|
|
// Mount management routes (if manager initialized)
|
|
if s.mountsHandlers != nil {
|
|
s.mountsHandlers.RegisterRoutes(s.router)
|
|
}
|
|
|
|
// File watcher routes (if watcher initialized)
|
|
if s.watcherHandlers != nil {
|
|
s.watcherHandlers.RegisterRoutes(s.router)
|
|
}
|
|
|
|
// Watchers management routes (if watchers initialized)
|
|
if s.watchersHandlers != nil {
|
|
s.watchersHandlers.RegisterRoutes(s.router)
|
|
}
|
|
|
|
// WebDAV handler (caching is handled by nginx proxy_cache)
|
|
if s.webdavHandler != nil {
|
|
s.router.PathPrefix("/webdav/").Handler(s.webdavHandler)
|
|
s.router.PathPrefix("/webdav").Handler(s.webdavHandler)
|
|
}
|
|
|
|
// Add middleware
|
|
s.router.Use(loggingMiddleware)
|
|
s.router.Use(corsMiddleware)
|
|
}
|
|
|
|
// Start starts the HTTP server
|
|
func (s *Server) Start() error {
|
|
addr := fmt.Sprintf("%s:%d", s.config.HTTPHost, s.config.HTTPPort)
|
|
|
|
s.server = &http.Server{
|
|
Addr: addr,
|
|
Handler: s.router,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
log.Printf("[API] Starting HTTP server on %s", addr)
|
|
|
|
go func() {
|
|
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("[API] HTTP server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Start watchers poller (if initialized)
|
|
if s.watchersPoller != nil {
|
|
if err := s.watchersPoller.Start(); err != nil {
|
|
log.Printf("[API] Warning: failed to start watchers poller: %v", err)
|
|
}
|
|
}
|
|
|
|
// Start mount poller (if initialized)
|
|
if s.mountPoller != nil {
|
|
if err := s.mountPoller.Start(); err != nil {
|
|
log.Printf("[API] Warning: failed to start mount poller: %v", err)
|
|
}
|
|
}
|
|
|
|
// Start IO stats poller alongside the mount poller. Cheap (one /proc read
|
|
// per CIFS/NFS mount per cycle, plus one RC call shared by all rclone
|
|
// mounts), so always-on rather than gated by config.
|
|
if s.mountStatsPoller != nil {
|
|
s.mountStatsPoller.Start()
|
|
}
|
|
|
|
// Build + periodically refresh the in-memory search index so
|
|
// POST /api/metadata/search matches in memory instead of a Redis
|
|
// round-trip per record (see search_index.go).
|
|
if s.storage != nil {
|
|
go s.search.runRefreshLoop(s.storage)
|
|
}
|
|
|
|
// Start meta publisher (if storage is connected)
|
|
// Publishes keyspace notifications to meta:events stream
|
|
if s.storage != nil && s.storage.IsConnected() {
|
|
s.metaPublisher = events.NewMetaPublisher(s.storage.GetRedisClient())
|
|
if err := s.metaPublisher.Start(); err != nil {
|
|
log.Printf("[API] Warning: failed to start meta publisher: %v", err)
|
|
} else {
|
|
// Schema indexer consumes the same stream to derive a live
|
|
// per-field schema (type/hint/breakdown). Start it before the
|
|
// republish flood so it captures the bootstrap events.
|
|
s.schemaIndexer = schema.NewIndexer(s.storage.GetRedisClient(), s.storage.GetPrefix())
|
|
if err := s.schemaIndexer.Start(); err != nil {
|
|
log.Printf("[API] Warning: failed to start schema indexer: %v", err)
|
|
s.schemaIndexer = nil
|
|
}
|
|
|
|
// Republish events for all existing metadata
|
|
go func() {
|
|
// Build any missing per-record field indexes first, in ONE
|
|
// keyspace pass. The republish below reads every record, and
|
|
// an un-indexed record read costs a full-keyspace SCAN — on a
|
|
// real store that walk warmed at ~1.3 records/s (a ~2.6h tail,
|
|
// paid again on every restart). Warming first turns the whole
|
|
// republish into index-served reads. Idempotent, so a
|
|
// fully-indexed store just pays the single scan.
|
|
// See storage/field_index.go.
|
|
started := time.Now()
|
|
if n, err := s.storage.WarmFieldIndexes(); err != nil {
|
|
log.Printf("[API] Warning: field index warm failed: %v (reads fall back to SCAN)", err)
|
|
} else if n > 0 {
|
|
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond))
|
|
}
|
|
|
|
count, err := s.RepublishMetadata()
|
|
if err != nil {
|
|
log.Printf("[API] Warning: failed to republish metadata on startup: %v", err)
|
|
} else {
|
|
log.Printf("[API] Republished %d metadata events on startup", count)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop gracefully stops the HTTP server
|
|
func (s *Server) Stop() error {
|
|
// Stop watchers poller
|
|
if s.watchersPoller != nil {
|
|
if err := s.watchersPoller.Stop(); err != nil {
|
|
log.Printf("[API] Warning: failed to stop watchers poller: %v", err)
|
|
}
|
|
}
|
|
|
|
// Stop mount poller
|
|
if s.mountPoller != nil {
|
|
if err := s.mountPoller.Stop(); err != nil {
|
|
log.Printf("[API] Warning: failed to stop mount poller: %v", err)
|
|
}
|
|
}
|
|
|
|
// Stop IO stats poller
|
|
if s.mountStatsPoller != nil {
|
|
s.mountStatsPoller.Stop()
|
|
}
|
|
|
|
// Stop schema indexer before the publisher so the consumer drains cleanly
|
|
if s.schemaIndexer != nil {
|
|
if err := s.schemaIndexer.Stop(); err != nil {
|
|
log.Printf("[API] Warning: failed to stop schema indexer: %v", err)
|
|
}
|
|
}
|
|
|
|
// Stop meta publisher
|
|
if s.metaPublisher != nil {
|
|
if err := s.metaPublisher.Stop(); err != nil {
|
|
log.Printf("[API] Warning: failed to stop meta publisher: %v", err)
|
|
}
|
|
}
|
|
|
|
if s.server == nil {
|
|
return nil
|
|
}
|
|
|
|
log.Println("[API] Stopping HTTP server...")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
return s.server.Shutdown(ctx)
|
|
}
|
|
|
|
// RepublishMetadata republishes all existing metadata to the meta:events stream
|
|
// This is called on startup and after reset-all
|
|
func (s *Server) RepublishMetadata() (int, error) {
|
|
if s.metaPublisher == nil {
|
|
return 0, fmt.Errorf("meta publisher not initialized")
|
|
}
|
|
if s.storage == nil || !s.storage.IsConnected() {
|
|
return 0, fmt.Errorf("storage not connected")
|
|
}
|
|
|
|
// Create the republish function that wraps storage methods
|
|
republishFunc := func() ([]string, func(string) (map[string]string, error), error) {
|
|
hashIDs, err := s.storage.GetAllHashIDs()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return hashIDs, s.storage.GetMetadataFlat, nil
|
|
}
|
|
|
|
return s.metaPublisher.RepublishAllMetadata(republishFunc)
|
|
}
|
|
|
|
// statusRecorder captures the response status so loggingMiddleware can stay
|
|
// silent on success and only report failures. It forwards Flush/Unwrap so SSE
|
|
// and other streaming handlers keep working through the wrapper.
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
written bool
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
|
if !r.written {
|
|
r.status = code
|
|
r.written = true
|
|
}
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (r *statusRecorder) Write(b []byte) (int, error) {
|
|
if !r.written {
|
|
r.status = http.StatusOK
|
|
r.written = true
|
|
}
|
|
return r.ResponseWriter.Write(b)
|
|
}
|
|
|
|
func (r *statusRecorder) Flush() {
|
|
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
|
|
|
// loggingMiddleware logs a request only when it fails (status >= 400), keeping
|
|
// the nominal case quiet. Successful requests are not logged.
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rec, r)
|
|
if rec.status >= 400 {
|
|
log.Printf("[API] %s %s -> %d (%s)", r.Method, r.URL.Path, rec.status, time.Since(start))
|
|
}
|
|
})
|
|
}
|
|
|
|
// corsMiddleware adds CORS headers
|
|
func corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|