diff --git a/.env.example b/.env.example index 4c65fa3..669eb0e 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,6 @@ DISCORD_CLIENT_SECRET= DISCORD_REDIRECT_URI=http://localhost:8787/api/auth/discord/callback DISCORD_ALLOWED_GUILD_ID= DISCORD_ALLOWED_ROLE_ID= +# Comma-separated Discord server (guild) IDs to blacklist. Users who belong to any +# of these servers are denied registration/login (requests the "guilds" scope). +DISCORD_BLOCKED_GUILD_IDS= diff --git a/cmd/capi/main.go b/cmd/capi/main.go index 305f18b..a64a825 100644 --- a/cmd/capi/main.go +++ b/cmd/capi/main.go @@ -121,26 +121,28 @@ type Account struct { } type DiscordSettings struct { - Managed bool `json:"managed,omitempty"` - Enabled bool `json:"enabled"` - ClientID string `json:"clientId,omitempty"` - ClientSecret string `json:"clientSecret,omitempty"` - RedirectURI string `json:"redirectUri,omitempty"` - AllowedGuildID string `json:"allowedGuildId,omitempty"` - AllowedRoleID string `json:"allowedRoleId,omitempty"` - AuthSuccessURL string `json:"authSuccessUrl,omitempty"` - SessionTTLHours int `json:"sessionTtlHours,omitempty"` + Managed bool `json:"managed,omitempty"` + Enabled bool `json:"enabled"` + ClientID string `json:"clientId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` + RedirectURI string `json:"redirectUri,omitempty"` + AllowedGuildID string `json:"allowedGuildId,omitempty"` + AllowedRoleID string `json:"allowedRoleId,omitempty"` + BlockedGuildIDs []string `json:"blockedGuildIds,omitempty"` + AuthSuccessURL string `json:"authSuccessUrl,omitempty"` + SessionTTLHours int `json:"sessionTtlHours,omitempty"` } type PublicDiscordSettings struct { - Enabled bool `json:"enabled"` - ClientID string `json:"clientId"` - ClientSecretSet bool `json:"clientSecretSet"` - RedirectURI string `json:"redirectUri"` - AllowedGuildID string `json:"allowedGuildId"` - AllowedRoleID string `json:"allowedRoleId"` - AuthSuccessURL string `json:"authSuccessUrl"` - SessionTTLHours int `json:"sessionTtlHours"` + Enabled bool `json:"enabled"` + ClientID string `json:"clientId"` + ClientSecretSet bool `json:"clientSecretSet"` + RedirectURI string `json:"redirectUri"` + AllowedGuildID string `json:"allowedGuildId"` + AllowedRoleID string `json:"allowedRoleId"` + BlockedGuildIDs []string `json:"blockedGuildIds"` + AuthSuccessURL string `json:"authSuccessUrl"` + SessionTTLHours int `json:"sessionTtlHours"` } type User struct { @@ -374,43 +376,44 @@ type CheckInRecord struct { } type Server struct { - mu sync.Mutex - openAIRefreshMu sync.Mutex - keyRotationMu sync.Mutex - state AppState - dataFile string - databaseURL string - staticDir string - db *sql.DB - persistence string - corsOrigin string - adminToken string - secretKey []byte - requestLimitPerMinute int - providerMode string - upstreamAPIKey string - upstreamTimeout time.Duration - httpClient *http.Client - webHTTPClient *http.Client - chatGPTAPIBase string - openAIAuthBase string - discordClientID string - discordClientSecret string - discordRedirectURI string - discordAllowedGuildID string - discordAllowedRoleID string - discordOAuthBase string - discordAPIBase string - authSuccessURL string - sessionTTL time.Duration - accountHealthInterval time.Duration - rateLimitBuckets map[string]int - idempotencyCache map[string]CachedResponse - authStates map[string]time.Time - sessions map[string]Session - openAIOAuthFlows map[string]openAIOAuthFlow - requestAccounts map[string]string - keyRotationOffsets map[string]int + mu sync.Mutex + openAIRefreshMu sync.Mutex + keyRotationMu sync.Mutex + state AppState + dataFile string + databaseURL string + staticDir string + db *sql.DB + persistence string + corsOrigin string + adminToken string + secretKey []byte + requestLimitPerMinute int + providerMode string + upstreamAPIKey string + upstreamTimeout time.Duration + httpClient *http.Client + webHTTPClient *http.Client + chatGPTAPIBase string + openAIAuthBase string + discordClientID string + discordClientSecret string + discordRedirectURI string + discordAllowedGuildID string + discordAllowedRoleID string + discordBlockedGuildIDs []string + discordOAuthBase string + discordAPIBase string + authSuccessURL string + sessionTTL time.Duration + accountHealthInterval time.Duration + rateLimitBuckets map[string]int + idempotencyCache map[string]CachedResponse + authStates map[string]time.Time + sessions map[string]Session + openAIOAuthFlows map[string]openAIOAuthFlow + requestAccounts map[string]string + keyRotationOffsets map[string]int } // openAIOAuthFlow tracks one in-progress ChatGPT OAuth (PKCE) authorization so @@ -575,20 +578,28 @@ type DiscordUser struct { Avatar string `json:"avatar"` } +// DiscordUserGuild is a partial guild object from GET /users/@me/guilds, used to +// enforce the blocked-server list (requires the "guilds" OAuth scope). +type DiscordUserGuild struct { + ID string `json:"id"` + Name string `json:"name"` +} + type DiscordGuildMember struct { User DiscordUser `json:"user"` Roles []string `json:"roles"` } type DiscordRuntimeConfig struct { - ClientID string - ClientSecret string - RedirectURI string - AllowedGuildID string - AllowedRoleID string - OAuthBase string - AuthSuccessURL string - SessionTTL time.Duration + ClientID string + ClientSecret string + RedirectURI string + AllowedGuildID string + AllowedRoleID string + BlockedGuildIDs []string + OAuthBase string + AuthSuccessURL string + SessionTTL time.Duration } func main() { @@ -700,38 +711,39 @@ func NewServer() *Server { dataFile := env("DATA_FILE", "data/state.json") s := &Server{ - state: defaultState(), - dataFile: dataFile, - databaseURL: env("DATABASE_URL", ""), - staticDir: env("STATIC_DIR", "dist"), - persistence: persistence, - corsOrigin: normalizeCORSOriginConfig(env("CORS_ORIGIN", "*")), - adminToken: env("ADMIN_TOKEN", ""), - secretKey: deriveSecretKey(env("SECRET_KEY", "")), - requestLimitPerMinute: envInt("REQUEST_LIMIT_PER_MINUTE", 60), - providerMode: env("PROVIDER_MODE", "mock"), - upstreamAPIKey: env("UPSTREAM_API_KEY", ""), - upstreamTimeout: time.Duration(envInt("UPSTREAM_TIMEOUT_SECONDS", defaultUpstreamTimeoutSeconds)) * time.Second, - httpClient: &http.Client{Timeout: time.Duration(envInt("UPSTREAM_TIMEOUT_SECONDS", defaultUpstreamTimeoutSeconds)) * time.Second}, - chatGPTAPIBase: env("CHATGPT_API_BASE", defaultChatGPTAPIBaseURL), - openAIAuthBase: env("OPENAI_AUTH_BASE", "https://auth.openai.com"), - discordClientID: env("DISCORD_CLIENT_ID", ""), - discordClientSecret: env("DISCORD_CLIENT_SECRET", ""), - discordRedirectURI: env("DISCORD_REDIRECT_URI", ""), - discordAllowedGuildID: env("DISCORD_ALLOWED_GUILD_ID", ""), - discordAllowedRoleID: env("DISCORD_ALLOWED_ROLE_ID", ""), - discordOAuthBase: env("DISCORD_OAUTH_BASE", "https://discord.com/api/oauth2"), - discordAPIBase: env("DISCORD_API_BASE", "https://discord.com/api/v10"), - authSuccessURL: env("AUTH_SUCCESS_URL", ""), - sessionTTL: time.Duration(envInt("SESSION_TTL_HOURS", 168)) * time.Hour, - accountHealthInterval: 15 * time.Minute, - rateLimitBuckets: map[string]int{}, - idempotencyCache: map[string]CachedResponse{}, - authStates: map[string]time.Time{}, - sessions: map[string]Session{}, - openAIOAuthFlows: map[string]openAIOAuthFlow{}, - requestAccounts: map[string]string{}, - keyRotationOffsets: map[string]int{}, + state: defaultState(), + dataFile: dataFile, + databaseURL: env("DATABASE_URL", ""), + staticDir: env("STATIC_DIR", "dist"), + persistence: persistence, + corsOrigin: normalizeCORSOriginConfig(env("CORS_ORIGIN", "*")), + adminToken: env("ADMIN_TOKEN", ""), + secretKey: deriveSecretKey(env("SECRET_KEY", "")), + requestLimitPerMinute: envInt("REQUEST_LIMIT_PER_MINUTE", 60), + providerMode: env("PROVIDER_MODE", "mock"), + upstreamAPIKey: env("UPSTREAM_API_KEY", ""), + upstreamTimeout: time.Duration(envInt("UPSTREAM_TIMEOUT_SECONDS", defaultUpstreamTimeoutSeconds)) * time.Second, + httpClient: &http.Client{Timeout: time.Duration(envInt("UPSTREAM_TIMEOUT_SECONDS", defaultUpstreamTimeoutSeconds)) * time.Second}, + chatGPTAPIBase: env("CHATGPT_API_BASE", defaultChatGPTAPIBaseURL), + openAIAuthBase: env("OPENAI_AUTH_BASE", "https://auth.openai.com"), + discordClientID: env("DISCORD_CLIENT_ID", ""), + discordClientSecret: env("DISCORD_CLIENT_SECRET", ""), + discordRedirectURI: env("DISCORD_REDIRECT_URI", ""), + discordAllowedGuildID: env("DISCORD_ALLOWED_GUILD_ID", ""), + discordAllowedRoleID: env("DISCORD_ALLOWED_ROLE_ID", ""), + discordBlockedGuildIDs: parseGuildIDList(env("DISCORD_BLOCKED_GUILD_IDS", "")), + discordOAuthBase: env("DISCORD_OAUTH_BASE", "https://discord.com/api/oauth2"), + discordAPIBase: env("DISCORD_API_BASE", "https://discord.com/api/v10"), + authSuccessURL: env("AUTH_SUCCESS_URL", ""), + sessionTTL: time.Duration(envInt("SESSION_TTL_HOURS", 168)) * time.Hour, + accountHealthInterval: 15 * time.Minute, + rateLimitBuckets: map[string]int{}, + idempotencyCache: map[string]CachedResponse{}, + authStates: map[string]time.Time{}, + sessions: map[string]Session{}, + openAIOAuthFlows: map[string]openAIOAuthFlow{}, + requestAccounts: map[string]string{}, + keyRotationOffsets: map[string]int{}, } s.webHTTPClient = newChatGPTWebHTTPClient(s.upstreamTimeout) s.initStorage() @@ -925,6 +937,7 @@ func (s *Server) registerRoutes(router *gin.Engine) { admin.PATCH("/channels/:id", s.updateChannel) admin.DELETE("/channels/:id", s.deleteChannel) admin.POST("/channels/:id/check", s.checkChannel) + admin.POST("/channels/:id/upstream-models", s.previewUpstreamModels) admin.POST("/channels/:id/sync-models", s.syncChannelModels) admin.GET("/models", s.listModels) admin.POST("/models", s.createModel) @@ -1113,6 +1126,7 @@ func (s *Server) configStatus(c *gin.Context) { discordEnabled := s.discordLoginEnabledLocked() discordGuildGate := s.discordAllowedGuildID != "" discordRoleGate := s.discordAllowedRoleID != "" + discordBlockedGuildGate := len(s.discordBlockedGuildIDs) > 0 s.mu.Unlock() c.JSON(http.StatusOK, gin.H{ @@ -1129,6 +1143,7 @@ func (s *Server) configStatus(c *gin.Context) { "discordLoginEnabled": discordEnabled, "discordGuildGate": discordGuildGate, "discordRoleGate": discordRoleGate, + "discordBlockedGuildGate": discordBlockedGuildGate, "corsOrigin": s.corsOrigin, "adminAuthEnabled": s.adminToken != "", "requestLimitPerMinute": s.requestLimitPerMinute, @@ -1628,15 +1643,16 @@ func (s *Server) getDiscordSettings(c *gin.Context) { func (s *Server) updateDiscordSettings(c *gin.Context) { var body struct { - Enabled bool `json:"enabled"` - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - ClearClientSecret bool `json:"clearClientSecret"` - RedirectURI string `json:"redirectUri"` - AllowedGuildID string `json:"allowedGuildId"` - AllowedRoleID string `json:"allowedRoleId"` - AuthSuccessURL string `json:"authSuccessUrl"` - SessionTTLHours int `json:"sessionTtlHours"` + Enabled bool `json:"enabled"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + ClearClientSecret bool `json:"clearClientSecret"` + RedirectURI string `json:"redirectUri"` + AllowedGuildID string `json:"allowedGuildId"` + AllowedRoleID string `json:"allowedRoleId"` + BlockedGuildIDs []string `json:"blockedGuildIds"` + AuthSuccessURL string `json:"authSuccessUrl"` + SessionTTLHours int `json:"sessionTtlHours"` } if err := c.ShouldBindJSON(&body); err != nil { s.openAIError(c, http.StatusBadRequest, "invalid_json", "Invalid JSON body", "invalid_request_error", nil) @@ -1678,6 +1694,11 @@ func (s *Server) updateDiscordSettings(c *gin.Context) { validationError(c, "Discord 身份组 ID 只能包含数字") return } + blockedGuildIDs, invalidBlocked := sanitizeGuildIDList(body.BlockedGuildIDs) + if invalidBlocked != "" { + validationError(c, "拉黑服务器 ID 只能包含数字") + return + } if body.RedirectURI != "" && !validHTTPURL(body.RedirectURI) { validationError(c, "Discord 回调地址必须是完整的 http:// 或 https:// 地址") return @@ -1733,6 +1754,7 @@ func (s *Server) updateDiscordSettings(c *gin.Context) { RedirectURI: body.RedirectURI, AllowedGuildID: body.AllowedGuildID, AllowedRoleID: body.AllowedRoleID, + BlockedGuildIDs: blockedGuildIDs, AuthSuccessURL: body.AuthSuccessURL, SessionTTLHours: body.SessionTTLHours, } @@ -1741,6 +1763,7 @@ func (s *Server) updateDiscordSettings(c *gin.Context) { s.discordRedirectURI = body.RedirectURI s.discordAllowedGuildID = body.AllowedGuildID s.discordAllowedRoleID = body.AllowedRoleID + s.discordBlockedGuildIDs = blockedGuildIDs s.authSuccessURL = body.AuthSuccessURL s.sessionTTL = time.Duration(body.SessionTTLHours) * time.Hour if !body.Enabled { @@ -1770,6 +1793,7 @@ func (s *Server) publicDiscordSettingsLocked(origin string) PublicDiscordSetting RedirectURI: redirectURI, AllowedGuildID: s.discordAllowedGuildID, AllowedRoleID: s.discordAllowedRoleID, + BlockedGuildIDs: append([]string{}, s.discordBlockedGuildIDs...), AuthSuccessURL: authSuccessURL, SessionTTLHours: int(s.sessionTTL.Hours()), } @@ -1789,6 +1813,7 @@ func (s *Server) publicDiscordSettingsLocked(origin string) PublicDiscordSetting RedirectURI: redirectURI, AllowedGuildID: settings.AllowedGuildID, AllowedRoleID: settings.AllowedRoleID, + BlockedGuildIDs: append([]string{}, settings.BlockedGuildIDs...), AuthSuccessURL: authSuccessURL, SessionTTLHours: settings.SessionTTLHours, } @@ -1814,7 +1839,13 @@ func (s *Server) discordStart(c *gin.Context) { values.Set("client_id", config.ClientID) values.Set("redirect_uri", config.RedirectURI) values.Set("response_type", "code") - values.Set("scope", "identify guilds.members.read") + // The "guilds" scope is only needed to read the user's guild list for the + // blocked-server check, so request it only when a blacklist is configured. + scope := "identify guilds.members.read" + if len(config.BlockedGuildIDs) > 0 { + scope += " guilds" + } + values.Set("scope", scope) values.Set("state", state) c.Redirect(http.StatusFound, strings.TrimRight(config.OAuthBase, "/")+"/authorize?"+values.Encode()) } @@ -1847,6 +1878,21 @@ func (s *Server) discordCallback(c *gin.Context) { return } + // Enforce the blocked-server list before any login or registration: a user + // who belongs to any blacklisted guild is denied outright, even if already + // bound to a local account. + if len(config.BlockedGuildIDs) > 0 { + guilds, err := s.fetchDiscordUserGuilds(token.AccessToken) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": err.Error()}}) + return + } + if firstBlockedGuild(guilds, config.BlockedGuildIDs) != "" { + c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"message": "你所在的 Discord 服务器已被限制登录"}}) + return + } + } + s.mu.Lock() boundAccount := s.findAccountByDiscordIDLocked(user.ID) var boundAccountCopy *Account @@ -3498,6 +3544,14 @@ func (s *Server) deleteChannel(c *gin.Context) { } func (s *Server) syncChannelModels(c *gin.Context) { + // An optional models list lets the admin commit an explicit selection (from + // the model picker) instead of importing every model the upstream returns. + var body struct { + Models []string `json:"models"` + } + _ = c.ShouldBindJSON(&body) + selected := mergeStrings(nil, body.Models) + s.mu.Lock() channel := s.findChannel(c.Param("id")) if channel == nil { @@ -3507,7 +3561,7 @@ func (s *Server) syncChannelModels(c *gin.Context) { } channelCopy := *channel upstreamKey := "" - if !isCodexChannel(channelCopy) { + if len(selected) == 0 && !isCodexChannel(channelCopy) { var err error upstreamKey, err = s.channelUpstreamKey(channelCopy) if err != nil { @@ -3518,14 +3572,18 @@ func (s *Server) syncChannelModels(c *gin.Context) { } s.mu.Unlock() - modelIDs, err := s.fetchUpstreamModelIDs(channelCopy, upstreamKey) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": err.Error()}}) - return - } + modelIDs := selected if len(modelIDs) == 0 { - c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": "上游未返回可用模型"}}) - return + var err error + modelIDs, err = s.fetchUpstreamModelIDs(channelCopy, upstreamKey) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": err.Error()}}) + return + } + if len(modelIDs) == 0 { + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": "上游未返回可用模型"}}) + return + } } s.mu.Lock() @@ -3573,6 +3631,35 @@ func (s *Server) syncChannelModels(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"channel": publicChannel(*channel), "models": channel.Models, "addedModels": added, "removedModels": removedModels}) } +func (s *Server) previewUpstreamModels(c *gin.Context) { + s.mu.Lock() + channel := s.findChannel(c.Param("id")) + if channel == nil { + s.mu.Unlock() + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "Channel not found"}}) + return + } + channelCopy := *channel + upstreamKey := "" + if !isCodexChannel(channelCopy) { + var err error + upstreamKey, err = s.channelUpstreamKey(channelCopy) + if err != nil { + s.mu.Unlock() + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": err.Error()}}) + return + } + } + s.mu.Unlock() + + modelIDs, err := s.fetchUpstreamModelIDs(channelCopy, upstreamKey) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": err.Error()}}) + return + } + c.JSON(http.StatusOK, gin.H{"models": modelIDs}) +} + func (s *Server) checkChannel(c *gin.Context) { s.mu.Lock() channel := s.findChannel(c.Param("id")) @@ -7889,14 +7976,15 @@ func (s *Server) discordRuntimeConfig(c *gin.Context) DiscordRuntimeConfig { authSuccessURL = requestOrigin(c) + "/" } return DiscordRuntimeConfig{ - ClientID: s.discordClientID, - ClientSecret: s.discordClientSecret, - RedirectURI: redirectURI, - AllowedGuildID: s.discordAllowedGuildID, - AllowedRoleID: s.discordAllowedRoleID, - OAuthBase: s.discordOAuthBase, - AuthSuccessURL: authSuccessURL, - SessionTTL: s.sessionTTL, + ClientID: s.discordClientID, + ClientSecret: s.discordClientSecret, + RedirectURI: redirectURI, + AllowedGuildID: s.discordAllowedGuildID, + AllowedRoleID: s.discordAllowedRoleID, + BlockedGuildIDs: append([]string(nil), s.discordBlockedGuildIDs...), + OAuthBase: s.discordOAuthBase, + AuthSuccessURL: authSuccessURL, + SessionTTL: s.sessionTTL, } } @@ -7964,6 +8052,14 @@ func (s *Server) fetchDiscordGuildMember(accessToken string, guildID string) (*D return &member, nil } +func (s *Server) fetchDiscordUserGuilds(accessToken string) ([]DiscordUserGuild, error) { + var guilds []DiscordUserGuild + if err := s.discordGet(accessToken, "/users/@me/guilds", &guilds); err != nil { + return nil, err + } + return guilds, nil +} + func (s *Server) discordGet(accessToken string, path string, target interface{}) error { request, err := http.NewRequest(http.MethodGet, strings.TrimRight(s.discordAPIBase, "/")+path, nil) if err != nil { @@ -8765,6 +8861,65 @@ func digitsOnly(value string) bool { return true } +// parseGuildIDList splits a comma/space/newline separated list of Discord guild +// IDs, keeping only digit-only values and dropping blanks and duplicates. +func parseGuildIDList(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\n' || r == '\r' || r == '\t' + }) + seen := map[string]bool{} + ids := []string{} + for _, field := range fields { + id := strings.TrimSpace(field) + if id == "" || !digitsOnly(id) || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids +} + +// sanitizeGuildIDList validates and de-duplicates an incoming ID list. It returns +// the cleaned list and the first invalid (non-digit) value found, if any. +func sanitizeGuildIDList(values []string) ([]string, string) { + seen := map[string]bool{} + ids := []string{} + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" { + continue + } + if !digitsOnly(id) { + return nil, id + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids, "" +} + +// firstBlockedGuild returns the ID of the first guild the user belongs to that +// appears in the blocked list, or "" when none match. +func firstBlockedGuild(guilds []DiscordUserGuild, blocked []string) string { + if len(blocked) == 0 { + return "" + } + blockedSet := map[string]bool{} + for _, id := range blocked { + blockedSet[id] = true + } + for _, guild := range guilds { + if blockedSet[guild.ID] { + return guild.ID + } + } + return "" +} + func validHTTPURL(value string) bool { parsed, err := url.Parse(value) return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" @@ -9548,6 +9703,7 @@ func (s *Server) applyPersistedDiscordSettings() { s.discordRedirectURI = settings.RedirectURI s.discordAllowedGuildID = settings.AllowedGuildID s.discordAllowedRoleID = settings.AllowedRoleID + s.discordBlockedGuildIDs = append([]string{}, settings.BlockedGuildIDs...) s.authSuccessURL = settings.AuthSuccessURL if settings.SessionTTLHours > 0 { s.sessionTTL = time.Duration(settings.SessionTTLHours) * time.Hour diff --git a/cmd/capi/main_test.go b/cmd/capi/main_test.go index 513c5e4..e5b0e0c 100644 --- a/cmd/capi/main_test.go +++ b/cmd/capi/main_test.go @@ -1491,6 +1491,102 @@ func TestSyncChannelModelsPullsFromUpstream(t *testing.T) { } } +func TestPreviewUpstreamModelsListsWithoutCommitting(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("upstream path = %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"preview-model-a"},{"id":"preview-model-b"}]}`)) + })) + defer upstream.Close() + + withEnv(t, map[string]string{"PERSISTENCE": "memory"}) + _, router := testServerRouter(t) + + created := perform(router, http.MethodPost, "/api/channels", `{"name":"Upstream","baseUrl":"`+upstream.URL+`/v1","upstreamApiKey":"preview-secret","models":["existing-model"]}`, nil) + if created.Code != http.StatusCreated { + t.Fatalf("create channel status = %d body = %s", created.Code, created.Body.String()) + } + var payload struct { + Channel PublicChannel `json:"channel"` + } + if err := json.Unmarshal(created.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode channel: %v", err) + } + + preview := perform(router, http.MethodPost, "/api/channels/"+payload.Channel.ID+"/upstream-models", `{}`, nil) + if preview.Code != http.StatusOK { + t.Fatalf("preview status = %d body = %s", preview.Code, preview.Body.String()) + } + if !bytes.Contains(preview.Body.Bytes(), []byte(`preview-model-a`)) || !bytes.Contains(preview.Body.Bytes(), []byte(`preview-model-b`)) { + t.Fatalf("preview missing upstream ids: %s", preview.Body.String()) + } + + // Preview is read-only: it must not attach models to the channel or create + // catalog entries the way sync-models does. + channels := perform(router, http.MethodGet, "/api/channels", "", nil) + if bytes.Contains(channels.Body.Bytes(), []byte(`preview-model-a`)) { + t.Fatalf("preview should not attach models to channel: %s", channels.Body.String()) + } + models := perform(router, http.MethodGet, "/api/models", "", nil) + if bytes.Contains(models.Body.Bytes(), []byte(`"id":"preview-model-a"`)) { + t.Fatalf("preview should not create catalog models: %s", models.Body.String()) + } +} + +func TestSyncChannelModelsAcceptsExplicitSelection(t *testing.T) { + upstreamHits := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHits++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"provider-model-a"},{"id":"provider-model-b"},{"id":"provider-model-c"}]}`)) + })) + defer upstream.Close() + + withEnv(t, map[string]string{"PERSISTENCE": "memory"}) + _, router := testServerRouter(t) + + created := perform(router, http.MethodPost, "/api/channels", `{"name":"Upstream","baseUrl":"`+upstream.URL+`/v1","upstreamApiKey":"sync-secret","models":["stale-provider-model"]}`, nil) + if created.Code != http.StatusCreated { + t.Fatalf("create channel status = %d body = %s", created.Code, created.Body.String()) + } + var payload struct { + Channel PublicChannel `json:"channel"` + } + if err := json.Unmarshal(created.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode channel: %v", err) + } + + // An explicit selection commits exactly those models and must not query upstream. + synced := perform(router, http.MethodPost, "/api/channels/"+payload.Channel.ID+"/sync-models", `{"models":["provider-model-a","provider-model-c"]}`, nil) + if synced.Code != http.StatusOK { + t.Fatalf("sync models status = %d body = %s", synced.Code, synced.Body.String()) + } + if upstreamHits != 0 { + t.Fatalf("explicit selection should not query upstream, hits = %d", upstreamHits) + } + + channels := perform(router, http.MethodGet, "/api/channels", "", nil) + if !bytes.Contains(channels.Body.Bytes(), []byte(`provider-model-a`)) || !bytes.Contains(channels.Body.Bytes(), []byte(`provider-model-c`)) { + t.Fatalf("channel missing selected models: %s", channels.Body.String()) + } + if bytes.Contains(channels.Body.Bytes(), []byte(`provider-model-b`)) { + t.Fatalf("channel should not contain unselected model: %s", channels.Body.String()) + } + if bytes.Contains(channels.Body.Bytes(), []byte(`stale-provider-model`)) { + t.Fatalf("stale model should be replaced by selection: %s", channels.Body.String()) + } + + models := perform(router, http.MethodGet, "/api/models", "", nil) + if !bytes.Contains(models.Body.Bytes(), []byte(`"id":"provider-model-a"`)) { + t.Fatalf("selected model was not created in catalog: %s", models.Body.String()) + } + if bytes.Contains(models.Body.Bytes(), []byte(`"id":"provider-model-b"`)) { + t.Fatalf("unselected model should not be created in catalog: %s", models.Body.String()) + } +} + func TestSyncChannelModelsRetriesWithAnthropicAuth(t *testing.T) { requests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -4228,6 +4324,111 @@ func TestDiscordOAuthRoleGateCreatesSessionForAdminRoutes(t *testing.T) { } } +func TestDiscordBlockedGuildDeniesLogin(t *testing.T) { + blockedGuildID := "999000111000111000" + discord := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"discord-access","token_type":"Bearer","expires_in":3600,"scope":"identify guilds.members.read guilds"}`)) + case "/api/v10/users/@me": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"dc_user_blocked","username":"capi","global_name":"CAPI"}`)) + case "/api/v10/users/@me/guilds": + if r.Header.Get("Authorization") != "Bearer discord-access" { + t.Fatalf("guild list auth = %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"111","name":"Fine"},{"id":"` + blockedGuildID + `","name":"Blocked"}]`)) + default: + t.Fatalf("unexpected discord path: %s", r.URL.Path) + } + })) + defer discord.Close() + + withEnv(t, map[string]string{ + "PERSISTENCE": "memory", + "ADMIN_TOKEN": "admin-secret", + "DISCORD_CLIENT_ID": "client-id", + "DISCORD_CLIENT_SECRET": "client-secret", + "DISCORD_REDIRECT_URI": "http://localhost:8787/api/auth/discord/callback", + "DISCORD_BLOCKED_GUILD_IDS": blockedGuildID, + "DISCORD_OAUTH_BASE": discord.URL + "/oauth2", + "DISCORD_API_BASE": discord.URL + "/api/v10", + "AUTH_SUCCESS_URL": "http://localhost:5173/", + }) + router := testRouter(t) + + start := perform(router, http.MethodGet, "/api/auth/discord/start", "", nil) + if start.Code != http.StatusFound { + t.Fatalf("discord start status = %d body = %s", start.Code, start.Body.String()) + } + authURL, err := url.Parse(start.Header().Get("Location")) + if err != nil { + t.Fatalf("parse auth URL: %v", err) + } + if !strings.Contains(authURL.Query().Get("scope"), "guilds.members.read guilds") { + t.Fatalf("blocked-guild config should append the guilds scope: %s", authURL.Query().Get("scope")) + } + state := authURL.Query().Get("state") + + callback := perform(router, http.MethodGet, "/api/auth/discord/callback?code=oauth-code&state="+url.QueryEscape(state), "", nil) + if callback.Code != http.StatusForbidden { + t.Fatalf("blocked guild member should be denied, status = %d body = %s", callback.Code, callback.Body.String()) + } + if len(callback.Result().Cookies()) != 0 { + t.Fatal("blocked guild member should not receive a session cookie") + } +} + +func TestDiscordBlockedGuildAllowsNonMember(t *testing.T) { + blockedGuildID := "999000111000111000" + discord := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth2/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"discord-access","token_type":"Bearer","expires_in":3600}`)) + case "/api/v10/users/@me": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"dc_user_ok","username":"capi","global_name":"CAPI"}`)) + case "/api/v10/users/@me/guilds": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"111","name":"Fine"},{"id":"222","name":"Also Fine"}]`)) + default: + t.Fatalf("unexpected discord path: %s", r.URL.Path) + } + })) + defer discord.Close() + + withEnv(t, map[string]string{ + "PERSISTENCE": "memory", + "ADMIN_TOKEN": "admin-secret", + "DISCORD_CLIENT_ID": "client-id", + "DISCORD_CLIENT_SECRET": "client-secret", + "DISCORD_REDIRECT_URI": "http://localhost:8787/api/auth/discord/callback", + "DISCORD_BLOCKED_GUILD_IDS": blockedGuildID, + "DISCORD_OAUTH_BASE": discord.URL + "/oauth2", + "DISCORD_API_BASE": discord.URL + "/api/v10", + "AUTH_SUCCESS_URL": "http://localhost:5173/", + }) + router := testRouter(t) + + start := perform(router, http.MethodGet, "/api/auth/discord/start", "", nil) + authURL, err := url.Parse(start.Header().Get("Location")) + if err != nil { + t.Fatalf("parse auth URL: %v", err) + } + state := authURL.Query().Get("state") + + callback := perform(router, http.MethodGet, "/api/auth/discord/callback?code=oauth-code&state="+url.QueryEscape(state), "", nil) + if callback.Code != http.StatusFound { + t.Fatalf("non-member should be allowed, status = %d body = %s", callback.Code, callback.Body.String()) + } + if len(callback.Result().Cookies()) == 0 { + t.Fatal("non-member should receive a session cookie") + } +} + func TestBoundDiscordIDRestoresLocalAdminAccount(t *testing.T) { discordUserID := "100000000000000001" discord := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/src/App.tsx b/src/App.tsx index bede509..a7f3435 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -240,6 +240,7 @@ type DiscordSettings = { redirectUri: string; allowedGuildId: string; allowedRoleId: string; + blockedGuildIds: string[]; authSuccessUrl: string; sessionTtlHours: number; }; @@ -772,6 +773,7 @@ function withBrowserDiscordDefaults(settings: DiscordSettings): DiscordSettings ...settings, redirectUri: settings.redirectUri && !settings.redirectUri.includes("localhost") ? settings.redirectUri : defaultDiscordRedirectUri(), authSuccessUrl: settings.authSuccessUrl && !settings.authSuccessUrl.includes("localhost") ? settings.authSuccessUrl : defaultAuthSuccessUrl(), + blockedGuildIds: arrayOf(settings.blockedGuildIds), sessionTtlHours: settings.sessionTtlHours || 168 }; } @@ -937,10 +939,11 @@ function App() { window.setTimeout(() => setToast(""), 1800); } - async function syncChannelModels(id: string) { + async function syncChannelModels(id: string, models?: string[]) { + const explicit = arrayOf(models).map((model) => model.trim()).filter(Boolean); const data = await fetchJson(`/api/channels/${id}/sync-models`, { method: "POST", - body: JSON.stringify({}) + body: JSON.stringify(explicit.length ? { models: explicit } : {}) }); const syncedChannel = normalizeChannel(data.channel); const addedModels = arrayOf(data.addedModels).map(normalizeModel); @@ -953,7 +956,7 @@ function App() { }); } removeModelsFromCatalog(data.removedModels); - setToast(syncedModels.length ? `已拉取 ${syncedModels.length} 个模型` : "上游没有返回模型"); + setToast(explicit.length ? `已保存 ${syncedModels.length} 个模型` : syncedModels.length ? `已拉取 ${syncedModels.length} 个模型` : "上游没有返回模型"); window.setTimeout(() => setToast(""), 2200); } @@ -1677,8 +1680,12 @@ function AccountHome({ {message &&

