mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
Introduce StatsService integration to admin and master handlers, exposing realtime metrics (requests, tokens, QPS, rate limit status) via new endpoints: - GET /admin/masters/:id/realtime - GET /v1/realtime Also embed realtime stats in the existing GET /admin/masters/:id response and change GlobalQPS default to 0 with validation to reject negative values.
442 lines
15 KiB
Go
442 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ez-api/ez-api/internal/model"
|
|
"github.com/ez-api/ez-api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type MasterHandler struct {
|
|
db *gorm.DB
|
|
logDB *gorm.DB
|
|
masterService *service.MasterService
|
|
syncService *service.SyncService
|
|
statsService *service.StatsService
|
|
logPartitioner *service.LogPartitioner
|
|
}
|
|
|
|
func NewMasterHandler(db *gorm.DB, logDB *gorm.DB, masterService *service.MasterService, syncService *service.SyncService, statsService *service.StatsService, partitioner *service.LogPartitioner) *MasterHandler {
|
|
if logDB == nil {
|
|
logDB = db
|
|
}
|
|
return &MasterHandler{db: db, logDB: logDB, masterService: masterService, syncService: syncService, statsService: statsService, logPartitioner: partitioner}
|
|
}
|
|
|
|
func (h *MasterHandler) logDBConn() *gorm.DB {
|
|
if h == nil || h.logDB == nil {
|
|
return h.db
|
|
}
|
|
return h.logDB
|
|
}
|
|
|
|
func (h *MasterHandler) logBaseQuery() *gorm.DB {
|
|
return logBaseQuery(h.logDBConn(), h.logPartitioner)
|
|
}
|
|
|
|
type IssueChildKeyRequest struct {
|
|
Group string `json:"group"`
|
|
Scopes string `json:"scopes"`
|
|
ModelLimits string `json:"model_limits,omitempty"`
|
|
ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
AllowIPs string `json:"allow_ips,omitempty"`
|
|
DenyIPs string `json:"deny_ips,omitempty"`
|
|
}
|
|
|
|
// IssueChildKey godoc
|
|
// @Summary Issue a child key
|
|
// @Description Issue a new access token (child key) for the authenticated master
|
|
// @Tags master
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security MasterAuth
|
|
// @Param request body IssueChildKeyRequest true "Key Request"
|
|
// @Success 201 {object} gin.H
|
|
// @Failure 400 {object} gin.H
|
|
// @Failure 401 {object} gin.H
|
|
// @Failure 403 {object} gin.H
|
|
// @Failure 500 {object} gin.H
|
|
// @Router /v1/tokens [post]
|
|
func (h *MasterHandler) IssueChildKey(c *gin.Context) {
|
|
master, exists := c.Get("master")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
|
return
|
|
}
|
|
masterModel := master.(*model.Master)
|
|
|
|
var req IssueChildKeyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// If group is not specified, inherit from master
|
|
group := req.Group
|
|
if strings.TrimSpace(group) == "" {
|
|
group = masterModel.Group
|
|
}
|
|
|
|
// Security: Ensure the requested group is allowed for this master.
|
|
// For now, we'll just enforce it's the same group.
|
|
if group != masterModel.Group {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot issue key for a different group"})
|
|
return
|
|
}
|
|
|
|
modelLimits := strings.TrimSpace(req.ModelLimits)
|
|
modelLimitsEnabled := false
|
|
if req.ModelLimitsEnabled != nil {
|
|
modelLimitsEnabled = *req.ModelLimitsEnabled
|
|
} else if modelLimits != "" {
|
|
modelLimitsEnabled = true
|
|
}
|
|
|
|
key, rawChildKey, err := h.masterService.IssueChildKey(masterModel.ID, service.IssueKeyOptions{
|
|
Group: group,
|
|
Scopes: strings.TrimSpace(req.Scopes),
|
|
ModelLimits: modelLimits,
|
|
ModelLimitsEnabled: modelLimitsEnabled,
|
|
ExpiresAt: req.ExpiresAt,
|
|
AllowIPs: strings.TrimSpace(req.AllowIPs),
|
|
DenyIPs: strings.TrimSpace(req.DenyIPs),
|
|
})
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, service.ErrMasterNotFound):
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
case errors.Is(err, service.ErrMasterNotActive):
|
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
case errors.Is(err, service.ErrModelLimitForbidden):
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
case errors.Is(err, service.ErrChildKeyGroupForbidden):
|
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
case errors.Is(err, service.ErrChildKeyLimitReached):
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
default:
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue child key", "details": err.Error()})
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := h.syncService.SyncKey(key); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync child key", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": key.ID,
|
|
"key_secret": rawChildKey,
|
|
"group": key.Group,
|
|
"scopes": key.Scopes,
|
|
})
|
|
}
|
|
|
|
// GetSelf godoc
|
|
// @Summary Get current master info
|
|
// @Description Returns master metadata for the authenticated master key
|
|
// @Tags master
|
|
// @Produce json
|
|
// @Security MasterAuth
|
|
// @Success 200 {object} MasterView
|
|
// @Failure 401 {object} gin.H
|
|
// @Router /v1/self [get]
|
|
func (h *MasterHandler) GetSelf(c *gin.Context) {
|
|
master, exists := c.Get("master")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
|
return
|
|
}
|
|
m := master.(*model.Master)
|
|
c.JSON(http.StatusOK, toMasterView(*m))
|
|
}
|
|
|
|
type TokenView struct {
|
|
ID uint `json:"id"`
|
|
Group string `json:"group"`
|
|
Scopes string `json:"scopes"`
|
|
Status string `json:"status"`
|
|
IssuedBy string `json:"issued_by"`
|
|
IssuedAtEpoch int64 `json:"issued_at_epoch"`
|
|
DefaultNamespace string `json:"default_namespace"`
|
|
Namespaces string `json:"namespaces"`
|
|
ModelLimits string `json:"model_limits,omitempty"`
|
|
ModelLimitsEnabled bool `json:"model_limits_enabled"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
AllowIPs string `json:"allow_ips,omitempty"`
|
|
DenyIPs string `json:"deny_ips,omitempty"`
|
|
LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"`
|
|
RequestCount int64 `json:"request_count"`
|
|
UsedTokens int64 `json:"used_tokens"`
|
|
QuotaLimit int64 `json:"quota_limit"`
|
|
QuotaUsed int64 `json:"quota_used"`
|
|
QuotaResetAt *time.Time `json:"quota_reset_at,omitempty"`
|
|
QuotaResetType string `json:"quota_reset_type,omitempty"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
|
|
func toTokenView(k model.Key) TokenView {
|
|
return TokenView{
|
|
ID: k.ID,
|
|
Group: k.Group,
|
|
Scopes: k.Scopes,
|
|
Status: k.Status,
|
|
IssuedBy: k.IssuedBy,
|
|
IssuedAtEpoch: k.IssuedAtEpoch,
|
|
DefaultNamespace: strings.TrimSpace(k.DefaultNamespace),
|
|
Namespaces: strings.TrimSpace(k.Namespaces),
|
|
ModelLimits: strings.TrimSpace(k.ModelLimits),
|
|
ModelLimitsEnabled: k.ModelLimitsEnabled,
|
|
ExpiresAt: k.ExpiresAt,
|
|
AllowIPs: strings.TrimSpace(k.AllowIPs),
|
|
DenyIPs: strings.TrimSpace(k.DenyIPs),
|
|
LastAccessedAt: k.LastAccessedAt,
|
|
RequestCount: k.RequestCount,
|
|
UsedTokens: k.UsedTokens,
|
|
QuotaLimit: k.QuotaLimit,
|
|
QuotaUsed: k.QuotaUsed,
|
|
QuotaResetAt: k.QuotaResetAt,
|
|
QuotaResetType: strings.TrimSpace(k.QuotaResetType),
|
|
CreatedAt: k.CreatedAt.UTC().Unix(),
|
|
UpdatedAt: k.UpdatedAt.UTC().Unix(),
|
|
}
|
|
}
|
|
|
|
// ListTokens godoc
|
|
// @Summary List child keys
|
|
// @Description List child keys issued under the authenticated master
|
|
// @Tags master
|
|
// @Produce json
|
|
// @Security MasterAuth
|
|
// @Param page query int false "page (1-based)"
|
|
// @Param limit query int false "limit (default 50, max 200)"
|
|
// @Param search query string false "search by group/scopes/namespaces/status"
|
|
// @Success 200 {array} TokenView
|
|
// @Failure 401 {object} gin.H
|
|
// @Failure 500 {object} gin.H
|
|
// @Router /v1/tokens [get]
|
|
func (h *MasterHandler) ListTokens(c *gin.Context) {
|
|
master, exists := c.Get("master")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
|
return
|
|
}
|
|
m := master.(*model.Master)
|
|
|
|
var keys []model.Key
|
|
q := h.db.Model(&model.Key{}).Where("master_id = ?", m.ID).Order("id desc")
|
|
query := parseListQuery(c)
|
|
q = applyListSearch(q, query.Search, `"group"`, "scopes", "default_namespace", "namespaces", "status")
|
|
q = applyListPagination(q, query)
|
|
if err := q.Find(&keys).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens", "details": err.Error()})
|
|
return
|
|
}
|
|
out := make([]TokenView, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, toTokenView(k))
|
|
}
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// GetToken godoc
|
|
// @Summary Get child key
|
|
// @Description Get a child key by id under the authenticated master
|
|
// @Tags master
|
|
// @Produce json
|
|
// @Security MasterAuth
|
|
// @Param id path int true "Token ID"
|
|
// @Success 200 {object} TokenView
|
|
// @Failure 400 {object} gin.H
|
|
// @Failure 401 {object} gin.H
|
|
// @Failure 404 {object} gin.H
|
|
// @Failure 500 {object} gin.H
|
|
// @Router /v1/tokens/{id} [get]
|
|
func (h *MasterHandler) GetToken(c *gin.Context) {
|
|
master, exists := c.Get("master")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
|
return
|
|
}
|
|
m := master.(*model.Master)
|
|
|
|
idRaw := strings.TrimSpace(c.Param("id"))
|
|
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
|
if err != nil || idU64 == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"})
|
|
return
|
|
}
|
|
|
|
var k model.Key
|
|
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, toTokenView(k))
|
|
}
|
|
|
|
type UpdateTokenRequest struct {
|
|
Scopes *string `json:"scopes,omitempty"`
|
|
Status *string `json:"status,omitempty"` // active/suspended
|
|
ModelLimits *string `json:"model_limits,omitempty"`
|
|
ModelLimitsEnabled *bool `json:"model_limits_enabled,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
AllowIPs *string `json:"allow_ips,omitempty"`
|
|
DenyIPs *string `json:"deny_ips,omitempty"`
|
|
}
|
|
|
|
// UpdateToken godoc
|
|
// @Summary Update child key
|
|
// @Description Update token scopes/status under the authenticated master
|
|
// @Tags master
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security MasterAuth
|
|
// @Param id path int true "Token ID"
|
|
// @Param request body UpdateTokenRequest true "Update payload"
|
|
// @Success 200 {object} TokenView
|
|
// @Failure 400 {object} gin.H
|
|
// @Failure 401 {object} gin.H
|
|
// @Failure 404 {object} gin.H
|
|
// @Failure 500 {object} gin.H
|
|
// @Router /v1/tokens/{id} [put]
|
|
func (h *MasterHandler) UpdateToken(c *gin.Context) {
|
|
master, exists := c.Get("master")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
|
return
|
|
}
|
|
m := master.(*model.Master)
|
|
|
|
idRaw := strings.TrimSpace(c.Param("id"))
|
|
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
|
if err != nil || idU64 == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"})
|
|
return
|
|
}
|
|
|
|
var req UpdateTokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
update := make(map[string]any)
|
|
if req.Scopes != nil {
|
|
update["scopes"] = strings.TrimSpace(*req.Scopes)
|
|
}
|
|
if req.ModelLimits != nil {
|
|
modelLimits := strings.TrimSpace(*req.ModelLimits)
|
|
if modelLimits != "" {
|
|
if err := h.masterService.ValidateModelLimits(m, modelLimits); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
}
|
|
update["model_limits"] = modelLimits
|
|
if req.ModelLimitsEnabled == nil {
|
|
update["model_limits_enabled"] = modelLimits != ""
|
|
}
|
|
}
|
|
if req.ModelLimitsEnabled != nil {
|
|
update["model_limits_enabled"] = *req.ModelLimitsEnabled
|
|
}
|
|
if req.ExpiresAt != nil {
|
|
update["expires_at"] = req.ExpiresAt
|
|
}
|
|
if req.AllowIPs != nil {
|
|
update["allow_ips"] = strings.TrimSpace(*req.AllowIPs)
|
|
}
|
|
if req.DenyIPs != nil {
|
|
update["deny_ips"] = strings.TrimSpace(*req.DenyIPs)
|
|
}
|
|
if req.Status != nil {
|
|
st := strings.ToLower(strings.TrimSpace(*req.Status))
|
|
if st != "active" && st != "suspended" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
|
|
return
|
|
}
|
|
update["status"] = st
|
|
}
|
|
if len(update) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
|
return
|
|
}
|
|
|
|
var k model.Key
|
|
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
|
return
|
|
}
|
|
if err := h.db.Model(&k).Updates(update).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update token", "details": err.Error()})
|
|
return
|
|
}
|
|
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()})
|
|
return
|
|
}
|
|
if err := h.syncService.SyncKey(&k); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, toTokenView(k))
|
|
}
|
|
|
|
// DeleteToken godoc
|
|
// @Summary Delete (revoke) child key
|
|
// @Description Suspends a child key under the authenticated master
|
|
// @Tags master
|
|
// @Produce json
|
|
// @Security MasterAuth
|
|
// @Param id path int true "Token ID"
|
|
// @Success 200 {object} gin.H
|
|
// @Failure 400 {object} gin.H
|
|
// @Failure 401 {object} gin.H
|
|
// @Failure 404 {object} gin.H
|
|
// @Failure 500 {object} gin.H
|
|
// @Router /v1/tokens/{id} [delete]
|
|
func (h *MasterHandler) DeleteToken(c *gin.Context) {
|
|
master, exists := c.Get("master")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "master key not found in context"})
|
|
return
|
|
}
|
|
m := master.(*model.Master)
|
|
|
|
idRaw := strings.TrimSpace(c.Param("id"))
|
|
idU64, err := strconv.ParseUint(idRaw, 10, 64)
|
|
if err != nil || idU64 == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token id"})
|
|
return
|
|
}
|
|
|
|
var k model.Key
|
|
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
|
return
|
|
}
|
|
|
|
if err := h.db.Model(&k).Update("status", "suspended").Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token", "details": err.Error()})
|
|
return
|
|
}
|
|
if err := h.db.Where("master_id = ? AND id = ?", m.ID, uint(idU64)).First(&k).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload token", "details": err.Error()})
|
|
return
|
|
}
|
|
if err := h.syncService.SyncKey(&k); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sync token", "details": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
|
|
}
|