Files
GopherGate/internal/server/clients.go
T
hobokenchicken 79571c6bdc
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
fix: replace sql.NullTime with string scan for MAX() aggregate queries
Go 1.26 changed NullTime.Scan to delegate to convertAssign,
which has no string->time.Time conversion path. The
modernc.org/sqlite driver returns raw strings for aggregate
expressions like MAX(last_used_at), causing silent scan failures
that made all clients/providers show 'Never' for last used.

Fixes by scanning into a string and parsing with time.Parse.
2026-04-30 09:32:11 -04:00

274 lines
7.0 KiB
Go

package server
import (
"net/http"
"time"
"gophergate/internal/db"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func (s *Server) handleGetClients(c *gin.Context) {
var clients []db.Client
err := s.database.Select(&clients, "SELECT * FROM clients ORDER BY created_at DESC")
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
type UIClient struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
LastUsed *time.Time `json:"last_used"`
RequestsCount int `json:"requests_count"`
TokensCount int `json:"tokens_count"`
Status string `json:"status"`
RateLimitPerMinute int `json:"rate_limit_per_minute"`
}
uiClients := make([]UIClient, len(clients))
for i, cl := range clients {
status := "active"
if !cl.IsActive {
status = "disabled"
}
name := ""
if cl.Name != nil {
name = *cl.Name
}
desc := ""
if cl.Description != nil {
desc = *cl.Description
}
var lastUsedStr string
_ = s.database.Get(&lastUsedStr, "SELECT MAX(last_used_at) FROM client_tokens WHERE client_id = ?", cl.ClientID)
var lastUsed *time.Time
if lastUsedStr != "" {
if t, err := time.Parse("2006-01-02 15:04:05", lastUsedStr); err == nil {
lastUsed = &t
}
}
uiClients[i] = UIClient{
ID: cl.ClientID,
Name: name,
Description: desc,
CreatedAt: cl.CreatedAt,
LastUsed: lastUsed,
RequestsCount: cl.TotalRequests,
TokensCount: cl.TotalTokens,
Status: status,
RateLimitPerMinute: cl.RateLimitPerMinute,
}
}
c.JSON(http.StatusOK, SuccessResponse(uiClients))
}
func (s *Server) handleGetClient(c *gin.Context) {
id := c.Param("id")
var cl db.Client
err := s.database.Get(&cl, "SELECT * FROM clients WHERE client_id = ?", id)
if err != nil {
c.JSON(http.StatusNotFound, ErrorResponse("Client not found"))
return
}
name := ""
if cl.Name != nil {
name = *cl.Name
}
desc := ""
if cl.Description != nil {
desc = *cl.Description
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"id": cl.ClientID,
"name": name,
"description": desc,
"is_active": cl.IsActive,
"rate_limit_per_minute": cl.RateLimitPerMinute,
"created_at": cl.CreatedAt,
}))
}
type UpdateClientRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
IsActive bool `json:"is_active"`
RateLimitPerMinute *int `json:"rate_limit_per_minute"`
}
func (s *Server) handleUpdateClient(c *gin.Context) {
id := c.Param("id")
var req UpdateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
_, err := s.database.Exec(`
UPDATE clients SET
name = ?,
description = ?,
is_active = ?,
rate_limit_per_minute = COALESCE(?, rate_limit_per_minute),
updated_at = CURRENT_TIMESTAMP
WHERE client_id = ?
`, req.Name, req.Description, req.IsActive, req.RateLimitPerMinute, id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client updated"}))
}
type CreateClientRequest struct {
Name string `json:"name" binding:"required"`
ClientID *string `json:"client_id"`
}
func (s *Server) handleCreateClient(c *gin.Context) {
var req CreateClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse("Invalid request"))
return
}
clientID := ""
if req.ClientID != nil {
clientID = *req.ClientID
} else {
clientID = "client-" + uuid.New().String()[:8]
}
_, err := s.database.Exec("INSERT INTO clients (client_id, name, is_active) VALUES (?, ?, 1)", clientID, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
token := "sk-" + uuid.New().String() + uuid.New().String()
token = token[:51]
_, err = s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, 'default')", clientID, token)
if err != nil {
// Log error
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"id": clientID,
"name": req.Name,
"status": "active",
"token": token,
"created_at": time.Now(),
}))
}
func (s *Server) handleDeleteClient(c *gin.Context) {
id := c.Param("id")
if id == "default" {
c.JSON(http.StatusBadRequest, ErrorResponse("Cannot delete default client"))
return
}
_, err := s.database.Exec("DELETE FROM clients WHERE client_id = ?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Client deleted"}))
}
func (s *Server) handleGetClientTokens(c *gin.Context) {
id := c.Param("id")
var tokens []db.ClientToken
err := s.database.Select(&tokens, "SELECT * FROM client_tokens WHERE client_id = ? ORDER BY created_at DESC", id)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
type MaskedToken struct {
ID int `json:"id"`
TokenMasked string `json:"token_masked"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
}
masked := make([]MaskedToken, len(tokens))
for i, t := range tokens {
maskedToken := "••••"
if len(t.Token) > 8 {
maskedToken = t.Token[:3] + "••••" + t.Token[len(t.Token)-8:]
}
masked[i] = MaskedToken{
ID: t.ID,
TokenMasked: maskedToken,
Name: t.Name,
IsActive: t.IsActive,
CreatedAt: t.CreatedAt,
LastUsedAt: t.LastUsedAt,
}
}
c.JSON(http.StatusOK, SuccessResponse(masked))
}
type CreateTokenRequest struct {
Name string `json:"name"`
}
func (s *Server) handleCreateClientToken(c *gin.Context) {
clientID := c.Param("id")
var req CreateTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
// optional name
}
name := "default"
if req.Name != "" {
name = req.Name
}
token := "sk-" + uuid.New().String() + uuid.New().String()
token = token[:51]
_, err := s.database.Exec("INSERT INTO client_tokens (client_id, token, name) VALUES (?, ?, ?)", clientID, token, name)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{
"token": token,
"name": name,
"created_at": time.Now(),
}))
}
func (s *Server) handleDeleteClientToken(c *gin.Context) {
tokenID := c.Param("token_id")
_, err := s.database.Exec("DELETE FROM client_tokens WHERE id = ?", tokenID)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse(err.Error()))
return
}
c.JSON(http.StatusOK, SuccessResponse(gin.H{"message": "Token revoked"}))
}