package gateway import ( "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" ) // persona-to-identity and API-key admin writes (owner-only, audited). Persona // writes register into the persona registry (real runtime effect) and persist a // YAML file so they survive a restart; API keys are minted/revoked in the // auth.APIKey store. const personaDir = "configs/personas" // personaUpsertRequest is the POST/PUT /api/v1/admin/personas body. type personaUpsertRequest struct { ID string `json:"id"` Name string `json:"name"` CharacterRef string `json:"characterRef"` Role string `json:"role"` SystemPrompt string `json:"systemPrompt"` Personality string `json:"personality"` SpeechStyle string `json:"speechStyle"` Catchphrase string `json:"catchphrase"` Background string `json:"background"` Values []string `json:"values"` Modes []string `json:"modes"` Mode string `json:"mode"` MemoryNS string `json:"memoryNamespace"` } // adminCreatePersona registers a new persona in the registry (real runtime // effect) and persists it to the persona YAML library so it survives a restart. func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) { var req personaUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required")) return } reg, ok := s.personas.(*personareg.Registry) if !ok { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured")) return } if _, exists := reg.Persona(req.ID); exists { s.auditClaims(r, "persona.create", "error") writeError(w, &apperr.AppError{Code: "duplicate", Message: "人格已存在,请使用编辑", HTTPCode: http.StatusConflict}) return } p := personaFromRequest(req) if err := savePersonaYAML(p); err != nil { s.auditClaims(r, "persona.create", "error") s.log.Error("persona persist", "id", req.ID, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona")) return } if err := reg.RegisterPersona(p); err != nil { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register persona")) return } if err := reg.RegisterCharacter(characterFromRequest(req)); err != nil { s.auditClaims(r, "persona.create", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register character")) return } s.auditClaims(r, "persona.create", "ok") writeJSON(w, http.StatusCreated, personaSummaryOf(p, characterFromRequest(req))) } // adminUpdatePersona edits an existing persona, persisting the YAML and // replacing the in-memory persona/character. func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req personaUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required")) return } req.ID = id reg, ok := s.personas.(*personareg.Registry) if !ok { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured")) return } if _, exists := reg.Persona(id); !exists { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "persona %q not found", id)) return } p := personaFromRequest(req) if err := savePersonaYAML(p); err != nil { s.auditClaims(r, "persona.update", "error") s.log.Error("persona persist", "id", id, "err", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona")) return } if err := reg.UpdatePersona(p); err != nil { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update persona")) return } if err := reg.UpdateCharacter(characterFromRequest(req)); err != nil { s.auditClaims(r, "persona.update", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update character")) return } s.auditClaims(r, "persona.update", "ok") writeJSON(w, http.StatusOK, personaSummaryOf(p, characterFromRequest(req))) } // adminCreateUser answers honestly: there is no persistent user table and no // user-authenticated login to back one (the deployment is a single admin // account from .env plus runtime API keys; design/11 is single-user first), so // user creation would be a row nobody could log in with. It reports the missing // store instead of fabricating a user. func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) { s.auditClaims(r, "user.create", "error") writeError(w, &apperr.AppError{ Code: "not_implemented", Message: "新增用户/角色未实现:无用户表,登录仅用 .env 单 admin(ADMIN_USERNAME/PASSWORD),角色为 owner>admin>viewer 常量;多用户存储与登录接入需先设计(design/11 单用户为主)", HTTPCode: http.StatusNotImplemented, }) } // apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body. type apiKeyIssueRequest struct { Role string `json:"role"` } // adminIssueApiKey mints a real API key bound to a role and returns the plaintext // once (the only time it is visible). The id in the response is the masked form // the admin list shows, so it can be used to revoke the key later. func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) { var req apiKeyIssueRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } role, err := auth.ParseRole(req.Role) if err != nil { s.auditClaims(r, "apikey.issue", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role")) return } keys, ok := s.users.(*auth.APIKey) if !ok { s.auditClaims(r, "apikey.issue", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置")) return } key, err := keys.Generate(role) if err != nil { s.auditClaims(r, "apikey.issue", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key")) return } s.auditClaims(r, "apikey.issue", "ok") writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)}) } // adminRevokeApiKey revokes a key by its masked id (the identifier the admin list // returns). It reports not-found when no key carries that mask. func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") keys, ok := s.users.(*auth.APIKey) if !ok { s.auditClaims(r, "apikey.revoke", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置")) return } if !keys.RevokeByMask(id) { s.auditClaims(r, "apikey.revoke", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "api key not found")) return } s.auditClaims(r, "apikey.revoke", "ok") writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true}) } // personaFromRequest builds a loader.Persona from the upsert request, filling a // derived system prompt/characterRef when omitted so a minimal create validates. func personaFromRequest(req personaUpsertRequest) *loader.Persona { systemPrompt := strings.TrimSpace(req.SystemPrompt) if systemPrompt == "" { systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role) } characterRef := strings.TrimSpace(req.CharacterRef) if characterRef == "" { characterRef = req.ID } return &loader.Persona{ ID: req.ID, Name: req.Name, CharacterRef: characterRef, Identity: loader.Identity{Role: req.Role, SystemPrompt: systemPrompt}, Personality: req.Personality, SpeechStyle: req.SpeechStyle, Catchphrase: req.Catchphrase, Background: req.Background, Values: req.Values, Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)}, } } // characterFromRequest builds the registry.Character bound to the persona. func characterFromRequest(req personaUpsertRequest) *personareg.Character { modes := append([]string(nil), req.Modes...) if len(modes) == 0 && req.Mode != "" { modes = []string{req.Mode} } return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes} } // personaSummaryOf maps a persona+character to its wire summary. func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary { return PersonaSummary{ ID: p.ID, Name: c.Name, Character: c.Name, IsDefault: c.Default, Mode: firstMode(c.Modes), MemoryNS: p.MemoryNamespace(), } } // savePersonaYAML persists p to the persona library as .yaml so it survives // a restart (wire.go loads this directory). The id is sanitized so user input // cannot traverse out of the library directory. func savePersonaYAML(p *loader.Persona) error { data, err := yaml.Marshal(p) if err != nil { return fmt.Errorf("persona: marshal %q: %w", p.ID, err) } if err := os.MkdirAll(personaDir, 0o755); err != nil { return fmt.Errorf("persona: mkdir: %w", err) } path := filepath.Join(personaDir, sanitizeID(p.ID)+".yaml") if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("persona: write %s: %w", path, err) } return nil } // sanitizeID keeps only URL/path-safe characters so a persona id cannot escape // the persona directory. func sanitizeID(id string) string { var b strings.Builder for _, r := range id { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) default: b.WriteByte('_') } } return b.String() }