- RealAuthManager: JWT signing/validation + bcrypt password hashing - MemUserStore: in-memory user store with default admin - Register endpoint: user creation with hashed password - Login endpoint: bcrypt verification + JWT generation - AuthManager interface extended with Login/Register methods
373 lines
10 KiB
Go
373 lines
10 KiB
Go
// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
|
)
|
|
|
|
// --- Interfaces for injected services ---
|
|
|
|
// AuthManager validates JWT tokens and handles login/register.
|
|
type AuthManager interface {
|
|
ValidateToken(token string) (userID, username, role string, err error)
|
|
Login(username, password string) (token string, err error)
|
|
Register(username, password string) (token string, err error)
|
|
}
|
|
|
|
// MemoryEntry is a simplified memory entry for API responses.
|
|
type MemoryEntry struct {
|
|
ID string `json:"id"`
|
|
UserID string `json:"user_id"`
|
|
Content string `json:"content"`
|
|
Summary string `json:"summary"`
|
|
Keywords []string `json:"keywords"`
|
|
Category string `json:"category"`
|
|
Priority float64 `json:"priority"`
|
|
}
|
|
|
|
// MemorySearcher handles memory search queries.
|
|
type MemorySearcher interface {
|
|
Search(ctx context.Context, userID, query string, limit int) (any, error)
|
|
List(ctx context.Context, userID string, limit int) (any, error)
|
|
Add(ctx context.Context, userID, content, category string, priority float64) (any, error)
|
|
Get(ctx context.Context, id string) (any, error)
|
|
Delete(ctx context.Context, id string) error
|
|
}
|
|
|
|
// VoiceService handles STT and TTS.
|
|
type VoiceService interface {
|
|
Recognize(ctx context.Context, audioData []byte) (string, error)
|
|
Synthesize(ctx context.Context, text string, voice string) ([]byte, error)
|
|
}
|
|
|
|
// OrchestratorPipeline handles the main dialogue flow.
|
|
type OrchestratorPipeline interface {
|
|
ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
|
|
}
|
|
|
|
// WebSocketHub manages WS connections.
|
|
type WebSocketHub interface {
|
|
UpgradeHandler() http.HandlerFunc
|
|
}
|
|
|
|
// Dependencies holds all injectable services.
|
|
type Dependencies struct {
|
|
AuthMgr AuthManager
|
|
MemorySvc MemorySearcher
|
|
VoiceSvc VoiceService
|
|
Orchestrator OrchestratorPipeline
|
|
WSHub WebSocketHub
|
|
}
|
|
|
|
// Router handles HTTP routing with middleware support.
|
|
type Router struct {
|
|
mux *http.ServeMux
|
|
deps Dependencies
|
|
}
|
|
|
|
// New creates a new Router with all routes configured.
|
|
func New(deps Dependencies) *Router {
|
|
r := &Router{mux: http.NewServeMux(), deps: deps}
|
|
r.setupRoutes()
|
|
return r
|
|
}
|
|
|
|
func (r *Router) setupRoutes() {
|
|
// Public
|
|
r.mux.HandleFunc("/api/v1/health", r.healthHandler)
|
|
r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler)
|
|
r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler)
|
|
|
|
// Protected
|
|
r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler))
|
|
r.mux.HandleFunc("/api/v1/sessions/", r.authMW(r.sessionByIDHandler))
|
|
|
|
// Memory
|
|
r.mux.HandleFunc("/api/v1/memory/search", r.authMW(r.memorySearchHandler))
|
|
r.mux.HandleFunc("/api/v1/memory", r.authMW(r.memoryHandler))
|
|
r.mux.HandleFunc("/api/v1/memory/", r.authMW(r.memoryHandler))
|
|
|
|
// Voice
|
|
r.mux.HandleFunc("/api/v1/voice/stt", r.authMW(r.voiceSTTHandler))
|
|
r.mux.HandleFunc("/api/v1/voice/tts", r.authMW(r.voiceTTSHandler))
|
|
|
|
// Admin
|
|
r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler)))
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *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", "Authorization, Content-Type")
|
|
if req.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
r.mux.ServeHTTP(w, req)
|
|
}
|
|
|
|
// --- Middleware ---
|
|
|
|
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
h := req.Header.Get("Authorization")
|
|
if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
|
writeJSON(w, 401, M{"error": "unauthorized"})
|
|
return
|
|
}
|
|
uid, uname, role, err := r.deps.AuthMgr.ValidateToken(h[7:])
|
|
if err != nil {
|
|
writeJSON(w, 401, M{"error": "invalid token"})
|
|
return
|
|
}
|
|
req.Header.Set("X-User-ID", uid)
|
|
req.Header.Set("X-Username", uname)
|
|
req.Header.Set("X-Role", role)
|
|
next(w, req)
|
|
}
|
|
}
|
|
|
|
func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
if req.Header.Get("X-Role") != "admin" {
|
|
writeJSON(w, 403, M{"error": "admin only"})
|
|
return
|
|
}
|
|
next(w, req)
|
|
}
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, 200, M{"status": "ok", "service": "cyrene-gateway", "version": "2.0.0-dev"})
|
|
}
|
|
|
|
func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != "POST" {
|
|
writeJSON(w, 405, M{"error": "POST required"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
json.NewDecoder(req.Body).Decode(&body)
|
|
|
|
token, err := r.deps.AuthMgr.Login(body.Username, body.Password)
|
|
if err != nil {
|
|
writeJSON(w, 401, M{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, M{"token": token})
|
|
}
|
|
|
|
func (r *Router) registerHandler(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != "POST" {
|
|
writeJSON(w, 405, M{"error": "POST required"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
json.NewDecoder(req.Body).Decode(&body)
|
|
|
|
token, err := r.deps.AuthMgr.Register(body.Username, body.Password)
|
|
if err != nil {
|
|
writeJSON(w, 400, M{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 201, M{"token": token})
|
|
}
|
|
|
|
// --- Sessions ---
|
|
|
|
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case "GET":
|
|
writeJSON(w, 200, M{"sessions": []any{}})
|
|
case "POST":
|
|
writeJSON(w, 201, M{"session_id": randID()})
|
|
default:
|
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
|
}
|
|
}
|
|
|
|
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
|
|
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
|
|
switch req.Method {
|
|
case "DELETE":
|
|
writeJSON(w, 204, nil)
|
|
case "GET":
|
|
writeJSON(w, 200, M{"session_id": id})
|
|
default:
|
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
|
}
|
|
}
|
|
|
|
// --- Memory ---
|
|
|
|
func (r *Router) memorySearchHandler(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != "GET" {
|
|
writeJSON(w, 405, M{"error": "GET required"})
|
|
return
|
|
}
|
|
if r.deps.MemorySvc == nil {
|
|
writeJSON(w, 503, M{"error": "memory service unavailable"})
|
|
return
|
|
}
|
|
q := req.URL.Query().Get("q")
|
|
uid := req.Header.Get("X-User-ID")
|
|
|
|
result, err := r.deps.MemorySvc.Search(req.Context(), uid, q, 10)
|
|
if err != nil {
|
|
writeJSON(w, 500, M{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, M{"data": result})
|
|
}
|
|
|
|
func (r *Router) memoryHandler(w http.ResponseWriter, req *http.Request) {
|
|
if r.deps.MemorySvc == nil {
|
|
writeJSON(w, 503, M{"error": "memory service unavailable"})
|
|
return
|
|
}
|
|
|
|
// Extract ID from path: /api/v1/memory/{id}
|
|
path := strings.TrimPrefix(req.URL.Path, "/api/v1/memory")
|
|
path = strings.TrimPrefix(path, "/")
|
|
userID := req.Header.Get("X-User-ID")
|
|
|
|
switch {
|
|
case path == "" && req.Method == "GET":
|
|
result, _ := r.deps.MemorySvc.List(req.Context(), userID, 20)
|
|
writeJSON(w, 200, M{"data": result})
|
|
|
|
case path == "" && req.Method == "POST":
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
Category string `json:"category"`
|
|
Priority float64 `json:"priority"`
|
|
}
|
|
json.NewDecoder(req.Body).Decode(&body)
|
|
if body.Category == "" { body.Category = "fact" }
|
|
mem, err := r.deps.MemorySvc.Add(req.Context(), userID, body.Content, body.Category, body.Priority)
|
|
if err != nil {
|
|
writeJSON(w, 500, M{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 201, M{"data": mem})
|
|
|
|
case path != "" && req.Method == "DELETE":
|
|
r.deps.MemorySvc.Delete(req.Context(), path)
|
|
writeJSON(w, 204, nil)
|
|
|
|
case path != "" && req.Method == "GET":
|
|
mem, err := r.deps.MemorySvc.Get(req.Context(), path)
|
|
if err != nil {
|
|
writeJSON(w, 404, M{"error": "memory not found"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, M{"data": mem})
|
|
|
|
default:
|
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
|
}
|
|
}
|
|
|
|
// --- Voice ---
|
|
|
|
func (r *Router) voiceSTTHandler(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != "POST" {
|
|
writeJSON(w, 405, M{"error": "POST required"})
|
|
return
|
|
}
|
|
if r.deps.VoiceSvc == nil {
|
|
writeJSON(w, 503, M{"error": "voice service unavailable"})
|
|
return
|
|
}
|
|
var body struct{ AudioData []byte `json:"audio_data"` }
|
|
json.NewDecoder(req.Body).Decode(&body)
|
|
text, err := r.deps.VoiceSvc.Recognize(req.Context(), body.AudioData)
|
|
if err != nil {
|
|
writeJSON(w, 500, M{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, M{"text": text})
|
|
}
|
|
|
|
func (r *Router) voiceTTSHandler(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != "POST" {
|
|
writeJSON(w, 405, M{"error": "POST required"})
|
|
return
|
|
}
|
|
if r.deps.VoiceSvc == nil {
|
|
writeJSON(w, 503, M{"error": "voice service unavailable"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
Voice string `json:"voice"`
|
|
}
|
|
json.NewDecoder(req.Body).Decode(&body)
|
|
if body.Voice == "" { body.Voice = "zh-CN-XiaoxiaoNeural" }
|
|
audio, err := r.deps.VoiceSvc.Synthesize(req.Context(), body.Text, body.Voice)
|
|
if err != nil {
|
|
writeJSON(w, 500, M{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, M{"audio_size": len(audio)})
|
|
}
|
|
|
|
// --- Admin ---
|
|
|
|
var startTime = time.Now()
|
|
|
|
func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, 200, M{
|
|
"dashboard": M{
|
|
"status": "running",
|
|
"version": "2.0.0-dev",
|
|
"uptime_sec": int(time.Since(startTime).Seconds()),
|
|
"services": M{
|
|
"memory": r.deps.MemorySvc != nil,
|
|
"voice": r.deps.VoiceSvc != nil,
|
|
"ai_core": r.deps.Orchestrator != nil,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
type M = map[string]any
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if data != nil {
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
}
|
|
|
|
func randID() string {
|
|
b := make([]byte, 8)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func (r *Router) userID(req *http.Request) string { return req.Header.Get("X-User-ID") }
|
|
|
|
// Ensure unused imports don't cause issues
|
|
|