// app/admin/admin.go // Admin API handlers for shared-sqs management // Created: 2026-04-09 // Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений package admin import ( "encoding/json" "net/http" "strings" "time" "shared-sqs/app/models" "shared-sqs/app/tenant" "github.com/google/uuid" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" ) // ─── вспомогательная функция: найти очередь тенанта по имени ─────────────── // findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено. func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) { key := tenantAccessKey + ":" + queueName models.SyncQueues.RLock() q, ok := models.SyncQueues.Queues[key] models.SyncQueues.RUnlock() if !ok { return "", nil } return key, q } // Handler — admin API handler, holds TenantStore и admin token type Handler struct { store *tenant.TenantStore adminToken string } // NewHandler — создаёт admin handler func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { return &Handler{store: store, adminToken: adminToken} } // RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth) func (h *Handler) RegisterRoutes(r *mux.Router) { adminRouter := r.PathPrefix("/admin").Subrouter() adminRouter.Use(h.bearerAuthMiddleware) adminRouter.HandleFunc("/tenants", h.createTenant).Methods("POST") adminRouter.HandleFunc("/tenants", h.listTenants).Methods("GET") adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") adminRouter.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST") adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE") adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET") } // RegisterPublicRoutes — публичные маршруты для UI console (без auth) // Дублируют admin API, но доступны без bearer token для удобства демо // TODO: убрать или заменить на session-auth перед production func (h *Handler) RegisterPublicRoutes(r *mux.Router) { ui := r.PathPrefix("/ui/api").Subrouter() ui.HandleFunc("/health", h.detailedHealth).Methods("GET") ui.HandleFunc("/tenants", h.listTenants).Methods("GET") ui.HandleFunc("/tenants", h.createTenant).Methods("POST") ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET") ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE") ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET") ui.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST") ui.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE") ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET") ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST") ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE") } // bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12) func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") expected := "Bearer " + h.adminToken if authHeader != expected { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) return } next.ServeHTTP(w, r) }) } // createTenantRequest — тело запроса POST /admin/tenants type createTenantRequest struct { Name string `json:"name"` MaxQueues int `json:"max_queues"` } // tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании) type tenantCreateResponse struct { ID string `json:"id"` Name string `json:"name"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` MaxQueues int `json:"max_queues"` CreatedAt time.Time `json:"created_at"` Active bool `json:"active"` } // tenantListItem — данные тенанта без secret_key (для List/Get) type tenantListItem struct { ID string `json:"id"` Name string `json:"name"` AccessKey string `json:"access_key"` MaxQueues int `json:"max_queues"` CreatedAt time.Time `json:"created_at"` Active bool `json:"active"` } // createTenant — POST /admin/tenants func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) { var req createTenantRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) return } if req.Name == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "name is required"}) return } t, err := h.store.Create(req.Name, req.MaxQueues) if err != nil { log.Errorf("admin: failed to create tenant: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "failed to create tenant"}) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(tenantCreateResponse{ ID: t.ID, Name: t.Name, AccessKey: t.AccessKey, SecretKey: t.SecretKey, MaxQueues: t.MaxQueues, CreatedAt: t.CreatedAt, Active: t.Active, }) } // listTenants — GET /admin/tenants func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) { tenants := h.store.List() items := make([]tenantListItem, 0, len(tenants)) for _, t := range tenants { items = append(items, tenantListItem{ ID: t.ID, Name: t.Name, AccessKey: t.AccessKey, MaxQueues: t.MaxQueues, CreatedAt: t.CreatedAt, Active: t.Active, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(items) } // getTenant — GET /admin/tenants/{id} func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] t, ok := h.store.GetByID(id) if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tenantListItem{ ID: t.ID, Name: t.Name, AccessKey: t.AccessKey, MaxQueues: t.MaxQueues, CreatedAt: t.CreatedAt, Active: t.Active, }) } // deleteTenant — DELETE /admin/tenants/{id} // Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak) func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] t, ok := h.store.GetByID(id) if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) return } // Удаляем все очереди тенанта из SyncQueues prefix := t.AccessKey + ":" models.SyncQueues.Lock() for key := range models.SyncQueues.Queues { if strings.HasPrefix(key, prefix) { delete(models.SyncQueues.Queues, key) } } models.SyncQueues.Unlock() h.store.Delete(id) w.WriteHeader(http.StatusNoContent) } // listTenantQueues — GET /admin/tenants/{id}/queues // Возвращает список очередей тенанта с количеством сообщений. func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] t, ok := h.store.GetByID(id) if !ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"}) return } prefix := t.AccessKey + ":" type queueInfo struct { Name string `json:"name"` Messages int `json:"messages"` NotVisible int `json:"not_visible"` VisibilityTimeout int `json:"visibility_timeout"` MaxMessageSize int `json:"max_message_size"` RetentionPeriod int `json:"retention_period"` } queues := make([]queueInfo, 0) models.SyncQueues.RLock() for key, q := range models.SyncQueues.Queues { if strings.HasPrefix(key, prefix) { name := strings.TrimPrefix(key, prefix) visible := 0 notVisible := 0 for _, msg := range q.Messages { if msg.ReceiptHandle != "" { notVisible++ } else { visible++ } } queues = append(queues, queueInfo{ Name: name, Messages: visible, NotVisible: notVisible, VisibilityTimeout: q.VisibilityTimeout, MaxMessageSize: q.MaximumMessageSize, RetentionPeriod: q.MessageRetentionPeriod, }) } } models.SyncQueues.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(queues) } // ─── QUEUE MANAGEMENT HANDLERS ──────────────────────────────────────────── // createQueueRequest — тело запроса POST .../queues type createQueueRequest struct { Name string `json:"name"` } // createTenantQueue — POST /admin/tenants/{id}/queues // Создаёт новую очередь для тенанта прямо в SyncQueues (без SQS-протокола). // Проверяет лимит MaxQueues тенанта и уникальность имени. func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid := vars["id"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } var req createQueueRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { jsonErr(w, http.StatusBadRequest, "name is required") return } key := t.AccessKey + ":" + req.Name models.SyncQueues.Lock() if _, exists := models.SyncQueues.Queues[key]; exists { models.SyncQueues.Unlock() jsonErr(w, http.StatusConflict, "queue already exists") return } // Проверяем лимит очередей тенанта count := 0 for k := range models.SyncQueues.Queues { if strings.HasPrefix(k, t.AccessKey+":") { count++ } } if t.MaxQueues > 0 && count >= t.MaxQueues { models.SyncQueues.Unlock() jsonErr(w, http.StatusForbidden, "queue limit exceeded") return } models.SyncQueues.Queues[key] = &models.Queue{ Name: req.Name, VisibilityTimeout: 30, MaximumMessageSize: 262144, MessageRetentionPeriod: 345600, Messages: []models.SqsMessage{}, Duplicates: make(map[string]time.Time), } models.SyncQueues.Unlock() log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"name": req.Name, "status": "created"}) } // deleteTenantQueue — DELETE /admin/tenants/{id}/queues/{queue} // Удаляет очередь тенанта из SyncQueues вместе со всеми её сообщениями. func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } key := t.AccessKey + ":" + queueName models.SyncQueues.Lock() if _, exists := models.SyncQueues.Queues[key]; !exists { models.SyncQueues.Unlock() jsonErr(w, http.StatusNotFound, "queue not found") return } delete(models.SyncQueues.Queues, key) models.SyncQueues.Unlock() log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID) w.WriteHeader(http.StatusNoContent) } // peekMessageItem — одно сообщение в ответе peekQueueMessages (без receipt handle) type peekMessageItem struct { ID string `json:"id"` Body string `json:"body"` MD5 string `json:"md5"` SentAt string `json:"sent_at"` Receives int `json:"receives"` InFlight bool `json:"in_flight"` } // peekQueueMessages — GET /admin/tenants/{id}/queues/{queue}/messages?limit=50 // Peek-просмотр сообщений: НЕ удаляет, НЕ выставляет ReceiptHandle — только чтение. // Это принципиальное отличие от SQS ReceiveMessage (который скрывает сообщения). func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } _, q := findQueue(t.AccessKey, queueName) if q == nil { jsonErr(w, http.StatusNotFound, "queue not found") return } // Лимит по умолчанию 50, максимум 1000 limit := 50 if lv := r.URL.Query().Get("limit"); lv != "" { if n := 0; len(lv) > 0 { for _, c := range lv { if c < '0' || c > '9' { n = -1 break } n = n*10 + int(c-'0') } if n > 0 && n <= 1000 { limit = n } } } models.SyncQueues.RLock() result := make([]peekMessageItem, 0, len(q.Messages)) for i, msg := range q.Messages { if i >= limit { break } result = append(result, peekMessageItem{ ID: msg.Uuid, Body: msg.MessageBody, MD5: msg.MD5OfMessageBody, SentAt: msg.SentTime.Format(time.RFC3339), Receives: msg.NumberOfReceives, InFlight: msg.ReceiptHandle != "", }) } models.SyncQueues.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } // sendMessageRequest — тело запроса POST .../messages type sendMessageRequest struct { Body string `json:"body"` } // sendMessageToQueue — POST /admin/tenants/{id}/queues/{queue}/messages // Отправляет сообщение напрямую в очередь минуя SQS-протокол. // Используется только из UI console — для prod нужен нормальный SQS send. func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } key, q := findQueue(t.AccessKey, queueName) if q == nil { jsonErr(w, http.StatusNotFound, "queue not found") return } var req sendMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Body == "" { jsonErr(w, http.StatusBadRequest, "body is required") return } msg := models.SqsMessage{ MessageBody: req.Body, Uuid: uuid.NewString(), SentTime: time.Now(), } models.SyncQueues.Lock() models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) models.SyncQueues.Unlock() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"}) } // purgeQueue — DELETE /admin/tenants/{id}/queues/{queue}/messages // Удаляет все сообщения из очереди (purge). Сама очередь остаётся. func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) tid, queueName := vars["id"], vars["queue"] t, ok := h.store.GetByID(tid) if !ok { jsonErr(w, http.StatusNotFound, "tenant not found") return } key, q := findQueue(t.AccessKey, queueName) if q == nil { jsonErr(w, http.StatusNotFound, "queue not found") return } models.SyncQueues.Lock() models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0] models.SyncQueues.Unlock() log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID) w.WriteHeader(http.StatusNoContent) } // jsonErr — вспомогательная функция: ответ с ошибкой в JSON func jsonErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } // adminHealthDetail — ответ GET /admin/health type adminHealthDetail struct { Status string `json:"status"` TenantCount int `json:"tenant_count"` QueueCount int `json:"queue_count"` MessageCount int `json:"message_count"` } // detailedHealth — GET /admin/health func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) { tenants := h.store.List() models.SyncQueues.RLock() queueCount := len(models.SyncQueues.Queues) msgCount := 0 for _, q := range models.SyncQueues.Queues { msgCount += len(q.Messages) } models.SyncQueues.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(adminHealthDetail{ Status: "ok", TenantCount: len(tenants), QueueCount: queueCount, MessageCount: msgCount, }) }