{message}

}
{data?.apiKeys?.map((key) => ( -
- {key.name}{key.prefix}... +
+ +
+ {key.name} + {key.prefix}… +
{statusLabel(key.status)}
))} @@ -3252,7 +3259,7 @@ function ChannelsView({ onCreate: (channel: ChannelCreate) => Promise; onImport: (channelId: string, file: File) => Promise; onDelete: (id: string) => void; - onSyncModels: (id: string) => Promise; + onSyncModels: (id: string, models?: string[]) => Promise; onCheck: (id: string) => Promise; }) { const initialTemplate = channelTemplateFor("openai"); @@ -3385,7 +3392,7 @@ function ChannelEditor({ onUpdate: (id: string, patch: ChannelPatch) => Promise; onImport: (channelId: string, file: File) => Promise; onDelete: (id: string) => void; - onSyncModels: (id: string) => Promise; + onSyncModels: (id: string, models?: string[]) => Promise; onCheck: (id: string) => Promise; }) { const [name, setName] = useState(channel.name); @@ -3399,6 +3406,7 @@ function ChannelEditor({ const [webEndpoint, setWebEndpoint] = useState(Boolean(channel.webEndpoint)); const [upstreamApiKey, setUpstreamApiKey] = useState(""); const [busy, setBusy] = useState(""); + const [pickerOpen, setPickerOpen] = useState(false); const accountCount = channel.openaiAccountCount ?? channel.openaiAccounts?.length ?? 0; const modelCount = arrayOf(channel.models).length; const capabilities = channelCapabilities(channel); @@ -3474,12 +3482,13 @@ function ChannelEditor({ } } - async function syncModels() { + async function openModelPicker() { setBusy("sync"); try { - await save(); - await onSyncModels(channel.id); - setModelSource("synced"); + // Persist any edits (Base URL / key) so the preview queries the live upstream. + await onUpdate(channel.id, currentChannelPatch()); + setUpstreamApiKey(""); + setPickerOpen(true); } finally { setBusy(""); } @@ -3514,6 +3523,7 @@ function ChannelEditor({ } return ( + <>
@@ -3631,7 +3641,7 @@ function ChannelEditor({ -
@@ -3675,6 +3685,174 @@ function ChannelEditor({
+ {pickerOpen && ( + model.trim()).filter(Boolean)} + onConfirm={async (selectedModels) => { await onSyncModels(channel.id, selectedModels); }} + onClose={() => setPickerOpen(false)} + /> + )} + + ); +} + +function ModelPickerModal({ + channelId, + channelName, + current, + onConfirm, + onClose +}: { + channelId: string; + channelName: string; + current: string[]; + onConfirm: (models: string[]) => Promise; + onClose: () => void; +}) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [upstream, setUpstream] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [query, setQuery] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(""); + try { + const data = await fetchJson<{ models?: string[] }>(`/api/channels/${channelId}/upstream-models`, { + method: "POST", + body: JSON.stringify({}) + }); + if (cancelled) return; + const seen = new Set(); + const unique = arrayOf(data.models).map((model) => model.trim()).filter((model) => { + if (!model) return false; + const key = model.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const currentLower = new Set(current.map((model) => model.toLowerCase())); + setUpstream(unique); + setSelected(new Set(unique.filter((model) => currentLower.has(model.toLowerCase())))); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : "获取上游模型失败"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + // Fetch once per open; `current` is only used to seed the initial checkboxes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channelId]); + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = normalizedQuery ? upstream.filter((model) => model.toLowerCase().includes(normalizedQuery)) : upstream; + const allVisibleSelected = filtered.length > 0 && filtered.every((model) => selected.has(model)); + + function toggle(model: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(model)) next.delete(model); + else next.add(model); + return next; + }); + } + + function toggleAllVisible() { + setSelected((prev) => { + const next = new Set(prev); + if (allVisibleSelected) filtered.forEach((model) => next.delete(model)); + else filtered.forEach((model) => next.add(model)); + return next; + }); + } + + async function confirm() { + // Final list = the picked upstream models plus any existing custom models the + // upstream does not expose, so manually added entries are never dropped. + const upstreamLower = new Set(upstream.map((model) => model.toLowerCase())); + const preserved = current.filter((model) => !upstreamLower.has(model.toLowerCase())); + const picked = upstream.filter((model) => selected.has(model)); + const seen = new Set(); + const finalList = [...preserved, ...picked].filter((model) => { + const key = model.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + setSaving(true); + try { + await onConfirm(finalList); + onClose(); + } catch { + // onConfirm surfaces its own toast on failure; keep the picker open to retry. + setSaving(false); + } + } + + return ( +
+
event.stopPropagation()}> +
+
+ 选择上游模型 + {channelName} · 勾选需要接入的模型 +
+ +
+ {loading ? ( +
正在获取上游模型…
+ ) : error ? ( +
{error}
+ ) : ( + <> +
+ setQuery(event.target.value)} + placeholder="搜索模型名称" + autoFocus + /> + +
+
+ 共 {upstream.length} 个 · 已选 {selected.size} 个{normalizedQuery ? ` · 匹配 ${filtered.length} 个` : ""} +
+
+ {filtered.map((model) => { + const checked = selected.has(model); + const already = current.some((item) => item.toLowerCase() === model.toLowerCase()); + return ( + + ); + })} + {filtered.length === 0 &&
没有匹配的模型
} +
+ + )} +
+ + +
+
+
); } @@ -3895,6 +4073,7 @@ function SettingsView({ models, channels }: { models: ModelItem[]; channels: Cha const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [clientSecret, setClientSecret] = useState(""); + const [blockedGuildText, setBlockedGuildText] = useState(""); const [message, setMessage] = useState(""); const [saving, setSaving] = useState(false); const [settingsTab, setSettingsTab] = useState("system"); @@ -3923,6 +4102,7 @@ function SettingsView({ models, channels }: { models: ModelItem[]; channels: Cha ]) .then(([discordData, authData, checkInData, accountData, maintenanceData, healthData]) => { setDiscord(withBrowserDiscordDefaults(discordData.discord)); + setBlockedGuildText(arrayOf(discordData.discord.blockedGuildIds).join("\n")); setRegistrationEnabled(authData.auth.registrationEnabled); setRegistrationMode(normalizeRegistrationMode(authData.auth.registrationMode)); setDefaultBalance(String(authData.auth.defaultBalance || 0)); @@ -3982,11 +4162,13 @@ function SettingsView({ models, channels }: { models: ModelItem[]; channels: Cha setMessage(""); try { const nextDiscord = withBrowserDiscordDefaults(discord); + const blockedGuildIds = blockedGuildText.split(/[\s,]+/).map((id) => id.trim()).filter(Boolean); const data = await fetchJson<{ discord: DiscordSettings }>("/api/settings/discord", { method: "PATCH", - body: JSON.stringify({ ...nextDiscord, clientSecret }) + body: JSON.stringify({ ...nextDiscord, blockedGuildIds, clientSecret }) }); setDiscord(withBrowserDiscordDefaults(data.discord)); + setBlockedGuildText(arrayOf(data.discord.blockedGuildIds).join("\n")); setClientSecret(""); setMessage("Discord 配置已保存"); } catch (error) { @@ -4539,6 +4721,16 @@ response = client.chat.completions.create( placeholder="允许登录的身份组 ID" /> +