Compare commits

..

3 Commits

Author SHA1 Message Date
Calcium-Ion
752dc11dd4 Merge pull request #1813 from QuantumNous/fix1797
fix: openai responses api 未统计图像生成调用计费
2025-09-16 14:07:14 +08:00
creamlike1024
17be7c3b45 fix: imageGenerationCall involves billing 2025-09-16 13:02:15 +08:00
creamlike1024
11cf70e60d fix: openai responses api 未统计图像生成调用计费 2025-09-16 12:47:59 +08:00
52 changed files with 238 additions and 7304 deletions

View File

@@ -1,375 +0,0 @@
package controller
import (
"encoding/json"
"net/http"
"one-api/model"
"one-api/setting/system_setting"
"one-api/src/oauth"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/golang-jwt/jwt/v5"
"one-api/middleware"
"strconv"
"strings"
)
// GetJWKS 获取JWKS公钥集
func GetJWKS(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// lazy init if needed
_ = oauth.EnsureInitialized()
jwks := oauth.GetJWKS()
if jwks == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "JWKS not available",
})
return
}
// 设置CORS headers
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET")
c.Header("Access-Control-Allow-Headers", "Content-Type")
c.Header("Cache-Control", "public, max-age=3600") // 缓存1小时
// 返回JWKS
c.Header("Content-Type", "application/json")
// 将JWKS转换为JSON字符串
jsonData, err := json.Marshal(jwks)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to marshal JWKS",
})
return
}
c.String(http.StatusOK, string(jsonData))
}
// OAuthTokenEndpoint OAuth2 令牌端点
func OAuthTokenEndpoint(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "unsupported_grant_type",
"error_description": "OAuth2 server is disabled",
})
return
}
// 只允许POST请求
if c.Request.Method != "POST" {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"error": "invalid_request",
"error_description": "Only POST method is allowed",
})
return
}
// 只允许application/x-www-form-urlencoded内容类型
contentType := c.GetHeader("Content-Type")
if contentType == "" || !strings.Contains(strings.ToLower(contentType), "application/x-www-form-urlencoded") {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded",
})
return
}
// lazy init
if err := oauth.EnsureInitialized(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
return
}
oauth.HandleTokenRequest(c)
}
// OAuthAuthorizeEndpoint OAuth2 授权端点
func OAuthAuthorizeEndpoint(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "server_error",
"error_description": "OAuth2 server is disabled",
})
return
}
if err := oauth.EnsureInitialized(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error", "error_description": err.Error()})
return
}
oauth.HandleAuthorizeRequest(c)
}
// OAuthServerInfo 获取OAuth2服务器信息
func OAuthServerInfo(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// 返回OAuth2服务器的基本信息类似OpenID Connect Discovery
issuer := settings.Issuer
if issuer == "" {
scheme := "https"
if c.Request.TLS == nil {
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
scheme = hdr
} else {
scheme = "http"
}
}
issuer = scheme + "://" + c.Request.Host
}
base := issuer + "/api"
c.JSON(http.StatusOK, gin.H{
"issuer": issuer,
"authorization_endpoint": base + "/oauth/authorize",
"token_endpoint": base + "/oauth/token",
"jwks_uri": base + "/.well-known/jwks.json",
"grant_types_supported": settings.AllowedGrantTypes,
"response_types_supported": []string{"code", "token"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"code_challenge_methods_supported": []string{"S256"},
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
"default_private_key_path": settings.DefaultPrivateKeyPath,
})
}
// OAuthOIDCConfiguration OIDC discovery document
func OAuthOIDCConfiguration(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
return
}
issuer := settings.Issuer
if issuer == "" {
scheme := "https"
if c.Request.TLS == nil {
if hdr := c.Request.Header.Get("X-Forwarded-Proto"); hdr != "" {
scheme = hdr
} else {
scheme = "http"
}
}
issuer = scheme + "://" + c.Request.Host
}
base := issuer + "/api"
c.JSON(http.StatusOK, gin.H{
"issuer": issuer,
"authorization_endpoint": base + "/oauth/authorize",
"token_endpoint": base + "/oauth/token",
"userinfo_endpoint": base + "/oauth/userinfo",
"jwks_uri": base + "/.well-known/jwks.json",
"response_types_supported": []string{"code", "token"},
"grant_types_supported": settings.AllowedGrantTypes,
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"scopes_supported": []string{"openid", "profile", "email", "api:read", "api:write", "admin"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"code_challenge_methods_supported": []string{"S256"},
"default_private_key_path": settings.DefaultPrivateKeyPath,
})
}
// OAuthIntrospect 令牌内省端点RFC 7662
func OAuthIntrospect(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// 只允许POST请求
if c.Request.Method != "POST" {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"error": "invalid_request",
"error_description": "Only POST method is allowed",
})
return
}
token := c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"active": false,
})
return
}
tokenString := token
// 验证并解析JWT
parsed, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
pub := oauth.GetPublicKeyByKid(func() string {
if v, ok := token.Header["kid"].(string); ok {
return v
}
return ""
}())
if pub == nil {
return nil, jwt.ErrTokenUnverifiable
}
return pub, nil
})
if err != nil || !parsed.Valid {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
// 检查撤销
if jti, ok := claims["jti"].(string); ok && jti != "" {
if revoked, _ := model.IsTokenRevoked(jti); revoked {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
}
// 有效
resp := gin.H{"active": true}
for k, v := range claims {
resp[k] = v
}
resp["token_type"] = "Bearer"
c.JSON(http.StatusOK, resp)
}
// OAuthRevoke 令牌撤销端点RFC 7009
func OAuthRevoke(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{
"error": "OAuth2 server is disabled",
})
return
}
// 只允许POST请求
if c.Request.Method != "POST" {
c.JSON(http.StatusMethodNotAllowed, gin.H{
"error": "invalid_request",
"error_description": "Only POST method is allowed",
})
return
}
token := c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing token parameter",
})
return
}
token = c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing token parameter",
})
return
}
// 尝试解析JWT若成功则记录jti到撤销表
parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
pub := oauth.GetRSAPublicKey()
if pub == nil {
return nil, jwt.ErrTokenUnverifiable
}
return pub, nil
})
if err == nil && parsed != nil && parsed.Valid {
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
var jti string
var exp int64
if v, ok := claims["jti"].(string); ok {
jti = v
}
if v, ok := claims["exp"].(float64); ok {
exp = int64(v)
} else if v, ok := claims["exp"].(int64); ok {
exp = v
}
if jti != "" {
// 如果没有exp默认撤销至当前+TTL 10分钟
if exp == 0 {
exp = time.Now().Add(10 * time.Minute).Unix()
}
_ = model.RevokeToken(jti, exp)
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// OAuthUserInfo returns OIDC userinfo based on access token
func OAuthUserInfo(c *gin.Context) {
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "OAuth2 server is disabled"})
return
}
// 需要 OAuthJWTAuth 中间件注入 claims
claims, ok := middleware.GetOAuthClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
// scope 校验:必须包含 openid
scope, _ := claims["scope"].(string)
if !strings.Contains(" "+scope+" ", " openid ") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient_scope"})
return
}
sub, _ := claims["sub"].(string)
resp := gin.H{"sub": sub}
// 若包含 profile/email scope补充返回
if strings.Contains(" "+scope+" ", " profile ") || strings.Contains(" "+scope+" ", " email ") {
if uid, err := strconv.Atoi(sub); err == nil {
if user, err2 := model.GetUserById(uid, false); err2 == nil && user != nil {
if strings.Contains(" "+scope+" ", " profile ") {
resp["name"] = user.DisplayName
resp["preferred_username"] = user.Username
}
if strings.Contains(" "+scope+" ", " email ") {
resp["email"] = user.Email
resp["email_verified"] = true
}
}
}
}
c.JSON(http.StatusOK, resp)
}

View File

@@ -1,374 +0,0 @@
package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
)
// CreateOAuthClientRequest 创建OAuth客户端请求
type CreateOAuthClientRequest struct {
Name string `json:"name" binding:"required"`
ClientType string `json:"client_type" binding:"required,oneof=confidential public"`
GrantTypes []string `json:"grant_types" binding:"required"`
RedirectURIs []string `json:"redirect_uris"`
Scopes []string `json:"scopes" binding:"required"`
Description string `json:"description"`
RequirePKCE bool `json:"require_pkce"`
}
// UpdateOAuthClientRequest 更新OAuth客户端请求
type UpdateOAuthClientRequest struct {
ID string `json:"id" binding:"required"`
Name string `json:"name" binding:"required"`
ClientType string `json:"client_type" binding:"required,oneof=confidential public"`
GrantTypes []string `json:"grant_types" binding:"required"`
RedirectURIs []string `json:"redirect_uris"`
Scopes []string `json:"scopes" binding:"required"`
Description string `json:"description"`
RequirePKCE bool `json:"require_pkce"`
Status int `json:"status" binding:"required,oneof=1 2"`
}
// GetAllOAuthClients 获取所有OAuth客户端
func GetAllOAuthClients(c *gin.Context) {
page, _ := strconv.Atoi(c.Query("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(c.Query("per_page"))
if perPage < 1 || perPage > 100 {
perPage = 20
}
startIdx := (page - 1) * perPage
clients, err := model.GetAllOAuthClients(startIdx, perPage)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// 清理敏感信息
for _, client := range clients {
client.Secret = maskSecret(client.Secret)
}
total, _ := model.CountOAuthClients()
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": clients,
"total": total,
"page": page,
"per_page": perPage,
})
}
// SearchOAuthClients 搜索OAuth客户端
func SearchOAuthClients(c *gin.Context) {
keyword := c.Query("keyword")
if keyword == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "关键词不能为空",
})
return
}
clients, err := model.SearchOAuthClients(keyword)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
// 清理敏感信息
for _, client := range clients {
client.Secret = maskSecret(client.Secret)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": clients,
})
}
// GetOAuthClient 获取单个OAuth客户端
func GetOAuthClient(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "ID不能为空",
})
return
}
client, err := model.GetOAuthClientByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "客户端不存在",
})
return
}
// 清理敏感信息
client.Secret = maskSecret(client.Secret)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": client,
})
}
// CreateOAuthClient 创建OAuth客户端
func CreateOAuthClient(c *gin.Context) {
var req CreateOAuthClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
})
return
}
// 验证授权类型
validGrantTypes := []string{"client_credentials", "authorization_code", "refresh_token"}
for _, grantType := range req.GrantTypes {
if !contains(validGrantTypes, grantType) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的授权类型: " + grantType,
})
return
}
}
// 如果包含authorization_code则必须提供redirect_uris
if contains(req.GrantTypes, "authorization_code") && len(req.RedirectURIs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "授权码模式需要提供重定向URI",
})
return
}
// 生成客户端ID和密钥
clientID := generateClientID()
clientSecret := ""
if req.ClientType == "confidential" {
clientSecret = generateClientSecret()
}
// 获取创建者ID
createdBy := c.GetInt("id")
// 创建客户端
client := &model.OAuthClient{
ID: clientID,
Secret: clientSecret,
Name: req.Name,
ClientType: req.ClientType,
RequirePKCE: req.RequirePKCE,
Status: common.UserStatusEnabled,
CreatedBy: createdBy,
Description: req.Description,
}
client.SetGrantTypes(req.GrantTypes)
client.SetRedirectURIs(req.RedirectURIs)
client.SetScopes(req.Scopes)
err := model.CreateOAuthClient(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "创建客户端失败: " + err.Error(),
})
return
}
// 返回结果(包含完整的客户端密钥,仅此一次)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "客户端创建成功",
"client_id": client.ID,
"client_secret": client.Secret, // 仅在创建时返回完整密钥
"data": client,
})
}
// UpdateOAuthClient 更新OAuth客户端
func UpdateOAuthClient(c *gin.Context) {
var req UpdateOAuthClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
})
return
}
// 获取现有客户端
client, err := model.GetOAuthClientByID(req.ID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "客户端不存在",
})
return
}
// 验证授权类型
validGrantTypes := []string{"client_credentials", "authorization_code", "refresh_token"}
for _, grantType := range req.GrantTypes {
if !contains(validGrantTypes, grantType) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的授权类型: " + grantType,
})
return
}
}
// 更新客户端信息
client.Name = req.Name
client.ClientType = req.ClientType
client.RequirePKCE = req.RequirePKCE
client.Status = req.Status
client.Description = req.Description
client.SetGrantTypes(req.GrantTypes)
client.SetRedirectURIs(req.RedirectURIs)
client.SetScopes(req.Scopes)
err = model.UpdateOAuthClient(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "更新客户端失败: " + err.Error(),
})
return
}
// 清理敏感信息
client.Secret = maskSecret(client.Secret)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "客户端更新成功",
"data": client,
})
}
// DeleteOAuthClient 删除OAuth客户端
func DeleteOAuthClient(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "ID不能为空",
})
return
}
err := model.DeleteOAuthClient(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "删除客户端失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "客户端删除成功",
})
}
// RegenerateOAuthClientSecret 重新生成客户端密钥
func RegenerateOAuthClientSecret(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "ID不能为空",
})
return
}
client, err := model.GetOAuthClientByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "客户端不存在",
})
return
}
// 只有机密客户端才能重新生成密钥
if client.ClientType != "confidential" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "只有机密客户端才能重新生成密钥",
})
return
}
// 生成新密钥
client.Secret = generateClientSecret()
err = model.UpdateOAuthClient(client)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "重新生成密钥失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "客户端密钥重新生成成功",
"client_secret": client.Secret, // 返回新生成的密钥
})
}
// generateClientID 生成客户端ID
func generateClientID() string {
return "client_" + randstr.String(16)
}
// generateClientSecret 生成客户端密钥
func generateClientSecret() string {
return randstr.String(32)
}
// maskSecret 掩码密钥显示
func maskSecret(secret string) string {
if len(secret) <= 6 {
return strings.Repeat("*", len(secret))
}
return secret[:3] + strings.Repeat("*", len(secret)-6) + secret[len(secret)-3:]
}
// contains 检查字符串切片是否包含指定值
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}

View File

@@ -1,89 +0,0 @@
package controller
import (
"github.com/gin-gonic/gin"
"net/http"
"one-api/logger"
"one-api/src/oauth"
)
type rotateKeyRequest struct {
Kid string `json:"kid"`
}
type genKeyFileRequest struct {
Path string `json:"path"`
Kid string `json:"kid"`
Overwrite bool `json:"overwrite"`
}
type importPemRequest struct {
Pem string `json:"pem"`
Kid string `json:"kid"`
}
// RotateOAuthSigningKey rotates the OAuth2 JWT signing key (Root only)
func RotateOAuthSigningKey(c *gin.Context) {
var req rotateKeyRequest
_ = c.BindJSON(&req)
kid, err := oauth.RotateSigningKey(req.Kid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key rotated: "+kid)
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
}
// ListOAuthSigningKeys returns current and historical JWKS signing keys
func ListOAuthSigningKeys(c *gin.Context) {
keys := oauth.ListSigningKeys()
c.JSON(http.StatusOK, gin.H{"success": true, "data": keys})
}
// DeleteOAuthSigningKey deletes a non-current key by kid
func DeleteOAuthSigningKey(c *gin.Context) {
kid := c.Param("kid")
if kid == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "kid required"})
return
}
if err := oauth.DeleteSigningKey(kid); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key deleted: "+kid)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GenerateOAuthSigningKeyFile generates a private key file and rotates current kid
func GenerateOAuthSigningKeyFile(c *gin.Context) {
var req genKeyFileRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Path == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "path required"})
return
}
kid, err := oauth.GenerateAndPersistKey(req.Path, req.Kid, req.Overwrite)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key generated to file: "+req.Path+" kid="+kid)
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid, "path": req.Path})
}
// ImportOAuthSigningKey imports PEM text and rotates current kid
func ImportOAuthSigningKey(c *gin.Context) {
var req importPemRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Pem == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "pem required"})
return
}
kid, err := oauth.ImportPEMKey(req.Pem, req.Kid)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
logger.LogInfo(c, "oauth signing key imported from PEM, kid="+kid)
c.JSON(http.StatusOK, gin.H{"success": true, "kid": kid})
}

View File

@@ -6,6 +6,10 @@ import (
"one-api/types"
)
const (
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
)
type SimpleResponse struct {
Usage `json:"usage"`
Error any `json:"error"`
@@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error)
}
func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
if len(o.Output) == 0 {
return false
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return true
}
}
return false
}
func (o *OpenAIResponsesResponse) GetQuality() string {
if len(o.Output) == 0 {
return ""
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return output.Quality
}
}
return ""
}
func (o *OpenAIResponsesResponse) GetSize() string {
if len(o.Output) == 0 {
return ""
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return output.Size
}
}
return ""
}
type IncompleteDetails struct {
Reasoning string `json:"reasoning"`
}
@@ -283,6 +323,8 @@ type ResponsesOutput struct {
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
}
type ResponsesOutputContent struct {

View File

@@ -1,326 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
<style>
:root { --bg:#0b0c10; --panel:#111317; --muted:#aab2bf; --accent:#3b82f6; --ok:#16a34a; --warn:#f59e0b; --err:#ef4444; --border:#1f2430; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: var(--bg); color:#e5e7eb; }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
h1 { font-size: 22px; margin:0 0 16px; }
.card { background: var(--panel); border:1px solid var(--border); border-radius: 10px; padding: 16px; margin: 12px 0; }
.row { display:flex; gap:12px; flex-wrap:wrap; }
.col { flex: 1 1 280px; display:flex; flex-direction:column; }
label { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
input, textarea, select { background:#0f1115; color:#e5e7eb; border:1px solid var(--border); padding:10px 12px; border-radius:8px; outline:none; }
textarea { min-height: 100px; resize: vertical; }
.btns { display:flex; gap:8px; flex-wrap:wrap; margin-top: 8px; }
button { background:#1a1f2b; color:#e5e7eb; border:1px solid var(--border); padding:8px 12px; border-radius:8px; cursor:pointer; }
button.primary { background: var(--accent); border-color: var(--accent); color:white; }
button.ok { background: var(--ok); border-color: var(--ok); color:white; }
button.warn { background: var(--warn); border-color: var(--warn); color:black; }
button.ghost { background: transparent; }
.muted { color: var(--muted); font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 880px){ .grid2 { grid-template-columns: 1fr; } }
.pill { padding: 3px 8px; border-radius:999px; font-size: 12px; border:1px solid var(--border); background:#0f1115; }
.ok { color: #10b981; }
.err { color: #ef4444; }
.sep { height:1px; background: var(--border); margin: 12px 0; }
</style>
</head>
<body>
<div class="wrap">
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
<div class="card">
<div class="row">
<div class="col">
<label>Issuer可选用于自动发现 /.well-known/openid-configuration</label>
<input id="issuer" placeholder="https://your-domain" />
<div class="btns"><button class="" id="btnDiscover">自动发现端点</button></div>
<div class="muted">提示:若未配置 Issuer可直接填写下方端点。</div>
</div>
</div>
<div class="row">
<div class="col"><label>Authorization Endpoint</label><input id="authorization_endpoint" placeholder="https://domain/api/oauth/authorize" /></div>
<div class="col"><label>Token Endpoint</label><input id="token_endpoint" placeholder="https://domain/api/oauth/token" /></div>
</div>
<div class="row">
<div class="col"><label>UserInfo Endpoint可选</label><input id="userinfo_endpoint" placeholder="https://domain/api/oauth/userinfo" /></div>
<div class="col"><label>Client ID</label><input id="client_id" placeholder="your-public-client-id" /></div>
</div>
<div class="row">
<div class="col"><label>Redirect URI当前页地址或你的回调</label><input id="redirect_uri" /></div>
<div class="col"><label>Scope</label><input id="scope" value="openid profile email" /></div>
</div>
<div class="row">
<div class="col"><label>State</label><input id="state" /></div>
<div class="col"><label>Nonce</label><input id="nonce" /></div>
</div>
<div class="row">
<div class="col"><label>Code Verifier自动生成不会上送</label><input id="code_verifier" class="mono" readonly /></div>
<div class="col"><label>Code ChallengeS256</label><input id="code_challenge" class="mono" readonly /></div>
</div>
<div class="btns">
<button id="btnGenPkce">生成 PKCE</button>
<button id="btnRandomState">随机 State</button>
<button id="btnRandomNonce">随机 Nonce</button>
<button id="btnMakeAuthURL">生成授权链接</button>
<button id="btnAuthorize" class="primary">跳转授权</button>
</div>
<div class="row" style="margin-top:8px;">
<div class="col">
<label>授权链接(只生成不跳转)</label>
<textarea id="authorize_url" class="mono" placeholder="(空)"></textarea>
<div class="btns"><button id="btnCopyAuthURL">复制链接</button></div>
</div>
</div>
<div class="sep"></div>
<div class="muted">说明:
<ul>
<li>本页为纯前端演示,适用于公开客户端(不需要 client_secret</li>
<li>如跨域调用 Token/UserInfo需要服务端正确设置 CORS建议将此 demo 部署到同源域名下。</li>
</ul>
</div>
<div class="sep"></div>
<div class="row">
<div class="col">
<label>粘贴 OIDC Discovery JSON/.well-known/openid-configuration</label>
<textarea id="conf_json" class="mono" placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'></textarea>
<div class="btns">
<button id="btnParseConf">解析并填充端点</button>
<button id="btnGenConf">用当前端点生成 JSON</button>
</div>
<div class="muted">可将服务端返回的 OIDC Discovery JSON 粘贴到此处,点击“解析并填充端点”。</div>
</div>
</div>
</div>
<div class="card">
<div class="row">
<div class="col">
<label>授权结果</label>
<div id="authResult" class="muted">等待授权...</div>
</div>
</div>
<div class="grid2" style="margin-top:12px;">
<div>
<label>Access Token</label>
<textarea id="access_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnCopyAT">复制</button>
<button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
</div>
<div id="userinfoOut" class="muted" style="margin-top:6px;"></div>
</div>
<div>
<label>ID TokenJWT</label>
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnDecodeJWT">解码显示 Claims</button>
</div>
<pre id="jwtClaims" class="mono" style="white-space:pre-wrap; word-break:break-all; margin-top:6px;"></pre>
</div>
</div>
<div class="grid2" style="margin-top:12px;">
<div>
<label>Refresh Token</label>
<textarea id="refresh_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnRefreshToken">使用 Refresh Token 刷新</button>
</div>
</div>
<div>
<label>原始 Token 响应</label>
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const toB64Url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function sha256B64Url(str){
const data = new TextEncoder().encode(str);
const digest = await crypto.subtle.digest('SHA-256', data);
return toB64Url(digest);
}
function randStr(len=64){
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const arr = new Uint8Array(len); crypto.getRandomValues(arr);
return Array.from(arr, v => chars[v % chars.length]).join('');
}
function setAuthInfo(msg, ok=true){
const el = $('authResult');
el.textContent = msg;
el.className = ok ? 'ok' : 'err';
}
function qs(name){ const u=new URL(location.href); return u.searchParams.get(name); }
function persist(name, val){ sessionStorage.setItem('demo_'+name, val); }
function load(name){ return sessionStorage.getItem('demo_'+name) || ''; }
// init defaults
(function init(){
$('redirect_uri').value = window.location.origin + window.location.pathname;
// try load from discovery if issuer saved previously
const iss = load('issuer'); if(iss) $('issuer').value = iss;
const cid = load('client_id'); if(cid) $('client_id').value = cid;
const scp = load('scope'); if(scp) $('scope').value = scp;
})();
$('btnDiscover').onclick = async () => {
const iss = $('issuer').value.trim(); if(!iss){ alert('请填写 Issuer'); return; }
try{
persist('issuer', iss);
const res = await fetch(iss.replace(/\/$/,'') + '/api/.well-known/openid-configuration');
const d = await res.json();
$('authorization_endpoint').value = d.authorization_endpoint || '';
$('token_endpoint').value = d.token_endpoint || '';
$('userinfo_endpoint').value = d.userinfo_endpoint || '';
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
$('conf_json').value = JSON.stringify(d, null, 2);
setAuthInfo('已从发现文档加载端点', true);
}catch(e){ setAuthInfo('自动发现失败:'+e, false); }
};
$('btnGenPkce').onclick = async () => {
const v = randStr(64); const c = await sha256B64Url(v);
$('code_verifier').value = v; $('code_challenge').value = c;
persist('code_verifier', v); persist('code_challenge', c);
setAuthInfo('已生成 PKCE 参数', true);
};
$('btnRandomState').onclick = () => { $('state').value = randStr(16); persist('state', $('state').value); };
$('btnRandomNonce').onclick = () => { $('nonce').value = randStr(16); persist('nonce', $('nonce').value); };
function buildAuthorizeURLFromFields() {
const auth = $('authorization_endpoint').value.trim();
const token = $('token_endpoint').value.trim(); // just validate
const cid = $('client_id').value.trim();
const red = $('redirect_uri').value.trim();
const scp = $('scope').value.trim() || 'openid profile email';
const st = $('state').value.trim() || randStr(16);
const no = $('nonce').value.trim() || randStr(16);
const cc = $('code_challenge').value.trim();
const cv = $('code_verifier').value.trim();
if(!auth || !token || !cid || !red){ throw new Error('请先完善端点/ClientID/RedirectURI'); }
if(!cc || !cv){ throw new Error('请先生成 PKCE'); }
persist('authorization_endpoint', auth); persist('token_endpoint', token);
persist('client_id', cid); persist('redirect_uri', red); persist('scope', scp);
persist('state', st); persist('nonce', no); persist('code_verifier', cv);
const u = new URL(auth);
u.searchParams.set('response_type', 'code');
u.searchParams.set('client_id', cid);
u.searchParams.set('redirect_uri', red);
u.searchParams.set('scope', scp);
u.searchParams.set('state', st);
u.searchParams.set('nonce', no);
u.searchParams.set('code_challenge', cc);
u.searchParams.set('code_challenge_method', 'S256');
return u.toString();
}
$('btnMakeAuthURL').onclick = () => {
try {
const url = buildAuthorizeURLFromFields();
$('authorize_url').value = url;
setAuthInfo('已生成授权链接', true);
} catch(e){ setAuthInfo(e.message, false); }
};
$('btnAuthorize').onclick = () => {
try { const url = buildAuthorizeURLFromFields(); location.href = url; }
catch(e){ setAuthInfo(e.message, false); }
};
$('btnCopyAuthURL').onclick = async () => { try{ await navigator.clipboard.writeText($('authorize_url').value); }catch{} };
// Parse OIDC discovery JSON pasted by user
$('btnParseConf').onclick = () => {
const txt = $('conf_json').value.trim(); if(!txt){ alert('请先粘贴 JSON'); return; }
try{
const d = JSON.parse(txt);
if (d.issuer) { $('issuer').value = d.issuer; persist('issuer', d.issuer); }
if (d.authorization_endpoint) $('authorization_endpoint').value = d.authorization_endpoint;
if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
if (d.userinfo_endpoint) $('userinfo_endpoint').value = d.userinfo_endpoint;
setAuthInfo('已解析配置并填充端点', true);
}catch(e){ setAuthInfo('解析失败:'+e, false); }
};
// Generate a minimal discovery JSON from current fields
$('btnGenConf').onclick = () => {
const d = {
issuer: $('issuer').value.trim() || undefined,
authorization_endpoint: $('authorization_endpoint').value.trim() || undefined,
token_endpoint: $('token_endpoint').value.trim() || undefined,
userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
};
$('conf_json').value = JSON.stringify(d, null, 2);
};
async function postForm(url, data){
const body = Object.entries(data).map(([k,v])=> `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
const res = await fetch(url, { method:'POST', headers:{ 'Content-Type':'application/x-www-form-urlencoded' }, body });
if(!res.ok){ const t = await res.text(); throw new Error(`HTTP ${res.status} ${t}`); }
return res.json();
}
async function handleCallback(){
const code = qs('code'); const err = qs('error');
const state = qs('state');
if(err){ setAuthInfo('授权失败:'+err, false); return; }
if(!code){ setAuthInfo('等待授权...', true); return; }
// state check
if(state && load('state') && state !== load('state')){ setAuthInfo('state 不匹配,已拒绝', false); return; }
try{
const tokenEp = load('token_endpoint');
const data = await postForm(tokenEp, {
grant_type:'authorization_code',
code,
client_id: load('client_id'),
redirect_uri: load('redirect_uri'),
code_verifier: load('code_verifier')
});
$('access_token').value = data.access_token || '';
$('id_token').value = data.id_token || '';
$('refresh_token').value = data.refresh_token || '';
$('token_raw').value = JSON.stringify(data, null, 2);
setAuthInfo('授权成功,已获取令牌', true);
}catch(e){ setAuthInfo('交换令牌失败:'+e.message, false); }
}
handleCallback();
$('btnCopyAT').onclick = async () => { try{ await navigator.clipboard.writeText($('access_token').value); }catch{} };
$('btnDecodeJWT').onclick = () => {
const t = $('id_token').value.trim(); if(!t){ $('jwtClaims').textContent='(空)'; return; }
const parts = t.split('.'); if(parts.length<2){ $('jwtClaims').textContent='格式错误'; return; }
try{ const json = JSON.parse(atob(parts[1].replace(/-/g,'+').replace(/_/g,'/'))); $('jwtClaims').textContent = JSON.stringify(json, null, 2);}catch(e){ $('jwtClaims').textContent='解码失败:'+e; }
};
$('btnCallUserInfo').onclick = async () => {
const at = $('access_token').value.trim(); const ep = $('userinfo_endpoint').value.trim(); if(!at||!ep){ alert('请填写UserInfo端点并获取AccessToken'); return; }
try{
const res = await fetch(ep, { headers:{ Authorization: 'Bearer '+at } });
const data = await res.json(); $('userinfoOut').textContent = JSON.stringify(data, null, 2);
}catch(e){ $('userinfoOut').textContent = '调用失败:'+e; }
};
$('btnRefreshToken').onclick = async () => {
const rt = $('refresh_token').value.trim(); if(!rt){ alert('没有刷新令牌'); return; }
try{
const tokenEp = load('token_endpoint');
const data = await postForm(tokenEp, {
grant_type:'refresh_token',
refresh_token: rt,
client_id: load('client_id')
});
$('access_token').value = data.access_token || '';
$('id_token').value = data.id_token || '';
$('refresh_token').value = data.refresh_token || '';
$('token_raw').value = JSON.stringify(data, null, 2);
setAuthInfo('刷新成功', true);
}catch(e){ setAuthInfo('刷新失败:'+e.message, false); }
};
</script>
</body>
</html>

View File

@@ -1,181 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
func main() {
// 测试 Client Credentials 流程
//testClientCredentials()
// 测试 Authorization Code + PKCE 流程(需要浏览器交互)
testAuthorizationCode()
}
// testClientCredentials 测试服务对服务认证
func testClientCredentials() {
fmt.Println("=== Testing Client Credentials Flow ===")
cfg := clientcredentials.Config{
ClientID: "client_dsFyyoyNZWjhbNa2", // 需要先创建客户端
ClientSecret: "hLLdn2Ia4UM7hcsJaSuUFDV0Px9BrkNq",
TokenURL: "http://localhost:3000/api/oauth/token",
Scopes: []string{"api:read", "api:write"},
EndpointParams: map[string][]string{
"audience": {"api://new-api"},
},
}
// 创建HTTP客户端
httpClient := cfg.Client(context.Background())
// 调用受保护的API
resp, err := httpClient.Get("http://localhost:3000/api/status")
if err != nil {
log.Printf("Request failed: %v", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Failed to read response: %v", err)
return
}
fmt.Printf("Status: %s\n", resp.Status)
fmt.Printf("Response: %s\n", string(body))
}
// testAuthorizationCode 测试授权码流程
func testAuthorizationCode() {
fmt.Println("=== Testing Authorization Code + PKCE Flow ===")
conf := oauth2.Config{
ClientID: "client_dsFyyoyNZWjhbNa2", // 需要先创建客户端
ClientSecret: "JHiugKf89OMmTLuZMZyA2sgZnO0Ioae3",
RedirectURL: "http://localhost:9999/callback",
// 包含 openid/profile/email 以便调用 UserInfo
Scopes: []string{"openid", "profile", "email", "api:read"},
Endpoint: oauth2.Endpoint{
AuthURL: "http://localhost:3000/api/oauth/authorize",
TokenURL: "http://localhost:3000/api/oauth/token",
},
}
// 生成PKCE参数
codeVerifier := oauth2.GenerateVerifier()
state := fmt.Sprintf("state-%d", time.Now().Unix())
// 构建授权URL
url := conf.AuthCodeURL(
state,
oauth2.S256ChallengeOption(codeVerifier),
//oauth2.SetAuthURLParam("audience", "api://new-api"),
)
fmt.Printf("Visit this URL to authorize:\n%s\n\n", url)
fmt.Printf("A local server will listen on http://localhost:9999/callback to receive the code...\n")
// 启动回调本地服务器,自动接收授权码
codeCh := make(chan string, 1)
srv := &http.Server{Addr: ":9999"}
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if errParam := q.Get("error"); errParam != "" {
fmt.Fprintf(w, "Authorization failed: %s", errParam)
return
}
gotState := q.Get("state")
if gotState != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "missing code", http.StatusBadRequest)
return
}
fmt.Fprintln(w, "Authorization received. You may close this window.")
select {
case codeCh <- code:
default:
}
go func() {
// 稍后关闭服务
_ = srv.Shutdown(context.Background())
}()
})
go func() {
_ = srv.ListenAndServe()
}()
// 等待授权码
var code string
select {
case code = <-codeCh:
case <-time.After(5 * time.Minute):
log.Println("Timeout waiting for authorization code")
_ = srv.Shutdown(context.Background())
return
}
// 交换令牌
token, err := conf.Exchange(
context.Background(),
code,
oauth2.VerifierOption(codeVerifier),
)
if err != nil {
log.Printf("Token exchange failed: %v", err)
return
}
fmt.Printf("Access Token: %s\n", token.AccessToken)
fmt.Printf("Token Type: %s\n", token.TokenType)
fmt.Printf("Expires In: %v\n", token.Expiry)
// 使用令牌调用 UserInfo
client := conf.Client(context.Background(), token)
userInfoURL := buildUserInfoFromAuth(conf.Endpoint.AuthURL)
resp, err := client.Get(userInfoURL)
if err != nil {
log.Printf("UserInfo request failed: %v", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Failed to read UserInfo response: %v", err)
return
}
fmt.Printf("UserInfo: %s\n", string(body))
}
// buildUserInfoFromAuth 将授权端点URL转换为UserInfo端点URL
func buildUserInfoFromAuth(auth string) string {
u, err := url.Parse(auth)
if err != nil {
return ""
}
// 将最后一个路径段 authorize 替换为 userinfo
dir := path.Dir(u.Path)
if strings.HasSuffix(u.Path, "/authorize") {
u.Path = path.Join(dir, "userinfo")
} else {
// 回退:追加默认 /oauth/userinfo
u.Path = path.Join(dir, "userinfo")
}
return u.String()
}

23
go.mod
View File

@@ -11,24 +11,20 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0
github.com/aws/smithy-go v1.22.5
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0
github.com/go-oauth2/gin-server v1.1.0
github.com/go-oauth2/oauth2/v4 v4.5.4
github.com/go-playground/validator/v10 v10.20.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0
github.com/samber/lo v1.39.0
@@ -42,7 +38,6 @@ require (
golang.org/x/crypto v0.35.0
golang.org/x/image v0.23.0
golang.org/x/net v0.35.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.11.0
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
@@ -60,7 +55,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -71,7 +65,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
@@ -85,25 +79,14 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 // indirect
github.com/tidwall/buntdb v1.1.2 // indirect
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e // indirect
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -111,7 +94,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

94
go.sum
View File

@@ -1,7 +1,5 @@
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
@@ -25,8 +23,8 @@ github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6 h1:FCLDGi1EmB7JzjVVYNZiqc/zAJj2BQ5M0lfkVOxbfs8=
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6/go.mod h1:5FoAH5xUHHCMDvQPy1rnj8moqLkLHFaDVBjHhcFwEi0=
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -41,22 +39,16 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
@@ -75,10 +67,6 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/go-oauth2/gin-server v1.1.0 h1:+7AyIfrcKaThZxxABRYECysxAfTccgpFdAqY1enuzBk=
github.com/go-oauth2/gin-server v1.1.0/go.mod h1:f08F3l5/Pbayb4pjnv5PpUdQLFejgGfHrTjA6IZb0eM=
github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0=
github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -102,26 +90,20 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -130,8 +112,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -152,10 +132,6 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
@@ -172,18 +148,6 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -196,8 +160,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
@@ -222,18 +184,10 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -246,35 +200,21 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E=
github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo=
github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI=
github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE=
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo=
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE=
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
@@ -289,24 +229,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4=
github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -323,8 +247,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
@@ -335,12 +257,12 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View File

@@ -14,7 +14,6 @@ import (
"one-api/router"
"one-api/service"
"one-api/setting/ratio_setting"
"one-api/src/oauth"
"os"
"strconv"
@@ -204,13 +203,5 @@ func InitResources() error {
if err != nil {
return err
}
// Initialize OAuth2 server
err = oauth.InitOAuthServer()
if err != nil {
common.SysLog("Warning: Failed to initialize OAuth2 server: " + err.Error())
// OAuth2 失败不应该阻止系统启动
}
return nil
}

View File

@@ -8,14 +8,11 @@ import (
"one-api/model"
"one-api/setting"
"one-api/setting/ratio_setting"
"one-api/setting/system_setting"
"one-api/src/oauth"
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
jwt "github.com/golang-jwt/jwt/v5"
)
func validUserInfo(username string, role int) bool {
@@ -180,7 +177,6 @@ func WssAuth(c *gin.Context) {
func TokenAuth() func(c *gin.Context) {
return func(c *gin.Context) {
rawAuth := c.Request.Header.Get("Authorization")
// 先检测是否为ws
if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" {
// Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1
@@ -239,11 +235,6 @@ func TokenAuth() func(c *gin.Context) {
}
}
if err != nil {
// OAuth Bearer fallback
if tryOAuthBearer(c, rawAuth) {
c.Next()
return
}
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
return
}
@@ -297,74 +288,6 @@ func TokenAuth() func(c *gin.Context) {
}
}
// tryOAuthBearer validates an OAuth JWT access token and sets minimal context for relay
func tryOAuthBearer(c *gin.Context, rawAuth string) bool {
if rawAuth == "" || !strings.HasPrefix(rawAuth, "Bearer ") {
return false
}
tokenString := strings.TrimSpace(strings.TrimPrefix(rawAuth, "Bearer "))
if tokenString == "" {
return false
}
settings := system_setting.GetOAuth2Settings()
// Parse & verify
parsed, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
if kid, ok := t.Header["kid"].(string); ok {
if settings.JWTKeyID != "" && kid != settings.JWTKeyID {
return nil, jwt.ErrTokenSignatureInvalid
}
}
pub := oauth.GetRSAPublicKey()
if pub == nil {
return nil, jwt.ErrTokenUnverifiable
}
return pub, nil
})
if err != nil || parsed == nil || !parsed.Valid {
return false
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return false
}
// issuer check when configured
if iss, ok2 := claims["iss"].(string); !ok2 || (settings.Issuer != "" && iss != settings.Issuer) {
return false
}
// revoke check
if jti, ok2 := claims["jti"].(string); ok2 && jti != "" {
if revoked, _ := model.IsTokenRevoked(jti); revoked {
return false
}
}
// scope check: must contain api:read or api:write or admin
scope, _ := claims["scope"].(string)
scopePadded := " " + scope + " "
if !(strings.Contains(scopePadded, " api:read ") || strings.Contains(scopePadded, " api:write ") || strings.Contains(scopePadded, " admin ")) {
return false
}
// subject must be user id to support quota logic
sub, _ := claims["sub"].(string)
uid, err := strconv.Atoi(sub)
if err != nil || uid <= 0 {
return false
}
// load user cache & set context
userCache, err := model.GetUserCache(uid)
if err != nil || userCache == nil || userCache.Status != common.UserStatusEnabled {
return false
}
c.Set("id", uid)
c.Set("group", userCache.Group)
c.Set("user_group", userCache.Group)
// set UsingGroup
common.SetContextKey(c, constant.ContextKeyUsingGroup, userCache.Group)
return true
}
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
if token == nil {
return fmt.Errorf("token is nil")

View File

@@ -1,291 +0,0 @@
package middleware
import (
"crypto/rsa"
"fmt"
"net/http"
"one-api/common"
"one-api/model"
"one-api/setting/system_setting"
"one-api/src/oauth"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// OAuthJWTAuth OAuth2 JWT认证中间件
func OAuthJWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查OAuth2是否启用
settings := system_setting.GetOAuth2Settings()
if !settings.Enabled {
c.Next()
return
}
// 获取Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Next() // 没有Authorization header继续到下一个中间件
return
}
// 检查是否为Bearer token
if !strings.HasPrefix(authHeader, "Bearer ") {
c.Next() // 不是Bearer token继续到下一个中间件
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == "" {
abortWithOAuthError(c, "invalid_token", "Missing token")
return
}
// 验证JWT token
claims, err := validateOAuthJWT(tokenString)
if err != nil {
abortWithOAuthError(c, "invalid_token", err.Error())
return
}
// 验证token的有效性
if err := validateOAuthClaims(claims); err != nil {
abortWithOAuthError(c, "invalid_token", err.Error())
return
}
// 设置上下文信息
setOAuthContext(c, claims)
c.Next()
}
}
// validateOAuthJWT 验证OAuth2 JWT令牌
func validateOAuthJWT(tokenString string) (jwt.MapClaims, error) {
// 解析JWT而不验证签名先获取header中的kid
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 检查签名方法
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// 获取kid
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("missing kid in token header")
}
// 根据kid获取公钥
publicKey, err := getPublicKeyByKid(kid)
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
return publicKey, nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
if !token.Valid {
return nil, fmt.Errorf("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}
// getPublicKeyByKid 根据kid获取公钥
func getPublicKeyByKid(kid string) (*rsa.PublicKey, error) {
// 这里需要从JWKS获取公钥
// 在实际实现中你可能需要从OAuth server获取JWKS
// 这里先实现一个简单版本
// TODO: 实现JWKS缓存和刷新机制
pub := oauth.GetPublicKeyByKid(kid)
if pub == nil {
return nil, fmt.Errorf("unknown kid: %s", kid)
}
return pub, nil
}
// validateOAuthClaims 验证OAuth2 claims
func validateOAuthClaims(claims jwt.MapClaims) error {
settings := system_setting.GetOAuth2Settings()
// 验证issuer若配置了 Issuer 则强校验,否则仅要求存在)
if iss, ok := claims["iss"].(string); ok {
if settings.Issuer != "" && iss != settings.Issuer {
return fmt.Errorf("invalid issuer")
}
} else {
return fmt.Errorf("missing issuer claim")
}
// 验证audience
// if aud, ok := claims["aud"].(string); ok {
// // TODO: 验证audience
// }
// 验证客户端ID
if clientID, ok := claims["client_id"].(string); ok {
// 验证客户端是否存在且有效
client, err := model.GetOAuthClientByID(clientID)
if err != nil {
return fmt.Errorf("invalid client")
}
if client.Status != common.UserStatusEnabled {
return fmt.Errorf("client disabled")
}
// 检查是否被撤销
if jti, ok := claims["jti"].(string); ok && jti != "" {
revoked, _ := model.IsTokenRevoked(jti)
if revoked {
return fmt.Errorf("token revoked")
}
}
} else {
return fmt.Errorf("missing client_id claim")
}
return nil
}
// setOAuthContext 设置OAuth上下文信息
func setOAuthContext(c *gin.Context, claims jwt.MapClaims) {
c.Set("oauth_claims", claims)
c.Set("oauth_authenticated", true)
// 提取基本信息
if clientID, ok := claims["client_id"].(string); ok {
c.Set("oauth_client_id", clientID)
}
if scope, ok := claims["scope"].(string); ok {
c.Set("oauth_scope", scope)
}
if sub, ok := claims["sub"].(string); ok {
c.Set("oauth_subject", sub)
}
// 对于client_credentials流程subject就是client_id
// 对于authorization_code流程subject是用户ID
if grantType, ok := claims["grant_type"].(string); ok {
c.Set("oauth_grant_type", grantType)
}
}
// abortWithOAuthError 返回OAuth错误响应
func abortWithOAuthError(c *gin.Context, errorCode, description string) {
c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, errorCode, description))
c.JSON(http.StatusUnauthorized, gin.H{
"error": errorCode,
"error_description": description,
})
c.Abort()
}
// RequireOAuthScope OAuth2 scope验证中间件
func RequireOAuthScope(requiredScope string) gin.HandlerFunc {
return func(c *gin.Context) {
// 检查是否通过OAuth认证
if !c.GetBool("oauth_authenticated") {
abortWithOAuthError(c, "insufficient_scope", "OAuth2 authentication required")
return
}
// 获取token的scope
scope, exists := c.Get("oauth_scope")
if !exists {
abortWithOAuthError(c, "insufficient_scope", "No scope in token")
return
}
scopeStr, ok := scope.(string)
if !ok {
abortWithOAuthError(c, "insufficient_scope", "Invalid scope format")
return
}
// 检查是否包含所需的scope
scopes := strings.Split(scopeStr, " ")
for _, s := range scopes {
if strings.TrimSpace(s) == requiredScope {
c.Next()
return
}
}
abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope))
}
}
// OptionalOAuthAuth 可选的OAuth认证中间件不会阻止请求
func OptionalOAuthAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// 尝试OAuth认证但不会阻止请求
authHeader := c.GetHeader("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := validateOAuthJWT(tokenString); err == nil {
if validateOAuthClaims(claims) == nil {
setOAuthContext(c, claims)
}
}
}
c.Next()
}
}
// RequireOAuthScopeIfPresent enforces scope only when OAuth is present; otherwise no-op
func RequireOAuthScopeIfPresent(requiredScope string) gin.HandlerFunc {
return func(c *gin.Context) {
if !c.GetBool("oauth_authenticated") {
c.Next()
return
}
scope, exists := c.Get("oauth_scope")
if !exists {
abortWithOAuthError(c, "insufficient_scope", "No scope in token")
return
}
scopeStr, ok := scope.(string)
if !ok {
abortWithOAuthError(c, "insufficient_scope", "Invalid scope format")
return
}
scopes := strings.Split(scopeStr, " ")
for _, s := range scopes {
if strings.TrimSpace(s) == requiredScope {
c.Next()
return
}
}
abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope))
}
}
// GetOAuthClaims 获取OAuth claims
func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) {
claims, exists := c.Get("oauth_claims")
if !exists {
return nil, false
}
mapClaims, ok := claims.(jwt.MapClaims)
return mapClaims, ok
}
// IsOAuthAuthenticated 检查是否通过OAuth认证
func IsOAuthAuthenticated(c *gin.Context) bool {
return c.GetBool("oauth_authenticated")
}

View File

@@ -265,7 +265,6 @@ func migrateDB() error {
&Setup{},
&TwoFA{},
&TwoFABackupCode{},
&OAuthClient{},
)
if err != nil {
return err

View File

@@ -1,183 +0,0 @@
package model
import (
"encoding/json"
"one-api/common"
"strings"
"time"
"gorm.io/gorm"
)
// OAuthClient OAuth2 客户端模型
type OAuthClient struct {
ID string `json:"id" gorm:"type:varchar(64);primaryKey"`
Secret string `json:"secret" gorm:"type:varchar(128);not null"`
Name string `json:"name" gorm:"type:varchar(255);not null"`
Domain string `json:"domain" gorm:"type:varchar(255)"` // 允许的重定向域名
RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // JSON array of redirect URIs
GrantTypes string `json:"grant_types" gorm:"type:varchar(255);default:'client_credentials'"`
Scopes string `json:"scopes" gorm:"type:varchar(255);default:'api:read'"`
RequirePKCE bool `json:"require_pkce" gorm:"default:true"`
Status int `json:"status" gorm:"type:int;default:1"` // 1: enabled, 2: disabled
CreatedBy int `json:"created_by" gorm:"type:int;not null"` // 创建者用户ID
CreatedTime int64 `json:"created_time" gorm:"bigint"`
LastUsedTime int64 `json:"last_used_time" gorm:"bigint;default:0"`
TokenCount int `json:"token_count" gorm:"type:int;default:0"` // 已签发的token数量
Description string `json:"description" gorm:"type:text"`
ClientType string `json:"client_type" gorm:"type:varchar(32);default:'confidential'"` // confidential, public
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// GetRedirectURIs 获取重定向URI列表
func (c *OAuthClient) GetRedirectURIs() []string {
if c.RedirectURIs == "" {
return []string{}
}
var uris []string
err := json.Unmarshal([]byte(c.RedirectURIs), &uris)
if err != nil {
common.SysLog("failed to unmarshal redirect URIs: " + err.Error())
return []string{}
}
return uris
}
// SetRedirectURIs 设置重定向URI列表
func (c *OAuthClient) SetRedirectURIs(uris []string) {
data, err := json.Marshal(uris)
if err != nil {
common.SysLog("failed to marshal redirect URIs: " + err.Error())
return
}
c.RedirectURIs = string(data)
}
// GetGrantTypes 获取允许的授权类型列表
func (c *OAuthClient) GetGrantTypes() []string {
if c.GrantTypes == "" {
return []string{"client_credentials"}
}
return strings.Split(c.GrantTypes, ",")
}
// SetGrantTypes 设置允许的授权类型列表
func (c *OAuthClient) SetGrantTypes(types []string) {
c.GrantTypes = strings.Join(types, ",")
}
// GetScopes 获取允许的scope列表
func (c *OAuthClient) GetScopes() []string {
if c.Scopes == "" {
return []string{"api:read"}
}
return strings.Split(c.Scopes, ",")
}
// SetScopes 设置允许的scope列表
func (c *OAuthClient) SetScopes(scopes []string) {
c.Scopes = strings.Join(scopes, ",")
}
// ValidateRedirectURI 验证重定向URI是否有效
func (c *OAuthClient) ValidateRedirectURI(uri string) bool {
allowedURIs := c.GetRedirectURIs()
for _, allowedURI := range allowedURIs {
if allowedURI == uri {
return true
}
}
return false
}
// ValidateGrantType 验证授权类型是否被允许
func (c *OAuthClient) ValidateGrantType(grantType string) bool {
allowedTypes := c.GetGrantTypes()
for _, allowedType := range allowedTypes {
if allowedType == grantType {
return true
}
}
return false
}
// ValidateScope 验证scope是否被允许
func (c *OAuthClient) ValidateScope(scope string) bool {
allowedScopes := c.GetScopes()
requestedScopes := strings.Split(scope, " ")
for _, requestedScope := range requestedScopes {
requestedScope = strings.TrimSpace(requestedScope)
if requestedScope == "" {
continue
}
found := false
for _, allowedScope := range allowedScopes {
if allowedScope == requestedScope {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// BeforeCreate GORM hook - 在创建前设置时间
func (c *OAuthClient) BeforeCreate(tx *gorm.DB) (err error) {
c.CreatedTime = time.Now().Unix()
return
}
// UpdateLastUsedTime 更新最后使用时间
func (c *OAuthClient) UpdateLastUsedTime() error {
c.LastUsedTime = time.Now().Unix()
c.TokenCount++
return DB.Model(c).Select("last_used_time", "token_count").Updates(c).Error
}
// GetOAuthClientByID 根据ID获取OAuth客户端
func GetOAuthClientByID(id string) (*OAuthClient, error) {
var client OAuthClient
err := DB.Where("id = ? AND status = ?", id, common.UserStatusEnabled).First(&client).Error
return &client, err
}
// GetAllOAuthClients 获取所有OAuth客户端
func GetAllOAuthClients(startIdx int, num int) ([]*OAuthClient, error) {
var clients []*OAuthClient
err := DB.Order("created_time desc").Limit(num).Offset(startIdx).Find(&clients).Error
return clients, err
}
// SearchOAuthClients 搜索OAuth客户端
func SearchOAuthClients(keyword string) ([]*OAuthClient, error) {
var clients []*OAuthClient
err := DB.Where("name LIKE ? OR id LIKE ? OR description LIKE ?",
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%").Find(&clients).Error
return clients, err
}
// CreateOAuthClient 创建OAuth客户端
func CreateOAuthClient(client *OAuthClient) error {
return DB.Create(client).Error
}
// UpdateOAuthClient 更新OAuth客户端
func UpdateOAuthClient(client *OAuthClient) error {
return DB.Save(client).Error
}
// DeleteOAuthClient 删除OAuth客户端
func DeleteOAuthClient(id string) error {
return DB.Where("id = ?", id).Delete(&OAuthClient{}).Error
}
// CountOAuthClients 统计OAuth客户端数量
func CountOAuthClients() (int64, error) {
var count int64
err := DB.Model(&OAuthClient{}).Count(&count).Error
return count, err
}

View File

@@ -1,57 +0,0 @@
package model
import (
"fmt"
"one-api/common"
"sync"
"time"
)
var revokedMem sync.Map // jti -> exp(unix)
func RevokeToken(jti string, exp int64) error {
if jti == "" {
return nil
}
// Prefer Redis, else in-memory
if common.RedisEnabled {
ttl := time.Duration(0)
if exp > 0 {
ttl = time.Until(time.Unix(exp, 0))
}
if ttl <= 0 {
ttl = time.Minute
}
key := fmt.Sprintf("oauth:revoked:%s", jti)
return common.RedisSet(key, "1", ttl)
}
if exp <= 0 {
exp = time.Now().Add(time.Minute).Unix()
}
revokedMem.Store(jti, exp)
return nil
}
func IsTokenRevoked(jti string) (bool, error) {
if jti == "" {
return false, nil
}
if common.RedisEnabled {
key := fmt.Sprintf("oauth:revoked:%s", jti)
if _, err := common.RedisGet(key); err == nil {
return true, nil
} else {
// Not found or error; treat as not revoked on error to avoid hard failures
return false, nil
}
}
// In-memory check
if v, ok := revokedMem.Load(jti); ok {
exp, _ := v.(int64)
if exp == 0 || time.Now().Unix() <= exp {
return true, nil
}
revokedMem.Delete(jti)
}
return false, nil
}

View File

@@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
if responsesResponse.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", responsesResponse.GetQuality())
c.Set("image_generation_call_size", responsesResponse.GetSize())
}
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
@@ -80,18 +86,25 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
c.Set("image_generation_call_size", streamResponse.Response.GetSize())
}
}
case "response.output_text.delta":

View File

@@ -276,6 +276,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
fileSearchTool.CallCount, dFileSearchQuota.String())
}
}
var dImageGenerationCallQuota decimal.Decimal
var imageGenerationCallPrice float64
if ctx.GetBool("image_generation_call") {
imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
}
var quotaCalculateDecimal decimal.Decimal
@@ -331,6 +338,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
// 添加 image generation call 计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -429,6 +438,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
if !dImageGenerationCallQuota.IsZero() {
other["image_generation_call"] = true
other["image_generation_call_price"] = imageGenerationCallPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens,

View File

@@ -31,21 +31,6 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth)
apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth)
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
// OAuth2 Server endpoints
apiRouter.GET("/.well-known/jwks.json", controller.GetJWKS)
apiRouter.GET("/.well-known/openid-configuration", controller.OAuthOIDCConfiguration)
apiRouter.GET("/.well-known/oauth-authorization-server", controller.OAuthServerInfo)
apiRouter.POST("/oauth/token", middleware.CriticalRateLimit(), controller.OAuthTokenEndpoint)
apiRouter.GET("/oauth/authorize", controller.OAuthAuthorizeEndpoint)
apiRouter.POST("/oauth/introspect", middleware.AdminAuth(), controller.OAuthIntrospect)
apiRouter.POST("/oauth/revoke", middleware.CriticalRateLimit(), controller.OAuthRevoke)
apiRouter.GET("/oauth/userinfo", middleware.OAuthJWTAuth(), controller.OAuthUserInfo)
// OAuth2 管理API (前端使用)
apiRouter.GET("/oauth/jwks", controller.GetJWKS)
apiRouter.GET("/oauth/server-info", controller.OAuthServerInfo)
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
@@ -55,17 +40,6 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
// OAuth2 admin operations
oauthAdmin := apiRouter.Group("/oauth")
oauthAdmin.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
{
oauthAdmin.POST("/keys/rotate", controller.RotateOAuthSigningKey)
oauthAdmin.GET("/keys", controller.ListOAuthSigningKeys)
oauthAdmin.DELETE("/keys/:kid", controller.DeleteOAuthSigningKey)
oauthAdmin.POST("/keys/generate_file", controller.GenerateOAuthSigningKeyFile)
oauthAdmin.POST("/keys/import_pem", controller.ImportOAuthSigningKey)
}
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
@@ -104,7 +78,7 @@ func SetApiRouter(router *gin.Engine) {
}
adminRoute := userRoute.Group("/")
adminRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
adminRoute.Use(middleware.AdminAuth())
{
adminRoute.GET("/", controller.GetAllUsers)
adminRoute.GET("/search", controller.SearchUsers)
@@ -120,7 +94,7 @@ func SetApiRouter(router *gin.Engine) {
}
}
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
optionRoute.Use(middleware.RootAuth())
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
@@ -134,7 +108,7 @@ func SetApiRouter(router *gin.Engine) {
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
channelRoute.Use(middleware.AdminAuth())
{
channelRoute.GET("/", controller.GetAllChannels)
channelRoute.GET("/search", controller.SearchChannels)
@@ -185,7 +159,7 @@ func SetApiRouter(router *gin.Engine) {
}
redemptionRoute := apiRouter.Group("/redemption")
redemptionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
redemptionRoute.Use(middleware.AdminAuth())
{
redemptionRoute.GET("/", controller.GetAllRedemptions)
redemptionRoute.GET("/search", controller.SearchRedemptions)
@@ -213,13 +187,13 @@ func SetApiRouter(router *gin.Engine) {
logRoute.GET("/token", controller.GetLogByKey)
}
groupRoute := apiRouter.Group("/group")
groupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
groupRoute.Use(middleware.AdminAuth())
{
groupRoute.GET("/", controller.GetGroups)
}
prefillGroupRoute := apiRouter.Group("/prefill_group")
prefillGroupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
prefillGroupRoute.Use(middleware.AdminAuth())
{
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
@@ -261,17 +235,5 @@ func SetApiRouter(router *gin.Engine) {
modelsRoute.PUT("/", controller.UpdateModelMeta)
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
}
oauthClientsRoute := apiRouter.Group("/oauth_clients")
oauthClientsRoute.Use(middleware.AdminAuth())
{
oauthClientsRoute.GET("/", controller.GetAllOAuthClients)
oauthClientsRoute.GET("/search", controller.SearchOAuthClients)
oauthClientsRoute.GET("/:id", controller.GetOAuthClient)
oauthClientsRoute.POST("/", controller.CreateOAuthClient)
oauthClientsRoute.PUT("/", controller.UpdateOAuthClient)
oauthClientsRoute.DELETE("/:id", controller.DeleteOAuthClient)
oauthClientsRoute.POST("/:id/regenerate_secret", controller.RegenerateOAuthClientSecret)
}
}
}

View File

@@ -10,6 +10,18 @@ const (
FileSearchPrice = 2.5
)
const (
GPTImage1Low1024x1024 = 0.011
GPTImage1Low1024x1536 = 0.016
GPTImage1Low1536x1024 = 0.016
GPTImage1Medium1024x1024 = 0.042
GPTImage1Medium1024x1536 = 0.063
GPTImage1Medium1536x1024 = 0.063
GPTImage1High1024x1024 = 0.167
GPTImage1High1024x1536 = 0.25
GPTImage1High1536x1024 = 0.25
)
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
@@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
}
return 0
}
func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
prices := map[string]map[string]float64{
"low": {
"1024x1024": GPTImage1Low1024x1024,
"1024x1536": GPTImage1Low1024x1536,
"1536x1024": GPTImage1Low1536x1024,
},
"medium": {
"1024x1024": GPTImage1Medium1024x1024,
"1024x1536": GPTImage1Medium1024x1536,
"1536x1024": GPTImage1Medium1536x1024,
},
"high": {
"1024x1024": GPTImage1High1024x1024,
"1024x1536": GPTImage1High1024x1536,
"1536x1024": GPTImage1High1536x1024,
},
}
if qualityMap, exists := prices[quality]; exists {
if price, exists := qualityMap[size]; exists {
return price
}
}
return GPTImage1High1024x1024
}

View File

@@ -1,74 +0,0 @@
package system_setting
import "one-api/setting/config"
type OAuth2Settings struct {
Enabled bool `json:"enabled"`
Issuer string `json:"issuer"`
AccessTokenTTL int `json:"access_token_ttl"` // in minutes
RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes
AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token
RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow
JWTSigningAlgorithm string `json:"jwt_signing_algorithm"`
JWTKeyID string `json:"jwt_key_id"`
JWTPrivateKeyFile string `json:"jwt_private_key_file"`
AutoCreateUser bool `json:"auto_create_user"` // auto create user on first OAuth2 login
DefaultUserRole int `json:"default_user_role"` // default role for auto-created users
DefaultUserGroup string `json:"default_user_group"` // default group for auto-created users
ScopeMappings map[string][]string `json:"scope_mappings"` // scope to permissions mapping
MaxJWKSKeys int `json:"max_jwks_keys"` // maximum number of JWKS signing keys to retain
DefaultPrivateKeyPath string `json:"default_private_key_path"` // suggested private key file path
}
// 默认配置
var defaultOAuth2Settings = OAuth2Settings{
Enabled: false,
AccessTokenTTL: 10, // 10 minutes
RefreshTokenTTL: 720, // 12 hours
AllowedGrantTypes: []string{"client_credentials", "authorization_code", "refresh_token"},
RequirePKCE: true,
JWTSigningAlgorithm: "RS256",
JWTKeyID: "oauth2-key-1",
AutoCreateUser: false,
DefaultUserRole: 1, // common user
DefaultUserGroup: "default",
ScopeMappings: map[string][]string{
"api:read": {"read"},
"api:write": {"write"},
"admin": {"admin"},
},
MaxJWKSKeys: 3,
DefaultPrivateKeyPath: "/etc/new-api/oauth2-private.pem",
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("oauth2", &defaultOAuth2Settings)
}
func GetOAuth2Settings() *OAuth2Settings {
return &defaultOAuth2Settings
}
// UpdateOAuth2Settings 更新OAuth2配置
func UpdateOAuth2Settings(settings OAuth2Settings) {
defaultOAuth2Settings = settings
}
// ValidateGrantType 验证授权类型是否被允许
func (s *OAuth2Settings) ValidateGrantType(grantType string) bool {
for _, allowedType := range s.AllowedGrantTypes {
if allowedType == grantType {
return true
}
}
return false
}
// GetScopePermissions 获取scope对应的权限
func (s *OAuth2Settings) GetScopePermissions(scope string) []string {
if perms, exists := s.ScopeMappings[scope]; exists {
return perms
}
return []string{}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +0,0 @@
package oauth
import (
"one-api/common"
"sync"
"time"
)
// KVStore is a minimal TTL key-value abstraction used by OAuth flows.
type KVStore interface {
Set(key, value string, ttl time.Duration) error
Get(key string) (string, bool)
Del(key string) error
}
type redisStore struct{}
func (r *redisStore) Set(key, value string, ttl time.Duration) error {
return common.RedisSet(key, value, ttl)
}
func (r *redisStore) Get(key string) (string, bool) {
v, err := common.RedisGet(key)
if err != nil || v == "" {
return "", false
}
return v, true
}
func (r *redisStore) Del(key string) error {
return common.RedisDel(key)
}
type memEntry struct {
val string
exp int64 // unix seconds, 0 means no expiry
}
type memoryStore struct {
m sync.Map // key -> memEntry
}
func (m *memoryStore) Set(key, value string, ttl time.Duration) error {
var exp int64
if ttl > 0 {
exp = time.Now().Add(ttl).Unix()
}
m.m.Store(key, memEntry{val: value, exp: exp})
return nil
}
func (m *memoryStore) Get(key string) (string, bool) {
v, ok := m.m.Load(key)
if !ok {
return "", false
}
e := v.(memEntry)
if e.exp > 0 && time.Now().Unix() > e.exp {
m.m.Delete(key)
return "", false
}
return e.val, true
}
func (m *memoryStore) Del(key string) error {
m.m.Delete(key)
return nil
}
var (
memStore = &memoryStore{}
rdsStore = &redisStore{}
)
func getStore() KVStore {
if common.RedisEnabled {
return rdsStore
}
return memStore
}
func storeSet(key, val string, ttl time.Duration) error { return getStore().Set(key, val, ttl) }
func storeGet(key string) (string, bool) { return getStore().Get(key) }
func storeDel(key string) error { return getStore().Del(key) }

View File

@@ -1,59 +0,0 @@
package oauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
// getFormOrBasicAuth extracts client_id/client_secret from Basic Auth first, then form
func getFormOrBasicAuth(c *gin.Context) (clientID, clientSecret string) {
id, secret, ok := c.Request.BasicAuth()
if ok {
return strings.TrimSpace(id), strings.TrimSpace(secret)
}
return strings.TrimSpace(c.PostForm("client_id")), strings.TrimSpace(c.PostForm("client_secret"))
}
// genCode generates URL-safe random string based on nBytes of entropy
func genCode(nBytes int) (string, error) {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// s256Base64URL computes base64url-encoded SHA256 digest
func s256Base64URL(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// writeNoStore sets no-store cache headers for OAuth responses
func writeNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Header("Pragma", "no-cache")
}
// writeOAuthRedirectError builds an error redirect to redirect_uri as RFC6749
func writeOAuthRedirectError(c *gin.Context, redirectURI, errCode, description, state string) {
writeNoStore(c)
q := "error=" + url.QueryEscape(errCode)
if description != "" {
q += "&error_description=" + url.QueryEscape(description)
}
if state != "" {
q += "&state=" + url.QueryEscape(state)
}
sep := "?"
if strings.Contains(redirectURI, "?") {
sep = "&"
}
c.Redirect(http.StatusFound, redirectURI+sep+q)
}

View File

@@ -1,662 +0,0 @@
<!-- This file is a copy of examples/oauth-demo.html for direct serving under /oauth-demo.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OAuth2/OIDC 授权码 + PKCE 前端演示</title>
<style>
:root {
--bg: #0b0c10;
--panel: #111317;
--muted: #aab2bf;
--accent: #3b82f6;
--ok: #16a34a;
--warn: #f59e0b;
--err: #ef4444;
--border: #1f2430;
}
body {
margin: 0;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
Segoe UI,
Roboto,
Helvetica,
Arial;
background: var(--bg);
color: #e5e7eb;
}
.wrap {
max-width: 980px;
margin: 32px auto;
padding: 0 16px;
}
h1 {
font-size: 22px;
margin: 0 0 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin: 12px 0;
}
.row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.col {
flex: 1 1 280px;
display: flex;
flex-direction: column;
}
label {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input,
textarea,
select {
background: #0f1115;
color: #e5e7eb;
border: 1px solid var(--border);
padding: 10px 12px;
border-radius: 8px;
outline: none;
}
textarea {
min-height: 100px;
resize: vertical;
}
.btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
button {
background: #1a1f2b;
color: #e5e7eb;
border: 1px solid var(--border);
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.ok {
background: var(--ok);
border-color: var(--ok);
color: white;
}
button.warn {
background: var(--warn);
border-color: var(--warn);
color: black;
}
button.ghost {
background: transparent;
}
.muted {
color: var(--muted);
font-size: 12px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
.grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 880px) {
.grid2 {
grid-template-columns: 1fr;
}
}
.ok {
color: #10b981;
}
.err {
color: #ef4444;
}
.sep {
height: 1px;
background: var(--border);
margin: 12px 0;
}
</style>
</head>
<body>
<div class="wrap">
<h1>OAuth2/OIDC 授权码 + PKCE 前端演示</h1>
<div class="card">
<div class="row">
<div class="col">
<label
>Issuer可选用于自动发现
/.well-known/openid-configuration</label
>
<input id="issuer" placeholder="https://your-domain" />
<div class="btns">
<button class="" id="btnDiscover">自动发现端点</button>
</div>
<div class="muted">提示:若未配置 Issuer可直接填写下方端点。</div>
</div>
</div>
<div class="row">
<div class="col">
<label>Response Type</label>
<select id="response_type">
<option value="code" selected>code</option>
<option value="token">token</option>
</select>
</div>
<div class="col">
<label>Authorization Endpoint</label
><input
id="authorization_endpoint"
placeholder="https://domain/api/oauth/authorize"
/>
</div>
<div class="col">
<label>Token Endpoint</label
><input
id="token_endpoint"
placeholder="https://domain/api/oauth/token"
/>
</div>
</div>
<div class="row">
<div class="col">
<label>UserInfo Endpoint可选</label
><input
id="userinfo_endpoint"
placeholder="https://domain/api/oauth/userinfo"
/>
</div>
<div class="col">
<label>Client ID</label
><input id="client_id" placeholder="your-public-client-id" />
</div>
</div>
<div class="row">
<div class="col">
<label>Client Secret可选机密客户端</label
><input id="client_secret" placeholder="留空表示公开客户端" />
</div>
</div>
<div class="row">
<div class="col">
<label>Redirect URI当前页地址或你的回调</label
><input id="redirect_uri" />
</div>
<div class="col">
<label>Scope</label
><input id="scope" value="openid profile email" />
</div>
</div>
<div class="row">
<div class="col"><label>State</label><input id="state" /></div>
<div class="col"><label>Nonce</label><input id="nonce" /></div>
</div>
<div class="row">
<div class="col">
<label>Code Verifier自动生成不会上送</label
><input id="code_verifier" class="mono" readonly />
</div>
<div class="col">
<label>Code ChallengeS256</label
><input id="code_challenge" class="mono" readonly />
</div>
</div>
<div class="btns">
<button id="btnGenPkce">生成 PKCE</button>
<button id="btnRandomState">随机 State</button>
<button id="btnRandomNonce">随机 Nonce</button>
<button id="btnMakeAuthURL">生成授权链接</button>
<button id="btnAuthorize" class="primary">跳转授权</button>
</div>
<div class="row" style="margin-top: 8px">
<div class="col">
<label>授权链接(只生成不跳转)</label>
<textarea
id="authorize_url"
class="mono"
placeholder="(空)"
></textarea>
<div class="btns">
<button id="btnCopyAuthURL">复制链接</button>
</div>
</div>
</div>
<div class="sep"></div>
<div class="muted">
说明:
<ul>
<li>
本页为纯前端演示,适用于公开客户端(不需要 client_secret
</li>
<li>
如跨域调用 Token/UserInfo需要服务端正确设置 CORS建议将此 demo
部署到同源域名下。
</li>
</ul>
</div>
<div class="sep"></div>
<div class="row">
<div class="col">
<label
>粘贴 OIDC Discovery
JSON/.well-known/openid-configuration</label
>
<textarea
id="conf_json"
class="mono"
placeholder='{"issuer":"https://...","authorization_endpoint":"...","token_endpoint":"...","userinfo_endpoint":"..."}'
></textarea>
<div class="btns">
<button id="btnParseConf">解析并填充端点</button>
<button id="btnGenConf">用当前端点生成 JSON</button>
</div>
<div class="muted">
可将服务端返回的 OIDC Discovery JSON
粘贴到此处,点击“解析并填充端点”。
</div>
</div>
</div>
</div>
<div class="card">
<div class="row">
<div class="col">
<label>授权结果</label>
<div id="authResult" class="muted">等待授权...</div>
</div>
</div>
<div class="grid2" style="margin-top: 12px">
<div>
<label>Access Token</label>
<textarea
id="access_token"
class="mono"
placeholder="(空)"
></textarea>
<div class="btns">
<button id="btnCopyAT">复制</button
><button id="btnCallUserInfo" class="ok">调用 UserInfo</button>
</div>
<div id="userinfoOut" class="muted" style="margin-top: 6px"></div>
</div>
<div>
<label>ID TokenJWT</label>
<textarea id="id_token" class="mono" placeholder="(空)"></textarea>
<div class="btns">
<button id="btnDecodeJWT">解码显示 Claims</button>
</div>
<pre
id="jwtClaims"
class="mono"
style="
white-space: pre-wrap;
word-break: break-all;
margin-top: 6px;
"
></pre>
</div>
</div>
<div class="grid2" style="margin-top: 12px">
<div>
<label>Refresh Token</label>
<textarea
id="refresh_token"
class="mono"
placeholder="(空)"
></textarea>
<div class="btns">
<button id="btnRefreshToken">使用 Refresh Token 刷新</button>
</div>
</div>
<div>
<label>原始 Token 响应</label>
<textarea id="token_raw" class="mono" placeholder="(空)"></textarea>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const toB64Url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
async function sha256B64Url(str) {
const data = new TextEncoder().encode(str);
const digest = await crypto.subtle.digest('SHA-256', data);
return toB64Url(digest);
}
function randStr(len = 64) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const arr = new Uint8Array(len);
crypto.getRandomValues(arr);
return Array.from(arr, (v) => chars[v % chars.length]).join('');
}
function setAuthInfo(msg, ok = true) {
const el = $('authResult');
el.textContent = msg;
el.className = ok ? 'ok' : 'err';
}
function qs(name) {
const u = new URL(location.href);
return u.searchParams.get(name);
}
function persist(k, v) {
sessionStorage.setItem('demo_' + k, v);
}
function load(k) {
return sessionStorage.getItem('demo_' + k) || '';
}
(function init() {
$('redirect_uri').value =
window.location.origin + window.location.pathname;
const iss = load('issuer');
if (iss) $('issuer').value = iss;
const cid = load('client_id');
if (cid) $('client_id').value = cid;
const scp = load('scope');
if (scp) $('scope').value = scp;
})();
$('btnDiscover').onclick = async () => {
const iss = $('issuer').value.trim();
if (!iss) {
alert('请填写 Issuer');
return;
}
try {
persist('issuer', iss);
const res = await fetch(
iss.replace(/\/$/, '') + '/api/.well-known/openid-configuration',
);
const d = await res.json();
$('authorization_endpoint').value = d.authorization_endpoint || '';
$('token_endpoint').value = d.token_endpoint || '';
$('userinfo_endpoint').value = d.userinfo_endpoint || '';
if (d.issuer) {
$('issuer').value = d.issuer;
persist('issuer', d.issuer);
}
$('conf_json').value = JSON.stringify(d, null, 2);
setAuthInfo('已从发现文档加载端点', true);
} catch (e) {
setAuthInfo('自动发现失败:' + e, false);
}
};
$('btnGenPkce').onclick = async () => {
const v = randStr(64);
const c = await sha256B64Url(v);
$('code_verifier').value = v;
$('code_challenge').value = c;
persist('code_verifier', v);
persist('code_challenge', c);
setAuthInfo('已生成 PKCE 参数', true);
};
$('btnRandomState').onclick = () => {
$('state').value = randStr(16);
persist('state', $('state').value);
};
$('btnRandomNonce').onclick = () => {
$('nonce').value = randStr(16);
persist('nonce', $('nonce').value);
};
function buildAuthorizeURLFromFields() {
const auth = $('authorization_endpoint').value.trim();
const token = $('token_endpoint').value.trim();
const cid = $('client_id').value.trim();
const red = $('redirect_uri').value.trim();
const scp = $('scope').value.trim() || 'openid profile email';
const rt = $('response_type').value;
const st = $('state').value.trim() || randStr(16);
const no = $('nonce').value.trim() || randStr(16);
const cc = $('code_challenge').value.trim();
const cv = $('code_verifier').value.trim();
if (!auth || !cid || !red) {
throw new Error('请先完善端点/ClientID/RedirectURI');
}
if (rt === 'code' && (!cc || !cv)) {
throw new Error('请先生成 PKCE');
}
persist('authorization_endpoint', auth);
persist('token_endpoint', token);
persist('client_id', cid);
persist('redirect_uri', red);
persist('scope', scp);
persist('state', st);
persist('nonce', no);
persist('code_verifier', cv);
const u = new URL(auth);
u.searchParams.set('response_type', rt);
u.searchParams.set('client_id', cid);
u.searchParams.set('redirect_uri', red);
u.searchParams.set('scope', scp);
u.searchParams.set('state', st);
if (no) u.searchParams.set('nonce', no);
if (rt === 'code') {
u.searchParams.set('code_challenge', cc);
u.searchParams.set('code_challenge_method', 'S256');
}
return u.toString();
}
$('btnMakeAuthURL').onclick = () => {
try {
const url = buildAuthorizeURLFromFields();
$('authorize_url').value = url;
setAuthInfo('已生成授权链接', true);
} catch (e) {
setAuthInfo(e.message, false);
}
};
$('btnAuthorize').onclick = () => {
try {
const url = buildAuthorizeURLFromFields();
location.href = url;
} catch (e) {
setAuthInfo(e.message, false);
}
};
$('btnCopyAuthURL').onclick = async () => {
try {
await navigator.clipboard.writeText($('authorize_url').value);
} catch {}
};
async function postForm(url, data, basic) {
const body = Object.entries(data)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (basic && basic.id && basic.secret) {
headers['Authorization'] =
'Basic ' + btoa(`${basic.id}:${basic.secret}`);
}
const res = await fetch(url, { method: 'POST', headers, body });
if (!res.ok) {
const t = await res.text();
throw new Error(`HTTP ${res.status} ${t}`);
}
return res.json();
}
async function handleCallback() {
const frag =
location.hash && location.hash.startsWith('#')
? new URLSearchParams(location.hash.slice(1))
: null;
const at = frag ? frag.get('access_token') : null;
const err = qs('error') || (frag ? frag.get('error') : null);
const state = qs('state') || (frag ? frag.get('state') : null);
if (err) {
setAuthInfo('授权失败:' + err, false);
return;
}
if (at) {
$('access_token').value = at || '';
$('token_raw').value = JSON.stringify(
{
access_token: at,
token_type: frag.get('token_type'),
expires_in: frag.get('expires_in'),
scope: frag.get('scope'),
state,
},
null,
2,
);
setAuthInfo('隐式模式已获取 Access Token', true);
return;
}
const code = qs('code');
if (!code) {
setAuthInfo('等待授权...', true);
return;
}
if (state && load('state') && state !== load('state')) {
setAuthInfo('state 不匹配,已拒绝', false);
return;
}
try {
const tokenEp = load('token_endpoint');
const cid = load('client_id');
const csec = $('client_secret').value.trim();
const basic = csec ? { id: cid, secret: csec } : null;
const data = await postForm(
tokenEp,
{
grant_type: 'authorization_code',
code,
client_id: cid,
redirect_uri: load('redirect_uri'),
code_verifier: load('code_verifier'),
},
basic,
);
$('access_token').value = data.access_token || '';
$('id_token').value = data.id_token || '';
$('refresh_token').value = data.refresh_token || '';
$('token_raw').value = JSON.stringify(data, null, 2);
setAuthInfo('授权成功,已获取令牌', true);
} catch (e) {
setAuthInfo('交换令牌失败:' + e.message, false);
}
}
handleCallback();
$('btnCopyAT').onclick = async () => {
try {
await navigator.clipboard.writeText($('access_token').value);
} catch {}
};
$('btnDecodeJWT').onclick = () => {
const t = $('id_token').value.trim();
if (!t) {
$('jwtClaims').textContent = '(空)';
return;
}
const parts = t.split('.');
if (parts.length < 2) {
$('jwtClaims').textContent = '格式错误';
return;
}
try {
const json = JSON.parse(
atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')),
);
$('jwtClaims').textContent = JSON.stringify(json, null, 2);
} catch (e) {
$('jwtClaims').textContent = '解码失败:' + e;
}
};
$('btnCallUserInfo').onclick = async () => {
const at = $('access_token').value.trim();
const ep = $('userinfo_endpoint').value.trim();
if (!at || !ep) {
alert('请填写UserInfo端点并获取AccessToken');
return;
}
try {
const res = await fetch(ep, {
headers: { Authorization: 'Bearer ' + at },
});
const data = await res.json();
$('userinfoOut').textContent = JSON.stringify(data, null, 2);
} catch (e) {
$('userinfoOut').textContent = '调用失败:' + e;
}
};
$('btnRefreshToken').onclick = async () => {
const rt = $('refresh_token').value.trim();
if (!rt) {
alert('没有刷新令牌');
return;
}
try {
const tokenEp = load('token_endpoint');
const cid = load('client_id');
const csec = $('client_secret').value.trim();
const basic = csec ? { id: cid, secret: csec } : null;
const data = await postForm(
tokenEp,
{ grant_type: 'refresh_token', refresh_token: rt, client_id: cid },
basic,
);
$('access_token').value = data.access_token || '';
$('id_token').value = data.id_token || '';
$('refresh_token').value = data.refresh_token || '';
$('token_raw').value = JSON.stringify(data, null, 2);
setAuthInfo('刷新成功', true);
} catch (e) {
setAuthInfo('刷新失败:' + e.message, false);
}
};
$('btnParseConf').onclick = () => {
const txt = $('conf_json').value.trim();
if (!txt) {
alert('请先粘贴 JSON');
return;
}
try {
const d = JSON.parse(txt);
if (d.issuer) {
$('issuer').value = d.issuer;
persist('issuer', d.issuer);
}
if (d.authorization_endpoint)
$('authorization_endpoint').value = d.authorization_endpoint;
if (d.token_endpoint) $('token_endpoint').value = d.token_endpoint;
if (d.userinfo_endpoint)
$('userinfo_endpoint').value = d.userinfo_endpoint;
setAuthInfo('已解析配置并填充端点', true);
} catch (e) {
setAuthInfo('解析失败:' + e, false);
}
};
$('btnGenConf').onclick = () => {
const d = {
issuer: $('issuer').value.trim() || undefined,
authorization_endpoint:
$('authorization_endpoint').value.trim() || undefined,
token_endpoint: $('token_endpoint').value.trim() || undefined,
userinfo_endpoint: $('userinfo_endpoint').value.trim() || undefined,
};
$('conf_json').value = JSON.stringify(d, null, 2);
};
</script>
</body>
</html>

View File

@@ -44,7 +44,6 @@ import Task from './pages/Task';
import ModelPage from './pages/Model';
import Playground from './pages/Playground';
import OAuth2Callback from './components/auth/OAuth2Callback';
import OAuthConsent from './pages/OAuth';
import PersonalSetting from './components/settings/PersonalSetting';
import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck';
@@ -199,14 +198,6 @@ function App() {
</Suspense>
}
/>
<Route
path='/oauth/consent'
element={
<Suspense fallback={<Loading></Loading>}>
<OAuthConsent />
</Suspense>
}
/>
<Route
path='/oauth/linuxdo'
element={

View File

@@ -176,11 +176,7 @@ const LoginForm = () => {
centered: true,
});
}
// 优先跳回 next仅允许相对路径
const sp = new URLSearchParams(window.location.search);
const next = sp.get('next');
const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
navigate(isSafeInternalPath ? next : '/console');
navigate('/console');
} else {
showError(message);
}
@@ -290,10 +286,7 @@ const LoginForm = () => {
setUserData(data);
updateAPI();
showSuccess('登录成功!');
const sp = new URLSearchParams(window.location.search);
const next = sp.get('next');
const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
navigate(isSafeInternalPath ? next : '/console');
navigate('/console');
};
// 返回登录页面

View File

@@ -135,9 +135,7 @@ const TwoFactorAuthModal = ({
autoFocus
/>
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
{t(
'支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。',
)}
{t('支持6位TOTP验证码或8位备用码可到`个人设置-安全设置-两步验证设置`配置或查看。')}
</Typography.Text>
</div>
</div>

View File

@@ -1,135 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal, Typography } from '@douyinfe/semi-ui';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Title } = Typography;
/**
* ResponsiveModal 响应式模态框组件
*
* 特性:
* - 响应式布局:移动端和桌面端不同的宽度和布局
* - 自定义头部:标题左对齐,操作按钮右对齐,移动端自动换行
* - Tailwind CSS 样式支持
* - 保持原 Modal 组件的所有功能
*/
const ResponsiveModal = ({
visible,
onCancel,
title,
headerActions = [],
children,
width = { mobile: '95%', desktop: 600 },
className = '',
footer = null,
titleProps = {},
headerClassName = '',
actionsClassName = '',
...props
}) => {
const isMobile = useIsMobile();
// 自定义 Header 组件
const CustomHeader = () => {
if (!title && (!headerActions || headerActions.length === 0)) return null;
return (
<div
className={`flex w-full gap-3 justify-between ${
isMobile ? 'flex-col items-start' : 'flex-row items-center'
} ${headerClassName}`}
>
{title && (
<Title heading={5} className='m-0 min-w-fit' {...titleProps}>
{title}
</Title>
)}
{headerActions && headerActions.length > 0 && (
<div
className={`flex flex-wrap gap-2 items-center ${
isMobile ? 'w-full justify-start' : 'w-auto justify-end'
} ${actionsClassName}`}
>
{headerActions.map((action, index) => (
<React.Fragment key={index}>{action}</React.Fragment>
))}
</div>
)}
</div>
);
};
// 计算模态框宽度
const getModalWidth = () => {
if (typeof width === 'object') {
return isMobile ? width.mobile : width.desktop;
}
return width;
};
return (
<Modal
visible={visible}
title={<CustomHeader />}
onCancel={onCancel}
footer={footer}
width={getModalWidth()}
className={`!top-12 ${className}`}
{...props}
>
{children}
</Modal>
);
};
ResponsiveModal.propTypes = {
// Modal 基础属性
visible: PropTypes.bool.isRequired,
onCancel: PropTypes.func.isRequired,
children: PropTypes.node,
// 自定义头部
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
headerActions: PropTypes.arrayOf(PropTypes.node),
// 样式和布局
width: PropTypes.oneOfType([
PropTypes.number,
PropTypes.string,
PropTypes.shape({
mobile: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
desktop: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
}),
]),
className: PropTypes.string,
footer: PropTypes.node,
// 标题自定义属性
titleProps: PropTypes.object,
// 自定义 CSS 类
headerClassName: PropTypes.string,
actionsClassName: PropTypes.string,
};
export default ResponsiveModal;

View File

@@ -21,7 +21,7 @@ import React, { useState, useMemo, useCallback } from 'react';
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { copy } from '../../../helpers';
import { copy } from '../../helpers';
const PERFORMANCE_CONFIG = {
MAX_DISPLAY_LENGTH: 50000, //

View File

@@ -28,7 +28,7 @@ import {
} from '@douyinfe/semi-ui';
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeViewer from '../common/ui/CodeViewer';
import CodeViewer from './CodeViewer';
const DebugPanel = ({
debugData,

View File

@@ -1,72 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import { Spin } from '@douyinfe/semi-ui';
import { API, showError } from '../../helpers';
import { useTranslation } from 'react-i18next';
import OAuth2ServerSettings from './oauth2/OAuth2ServerSettings';
import OAuth2ClientSettings from './oauth2/OAuth2ClientSettings';
const OAuth2Setting = () => {
const { t } = useTranslation();
const [options, setOptions] = useState({});
const [loading, setLoading] = useState(false);
const getOptions = async () => {
setLoading(true);
try {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
const map = {};
for (const item of data) {
map[item.key] = item.value;
}
setOptions(map);
} else {
showError(message);
}
} catch (error) {
showError(t('获取OAuth2设置失败'));
} finally {
setLoading(false);
}
};
const refresh = () => {
getOptions();
};
useEffect(() => {
getOptions();
}, []);
return (
<Spin spinning={loading} size='large'>
{/* 服务器配置 */}
<OAuth2ServerSettings options={options} refresh={refresh} />
{/* 客户端管理 */}
<OAuth2ClientSettings />
</Spin>
);
};
export default OAuth2Setting;

View File

@@ -1,400 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import {
Card,
Table,
Button,
Space,
Tag,
Typography,
Input,
Popconfirm,
Empty,
Tooltip,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { User } from 'lucide-react';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { API, showError, showSuccess } from '../../../helpers';
import OAuth2ClientModal from './modals/OAuth2ClientModal';
import SecretDisplayModal from './modals/SecretDisplayModal';
import ServerInfoModal from './modals/ServerInfoModal';
import JWKSInfoModal from './modals/JWKSInfoModal';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function OAuth2ClientSettings() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState([]);
const [filteredClients, setFilteredClients] = useState([]);
const [searchKeyword, setSearchKeyword] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingClient, setEditingClient] = useState(null);
const [showSecretModal, setShowSecretModal] = useState(false);
const [currentSecret, setCurrentSecret] = useState('');
const [showServerInfoModal, setShowServerInfoModal] = useState(false);
const [showJWKSModal, setShowJWKSModal] = useState(false);
// 加载客户端列表
const loadClients = async () => {
setLoading(true);
try {
const res = await API.get('/api/oauth_clients/');
if (res.data.success) {
setClients(res.data.data || []);
setFilteredClients(res.data.data || []);
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('加载OAuth2客户端失败'));
} finally {
setLoading(false);
}
};
// 搜索过滤
const handleSearch = (value) => {
setSearchKeyword(value);
if (!value) {
setFilteredClients(clients);
} else {
const filtered = clients.filter(
(client) =>
client.name?.toLowerCase().includes(value.toLowerCase()) ||
client.id?.toLowerCase().includes(value.toLowerCase()) ||
client.description?.toLowerCase().includes(value.toLowerCase()),
);
setFilteredClients(filtered);
}
};
// 删除客户端
const handleDelete = async (client) => {
try {
const res = await API.delete(`/api/oauth_clients/${client.id}`);
if (res.data.success) {
showSuccess(t('删除成功'));
loadClients();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('删除失败'));
}
};
// 重新生成密钥
const handleRegenerateSecret = async (client) => {
try {
const res = await API.post(
`/api/oauth_clients/${client.id}/regenerate_secret`,
);
if (res.data.success) {
setCurrentSecret(res.data.client_secret);
setShowSecretModal(true);
loadClients();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('重新生成密钥失败'));
}
};
// 查看服务器信息
const showServerInfo = () => {
setShowServerInfoModal(true);
};
// 查看JWKS
const showJWKS = () => {
setShowJWKSModal(true);
};
// 表格列定义
const columns = [
{
title: t('客户端名称'),
dataIndex: 'name',
render: (name, record) => (
<div className='flex items-center cursor-help'>
<User size={16} className='mr-1.5 text-gray-500' />
<Tooltip content={record.description || t('暂无描述')} position='top'>
<Text strong>{name}</Text>
</Tooltip>
</div>
),
},
{
title: t('客户端ID'),
dataIndex: 'id',
render: (id) => (
<Text type='tertiary' size='small' code copyable>
{id}
</Text>
),
},
{
title: t('状态'),
dataIndex: 'status',
render: (status) => (
<Tag color={status === 1 ? 'green' : 'red'} shape='circle'>
{status === 1 ? t('启用') : t('禁用')}
</Tag>
),
},
{
title: t('类型'),
dataIndex: 'client_type',
render: (text) => (
<Tag color='white' shape='circle'>
{text === 'confidential' ? t('机密客户端') : t('公开客户端')}
</Tag>
),
},
{
title: t('授权类型'),
dataIndex: 'grant_types',
render: (grantTypes) => {
const types =
typeof grantTypes === 'string'
? grantTypes.split(',')
: grantTypes || [];
const typeMap = {
client_credentials: t('客户端凭证'),
authorization_code: t('授权码'),
refresh_token: t('刷新令牌'),
};
return (
<div className='flex flex-wrap gap-1'>
{types.slice(0, 2).map((type) => (
<Tag color='white' key={type} size='small' shape='circle'>
{typeMap[type] || type}
</Tag>
))}
{types.length > 2 && (
<Tooltip
content={types
.slice(2)
.map((t) => typeMap[t] || t)
.join(', ')}
>
<Tag color='white' size='small' shape='circle'>
+{types.length - 2}
</Tag>
</Tooltip>
)}
</div>
);
},
},
{
title: t('创建时间'),
dataIndex: 'created_time',
render: (time) => new Date(time * 1000).toLocaleString(),
},
{
title: t('操作'),
render: (_, record) => (
<Space size={4} wrap>
<Button
type='primary'
size='small'
onClick={() => {
setEditingClient(record);
setShowModal(true);
}}
>
{t('编辑')}
</Button>
{record.client_type === 'confidential' && (
<Popconfirm
title={t('确认重新生成客户端密钥?')}
content={t('操作不可撤销,旧密钥将立即失效。')}
onConfirm={() => handleRegenerateSecret(record)}
okText={t('确认')}
cancelText={t('取消')}
position='bottomLeft'
>
<Button type='secondary' size='small'>
{t('重新生成密钥')}
</Button>
</Popconfirm>
)}
<Popconfirm
title={t('请再次确认删除该客户端')}
content={t('删除后无法恢复,相关 API 调用将立即失效。')}
onConfirm={() => handleDelete(record)}
okText={t('确定删除')}
cancelText={t('取消')}
position='bottomLeft'
>
<Button type='danger' size='small'>
{t('删除')}
</Button>
</Popconfirm>
</Space>
),
fixed: 'right',
},
];
useEffect(() => {
loadClients();
}, []);
return (
<Card
className='!rounded-2xl shadow-sm border-0'
style={{ marginTop: 10 }}
title={
<div
className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
style={{ paddingRight: '8px' }}
>
<div className='flex items-center'>
<User size={18} className='mr-2' />
<Text strong>{t('OAuth2 客户端管理')}</Text>
<Tag color='white' shape='circle' size='small' className='ml-2'>
{filteredClients.length} {t('个客户端')}
</Tag>
</div>
<div className='flex items-center gap-2 sm:flex-shrink-0 flex-wrap'>
<Input
prefix={<IconSearch />}
placeholder={t('搜索客户端名称、ID或描述')}
value={searchKeyword}
onChange={handleSearch}
showClear
size='small'
style={{ width: 300 }}
/>
<Button type='tertiary' onClick={loadClients} size='small'>
{t('刷新')}
</Button>
<Button type='secondary' onClick={showServerInfo} size='small'>
{t('服务器信息')}
</Button>
<Button type='secondary' onClick={showJWKS} size='small'>
{t('查看JWKS')}
</Button>
<Button
type='primary'
onClick={() => {
setEditingClient(null);
setShowModal(true);
}}
size='small'
>
{t('创建客户端')}
</Button>
</div>
</div>
}
>
<div className='mb-4'>
<Text type='tertiary'>
{t(
'管理OAuth2客户端应用程序每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用公开客户端用于移动应用或单页应用。',
)}
</Text>
</div>
{/* 客户端表格 */}
<Table
columns={columns}
dataSource={filteredClients}
rowKey='id'
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: true,
pageSize: 10,
}}
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
title={t('暂无OAuth2客户端')}
description={t(
'还没有创建任何客户端,点击下方按钮创建第一个客户端',
)}
style={{ padding: 30 }}
>
<Button
type='primary'
onClick={() => {
setEditingClient(null);
setShowModal(true);
}}
>
{t('创建第一个客户端')}
</Button>
</Empty>
}
/>
{/* OAuth2 客户端模态框 */}
<OAuth2ClientModal
visible={showModal}
client={editingClient}
onCancel={() => {
setShowModal(false);
setEditingClient(null);
}}
onSuccess={() => {
setShowModal(false);
setEditingClient(null);
loadClients();
}}
/>
{/* 密钥显示模态框 */}
<SecretDisplayModal
visible={showSecretModal}
onClose={() => setShowSecretModal(false)}
secret={currentSecret}
/>
{/* 服务器信息模态框 */}
<ServerInfoModal
visible={showServerInfoModal}
onClose={() => setShowServerInfoModal(false)}
/>
{/* JWKS信息模态框 */}
<JWKSInfoModal
visible={showJWKSModal}
onClose={() => setShowJWKSModal(false)}
/>
</Card>
);
}

View File

@@ -1,473 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import {
Banner,
Button,
Col,
Form,
Row,
Card,
Typography,
Badge,
Divider,
} from '@douyinfe/semi-ui';
import { Server } from 'lucide-react';
import JWKSManagerModal from './modals/JWKSManagerModal';
import {
compareObjects,
API,
showError,
showSuccess,
showWarning,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export default function OAuth2ServerSettings(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
'oauth2.enabled': false,
'oauth2.issuer': '',
'oauth2.access_token_ttl': 10,
'oauth2.refresh_token_ttl': 720,
'oauth2.jwt_signing_algorithm': 'RS256',
'oauth2.jwt_key_id': 'oauth2-key-1',
'oauth2.allowed_grant_types': [
'client_credentials',
'authorization_code',
'refresh_token',
],
'oauth2.require_pkce': true,
'oauth2.max_jwks_keys': 3,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
const [keysReady, setKeysReady] = useState(true);
const [keysLoading, setKeysLoading] = useState(false);
const [serverInfo, setServerInfo] = useState(null);
const enabledRef = useRef(inputs['oauth2.enabled']);
// 模态框状态
const [jwksVisible, setJwksVisible] = useState(false);
function handleFieldChange(fieldName) {
return (value) => {
setInputs((inputs) => ({ ...inputs, [fieldName]: value }));
};
}
function onSubmit() {
const updateArray = compareObjects(inputs, inputsRow);
if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
const requestQueue = updateArray.map((item) => {
let value = '';
if (typeof inputs[item.key] === 'boolean') {
value = String(inputs[item.key]);
} else if (Array.isArray(inputs[item.key])) {
value = JSON.stringify(inputs[item.key]);
} else {
value = inputs[item.key];
}
return API.put('/api/option/', {
key: item.key,
value,
});
});
setLoading(true);
Promise.all(requestQueue)
.then((res) => {
if (requestQueue.length === 1) {
if (res.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (res.includes(undefined))
return showError(t('部分保存失败,请重试'));
}
showSuccess(t('保存成功'));
if (props && props.refresh) {
props.refresh();
}
})
.catch(() => {
showError(t('保存失败,请重试'));
})
.finally(() => {
setLoading(false);
});
}
// 测试OAuth2连接默认静默仅用户点击时弹提示
const testOAuth2 = async (silent = true) => {
// 未启用时不触发测试,避免 404
if (!enabledRef.current) return;
try {
const res = await API.get('/api/oauth/server-info', {
skipErrorHandler: true,
});
if (!enabledRef.current) return;
if (
res.status === 200 &&
(res.data.issuer || res.data.authorization_endpoint)
) {
if (!silent) showSuccess('OAuth2服务器运行正常');
setServerInfo(res.data);
} else {
if (!enabledRef.current) return;
if (!silent) showError('OAuth2服务器测试失败');
}
} catch (error) {
if (!enabledRef.current) return;
if (!silent) showError('OAuth2服务器连接测试失败');
}
};
useEffect(() => {
if (props && props.options) {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
if (key === 'oauth2.allowed_grant_types') {
try {
currentInputs[key] = JSON.parse(
props.options[key] ||
'["client_credentials","authorization_code","refresh_token"]',
);
} catch {
currentInputs[key] = [
'client_credentials',
'authorization_code',
'refresh_token',
];
}
} else if (typeof inputs[key] === 'boolean') {
currentInputs[key] = props.options[key] === 'true';
} else if (typeof inputs[key] === 'number') {
currentInputs[key] = parseInt(props.options[key]) || inputs[key];
} else {
currentInputs[key] = props.options[key];
}
}
}
setInputs({ ...inputs, ...currentInputs });
setInputsRow(structuredClone({ ...inputs, ...currentInputs }));
if (refForm.current) {
refForm.current.setValues({ ...inputs, ...currentInputs });
}
}
}, [props]);
useEffect(() => {
enabledRef.current = inputs['oauth2.enabled'];
}, [inputs['oauth2.enabled']]);
useEffect(() => {
const loadKeys = async () => {
try {
setKeysLoading(true);
const res = await API.get('/api/oauth/keys', {
skipErrorHandler: true,
});
const list = res?.data?.data || [];
setKeysReady(list.length > 0);
} catch {
setKeysReady(false);
} finally {
setKeysLoading(false);
}
};
if (inputs['oauth2.enabled']) {
loadKeys();
testOAuth2(true);
} else {
// 禁用时清理状态,避免残留状态与不必要的请求
setKeysReady(true);
setServerInfo(null);
setKeysLoading(false);
}
}, [inputs['oauth2.enabled']]);
const isEnabled = inputs['oauth2.enabled'];
return (
<div>
{/* OAuth2 服务端管理 */}
<Card
className='!rounded-2xl shadow-sm border-0'
style={{ marginTop: 10 }}
title={
<div
className='flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-3 sm:gap-0'
style={{ paddingRight: '8px' }}
>
<div className='flex items-center'>
<Server size={18} className='mr-2' />
<Text strong>{t('OAuth2 服务端管理')}</Text>
{isEnabled ? (
serverInfo ? (
<Badge
count={t('运行正常')}
type='success'
style={{ marginLeft: 8 }}
/>
) : (
<Badge
count={t('配置中')}
type='warning'
style={{ marginLeft: 8 }}
/>
)
) : (
<Badge
count={t('未启用')}
type='tertiary'
style={{ marginLeft: 8 }}
/>
)}
</div>
<div className='flex items-center gap-2 sm:flex-shrink-0'>
{isEnabled && (
<Button
type='secondary'
onClick={() => setJwksVisible(true)}
size='small'
>
{t('密钥管理')}
</Button>
)}
<Button
type='primary'
onClick={onSubmit}
loading={loading}
size='small'
>
{t('保存配置')}
</Button>
</div>
</div>
}
>
<Form
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
>
{!keysReady && isEnabled && (
<Banner
type='warning'
className='!rounded-lg'
closeIcon={null}
description={t(
'尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。签名密钥用于 JWT 令牌的安全签发。',
)}
/>
)}
<Row gutter={[16, 24]}>
<Col xs={24} lg={12}>
<Form.Switch
field='oauth2.enabled'
label={t('启用 OAuth2 & SSO')}
value={inputs['oauth2.enabled']}
onChange={handleFieldChange('oauth2.enabled')}
extraText={t('开启后将允许以 OAuth2/OIDC 标准进行授权与登录')}
/>
</Col>
<Col xs={24} lg={12}>
<Form.Input
field='oauth2.issuer'
label={t('发行人 (Issuer)')}
placeholder={window.location.origin}
value={inputs['oauth2.issuer']}
onChange={handleFieldChange('oauth2.issuer')}
extraText={t('为空则按请求自动推断(含 X-Forwarded-Proto')}
/>
</Col>
</Row>
{/* 令牌配置 */}
<Divider margin='24px'>{t('令牌配置')}</Divider>
<Row gutter={[16, 24]}>
<Col xs={24} sm={12} lg={8}>
<Form.InputNumber
field='oauth2.access_token_ttl'
label={t('访问令牌有效期')}
suffix={t('分钟')}
min={1}
max={1440}
value={inputs['oauth2.access_token_ttl']}
onChange={handleFieldChange('oauth2.access_token_ttl')}
extraText={t('访问令牌的有效时间建议较短10-60分钟')}
style={{
width: '100%',
opacity: isEnabled ? 1 : 0.5,
}}
disabled={!isEnabled}
/>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.InputNumber
field='oauth2.refresh_token_ttl'
label={t('刷新令牌有效期')}
suffix={t('小时')}
min={1}
max={8760}
value={inputs['oauth2.refresh_token_ttl']}
onChange={handleFieldChange('oauth2.refresh_token_ttl')}
extraText={t('刷新令牌的有效时间建议较长12-720小时')}
style={{
width: '100%',
opacity: isEnabled ? 1 : 0.5,
}}
disabled={!isEnabled}
/>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.InputNumber
field='oauth2.max_jwks_keys'
label={t('JWKS历史保留上限')}
min={1}
max={10}
value={inputs['oauth2.max_jwks_keys']}
onChange={handleFieldChange('oauth2.max_jwks_keys')}
extraText={t('轮换后最多保留的历史签名密钥数量')}
style={{
width: '100%',
opacity: isEnabled ? 1 : 0.5,
}}
disabled={!isEnabled}
/>
</Col>
</Row>
<Row gutter={[16, 24]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Form.Select
field='oauth2.jwt_signing_algorithm'
label={t('JWT签名算法')}
value={inputs['oauth2.jwt_signing_algorithm']}
onChange={handleFieldChange('oauth2.jwt_signing_algorithm')}
extraText={t('JWT令牌的签名算法推荐使用RS256')}
style={{
width: '100%',
opacity: isEnabled ? 1 : 0.5,
}}
disabled={!isEnabled}
>
<Form.Select.Option value='RS256'>
RS256 (RSA with SHA-256)
</Form.Select.Option>
<Form.Select.Option value='HS256'>
HS256 (HMAC with SHA-256)
</Form.Select.Option>
</Form.Select>
</Col>
<Col xs={24} lg={12}>
<Form.Input
field='oauth2.jwt_key_id'
label={t('JWT密钥ID')}
placeholder='oauth2-key-1'
value={inputs['oauth2.jwt_key_id']}
onChange={handleFieldChange('oauth2.jwt_key_id')}
extraText={t('用于标识JWT签名密钥支持密钥轮换')}
style={{
width: '100%',
opacity: isEnabled ? 1 : 0.5,
}}
disabled={!isEnabled}
/>
</Col>
</Row>
{/* 授权配置 */}
<Divider margin='24px'>{t('授权配置')}</Divider>
<Row gutter={[16, 24]}>
<Col xs={24} lg={12}>
<Form.Select
field='oauth2.allowed_grant_types'
label={t('允许的授权类型')}
multiple
value={inputs['oauth2.allowed_grant_types']}
onChange={handleFieldChange('oauth2.allowed_grant_types')}
extraText={t('选择允许的OAuth2授权流程')}
style={{
width: '100%',
opacity: isEnabled ? 1 : 0.5,
}}
disabled={!isEnabled}
>
<Form.Select.Option value='client_credentials'>
{t('Client Credentials客户端凭证')}
</Form.Select.Option>
<Form.Select.Option value='authorization_code'>
{t('Authorization Code授权码')}
</Form.Select.Option>
<Form.Select.Option value='refresh_token'>
{t('Refresh Token刷新令牌')}
</Form.Select.Option>
</Form.Select>
</Col>
<Col xs={24} lg={12}>
<Form.Switch
field='oauth2.require_pkce'
label={t('强制PKCE验证')}
value={inputs['oauth2.require_pkce']}
onChange={handleFieldChange('oauth2.require_pkce')}
extraText={t('为授权码流程强制启用PKCE提高安全性')}
disabled={!isEnabled}
/>
</Col>
</Row>
<div style={{ marginTop: 16 }}>
<Text type='tertiary' size='small'>
<div className='space-y-1'>
<div> {t('OAuth2 服务器提供标准的 API 认证与授权')}</div>
<div>
{' '}
{t(
'支持 Client Credentials、Authorization Code + PKCE 等标准流程',
)}
</div>
<div>
{' '}
{t(
'配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作',
)}
</div>
<div>
{t('生产环境务必启用 HTTPS并妥善管理 JWT 签名密钥')}
</div>
</div>
</Text>
</div>
</Form>
</Card>
{/* 模态框 */}
<JWKSManagerModal
visible={jwksVisible}
onClose={() => setJwksVisible(false)}
/>
</div>
);
}

View File

@@ -1,78 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal, Banner, Typography } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const ClientInfoModal = ({ visible, onClose, clientId, clientSecret }) => {
const { t } = useTranslation();
return (
<Modal
title={t('客户端创建成功')}
visible={visible}
onCancel={onClose}
onOk={onClose}
cancelText=''
okText={t('我已复制保存')}
width={650}
bodyStyle={{ padding: '20px 24px' }}
>
<Banner
type='success'
closeIcon={null}
description={t(
'客户端信息如下,请立即复制保存。关闭此窗口后将无法再次查看密钥。',
)}
className='mb-5 !rounded-lg'
/>
<div className='space-y-4'>
<div className='flex justify-center items-center'>
<div className='text-center'>
<Text strong className='block mb-2'>
{t('客户端ID')}
</Text>
<Text code copyable>
{clientId}
</Text>
</div>
</div>
{clientSecret && (
<div className='flex justify-center items-center'>
<div className='text-center'>
<Text strong className='block mb-2'>
{t('客户端密钥(仅此一次显示)')}
</Text>
<Text code copyable>
{clientSecret}
</Text>
</div>
</div>
)}
</div>
</Modal>
);
};
export default ClientInfoModal;

View File

@@ -1,70 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import CodeViewer from '../../../common/ui/CodeViewer';
const JWKSInfoModal = ({ visible, onClose }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [jwksInfo, setJwksInfo] = useState(null);
const loadJWKSInfo = async () => {
setLoading(true);
try {
const res = await API.get('/api/oauth/jwks');
setJwksInfo(res.data);
} catch (error) {
showError(t('获取JWKS失败'));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
loadJWKSInfo();
}
}, [visible]);
return (
<Modal
title={t('JWKS 信息')}
visible={visible}
onCancel={onClose}
onOk={onClose}
cancelText=''
okText={t('关闭')}
width={650}
bodyStyle={{ padding: '20px 24px' }}
confirmLoading={loading}
>
<CodeViewer
content={jwksInfo ? JSON.stringify(jwksInfo, null, 2) : t('加载中...')}
title={t('JWKS 密钥集')}
language='json'
/>
</Modal>
);
};
export default JWKSInfoModal;

View File

@@ -1,399 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Space,
Tag,
Typography,
Popconfirm,
Toast,
Form,
Card,
Tabs,
TabPane,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import ResponsiveModal from '../../../common/ui/ResponsiveModal';
const { Text } = Typography;
// 操作模式枚举
const OPERATION_MODES = {
VIEW: 'view',
IMPORT: 'import',
GENERATE: 'generate',
};
export default function JWKSManagerModal({ visible, onClose }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [keys, setKeys] = useState([]);
const [activeTab, setActiveTab] = useState(OPERATION_MODES.VIEW);
const load = async () => {
setLoading(true);
try {
const res = await API.get('/api/oauth/keys');
if (res?.data?.success) setKeys(res.data.data || []);
else showError(res?.data?.message || t('获取密钥列表失败'));
} catch {
showError(t('获取密钥列表失败'));
} finally {
setLoading(false);
}
};
const rotate = async () => {
setLoading(true);
try {
const res = await API.post('/api/oauth/keys/rotate', {});
if (res?.data?.success) {
showSuccess(t('签名密钥已轮换:{{kid}}', { kid: res.data.kid }));
await load();
} else showError(res?.data?.message || t('密钥轮换失败'));
} catch {
showError(t('密钥轮换失败'));
} finally {
setLoading(false);
}
};
const del = async (kid) => {
setLoading(true);
try {
const res = await API.delete(`/api/oauth/keys/${kid}`);
if (res?.data?.success) {
Toast.success(t('已删除:{{kid}}', { kid }));
await load();
} else showError(res?.data?.message || t('删除失败'));
} catch {
showError(t('删除失败'));
} finally {
setLoading(false);
}
};
// Import PEM state
const [pem, setPem] = useState('');
const [customKid, setCustomKid] = useState('');
// Generate PEM file state
const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
const [genKid, setGenKid] = useState('');
// 重置表单数据
const resetForms = () => {
setPem('');
setCustomKid('');
setGenKid('');
};
useEffect(() => {
if (visible) {
load();
// 重置到主视图
setActiveTab(OPERATION_MODES.VIEW);
} else {
// 模态框关闭时重置表单数据
resetForms();
}
}, [visible]);
useEffect(() => {
if (!visible) return;
(async () => {
try {
const res = await API.get('/api/oauth/server-info');
const p = res?.data?.default_private_key_path;
if (p) setGenPath(p);
} catch {}
})();
}, [visible]);
// 导入 PEM 私钥
const importPem = async () => {
if (!pem.trim()) return Toast.warning(t('请粘贴 PEM 私钥'));
setLoading(true);
try {
const res = await API.post('/api/oauth/keys/import_pem', {
pem,
kid: customKid.trim(),
});
if (res?.data?.success) {
Toast.success(
t('已导入私钥并切换到 kid={{kid}}', { kid: res.data.kid }),
);
resetForms();
setActiveTab(OPERATION_MODES.VIEW);
await load();
} else {
Toast.error(res?.data?.message || t('导入失败'));
}
} catch {
Toast.error(t('导入失败'));
} finally {
setLoading(false);
}
};
// 生成 PEM 文件
const generatePemFile = async () => {
if (!genPath.trim()) return Toast.warning(t('请填写保存路径'));
setLoading(true);
try {
const res = await API.post('/api/oauth/keys/generate_file', {
path: genPath.trim(),
kid: genKid.trim(),
});
if (res?.data?.success) {
Toast.success(t('已生成并生效:{{path}}', { path: res.data.path }));
resetForms();
setActiveTab(OPERATION_MODES.VIEW);
await load();
} else {
Toast.error(res?.data?.message || t('生成失败'));
}
} catch {
Toast.error(t('生成失败'));
} finally {
setLoading(false);
}
};
const columns = [
{
title: 'KID',
dataIndex: 'kid',
render: (kid) => (
<Text code copyable>
{kid}
</Text>
),
},
{
title: t('创建时间'),
dataIndex: 'created_at',
render: (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '-'),
},
{
title: t('状态'),
dataIndex: 'current',
render: (cur) =>
cur ? (
<Tag color='green' shape='circle'>
{t('当前')}
</Tag>
) : (
<Tag shape='circle'>{t('历史')}</Tag>
),
},
{
title: t('操作'),
render: (_, r) => (
<Space>
{!r.current && (
<Popconfirm
title={t('确定删除密钥 {{kid}} ', { kid: r.kid })}
content={t(
'删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)',
)}
okText={t('删除')}
onConfirm={() => del(r.kid)}
>
<Button size='small' type='danger'>
{t('删除')}
</Button>
</Popconfirm>
)}
</Space>
),
},
];
// 头部操作按钮 - 根据当前标签页动态生成
const getHeaderActions = () => {
if (activeTab === OPERATION_MODES.VIEW) {
const hasKeys = Array.isArray(keys) && keys.length > 0;
return [
<Button key='refresh' onClick={load} loading={loading} size='small'>
{t('刷新')}
</Button>,
<Button
key='rotate'
type='primary'
onClick={rotate}
loading={loading}
size='small'
>
{hasKeys ? t('轮换密钥') : t('初始化密钥')}
</Button>,
];
}
if (activeTab === OPERATION_MODES.IMPORT) {
return [
<Button
key='import'
type='primary'
onClick={importPem}
loading={loading}
size='small'
>
{t('导入并生效')}
</Button>,
];
}
if (activeTab === OPERATION_MODES.GENERATE) {
return [
<Button
key='generate'
type='primary'
onClick={generatePemFile}
loading={loading}
size='small'
>
{t('生成并生效')}
</Button>,
];
}
return [];
};
// 渲染密钥列表视图
const renderKeysView = () => (
<Card
className='!rounded-lg'
title={
<Text className='text-blue-700 dark:text-blue-300'>
{t(
'提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。',
)}
</Text>
}
>
<Table
dataSource={keys}
columns={columns}
rowKey='kid'
loading={loading}
pagination={false}
empty={<Text type='tertiary'>{t('暂无密钥')}</Text>}
/>
</Card>
);
// 渲染导入 PEM 私钥视图
const renderImportView = () => (
<Card
className='!rounded-lg'
title={
<Text className='text-yellow-700 dark:text-yellow-300'>
{t(
'建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。',
)}
</Text>
}
>
<Form labelPosition='left' labelWidth={120}>
<Form.Input
field='kid'
label={t('自定义 KID')}
placeholder={t('可留空自动生成')}
value={customKid}
onChange={setCustomKid}
/>
<Form.TextArea
field='pem'
label={t('PEM 私钥')}
value={pem}
onChange={setPem}
rows={8}
placeholder={
'-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----'
}
/>
</Form>
</Card>
);
// 渲染生成 PEM 文件视图
const renderGenerateView = () => (
<Card
className='!rounded-lg'
title={
<Text className='text-orange-700 dark:text-orange-300'>
{t(
'建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600并妥善备份。',
)}
</Text>
}
>
<Form labelPosition='left' labelWidth={120}>
<Form.Input
field='path'
label={t('保存路径')}
value={genPath}
onChange={setGenPath}
placeholder='/secure/path/oauth2-private.pem'
/>
<Form.Input
field='genKid'
label={t('自定义 KID')}
value={genKid}
onChange={setGenKid}
placeholder={t('可留空自动生成')}
/>
</Form>
</Card>
);
return (
<ResponsiveModal
visible={visible}
title={t('JWKS 管理')}
headerActions={getHeaderActions()}
onCancel={onClose}
footer={null}
width={{ mobile: '95%', desktop: 800 }}
>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
type='card'
size='medium'
className='!-mt-2'
>
<TabPane tab={t('密钥列表')} itemKey={OPERATION_MODES.VIEW}>
{renderKeysView()}
</TabPane>
<TabPane tab={t('导入 PEM 私钥')} itemKey={OPERATION_MODES.IMPORT}>
{renderImportView()}
</TabPane>
<TabPane tab={t('生成 PEM 文件')} itemKey={OPERATION_MODES.GENERATE}>
{renderGenerateView()}
</TabPane>
</Tabs>
</ResponsiveModal>
);
}

View File

@@ -1,730 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import {
SideSheet,
Form,
Input,
Select,
Space,
Typography,
Button,
Card,
Avatar,
Tag,
Spin,
Radio,
Divider,
} from '@douyinfe/semi-ui';
import {
IconKey,
IconLink,
IconSave,
IconClose,
IconPlus,
IconDelete,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
import ClientInfoModal from './ClientInfoModal';
const { Text, Title } = Typography;
const { Option } = Select;
const AUTH_CODE = 'authorization_code';
const CLIENT_CREDENTIALS = 'client_credentials';
// 子组件重定向URI编辑卡片
function RedirectUriCard({
t,
isAuthCodeSelected,
redirectUris,
onAdd,
onUpdate,
onRemove,
onFillTemplate,
}) {
return (
<Card
header={
<div className='flex justify-between items-center'>
<div className='flex items-center'>
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
<IconLink size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('重定向URI配置')}</Text>
<div className='text-xs text-gray-600'>
{t('用于授权码流程的重定向地址')}
</div>
</div>
</div>
<Button
type='tertiary'
onClick={onFillTemplate}
size='small'
disabled={!isAuthCodeSelected}
>
{t('填入示例模板')}
</Button>
</div>
}
headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }}
className='!rounded-2xl shadow-sm border-0'
>
<div className='space-y-1'>
{redirectUris.length === 0 && (
<div className='text-center py-4 px-4'>
<Text type='tertiary' className='text-gray-500 text-sm'>
{t('暂无重定向URI点击下方按钮添加')}
</Text>
</div>
)}
{redirectUris.map((uri, index) => (
<div
key={index}
style={{
marginBottom: 8,
display: 'flex',
gap: 8,
alignItems: 'center',
}}
>
<Input
placeholder={t('例如https://your-app.com/callback')}
value={uri}
onChange={(value) => onUpdate(index, value)}
style={{ flex: 1 }}
disabled={!isAuthCodeSelected}
/>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
onClick={() => onRemove(index)}
disabled={!isAuthCodeSelected}
/>
</div>
))}
<div className='py-2 flex justify-center gap-2'>
<Button
icon={<IconPlus />}
type='primary'
theme='outline'
onClick={onAdd}
disabled={!isAuthCodeSelected}
>
{t('添加重定向URI')}
</Button>
</div>
</div>
<Divider margin='12px' align='center'>
<Text type='tertiary' size='small'>
{isAuthCodeSelected
? t(
'用户授权后将重定向到这些URI。必须使用HTTPS本地开发可使用HTTP仅限localhost/127.0.0.1',
)
: t('仅在选择“授权码”授权类型时需要配置重定向URI')}
</Text>
</Divider>
</Card>
);
}
const OAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const formApiRef = useRef(null);
const [loading, setLoading] = useState(false);
const [redirectUris, setRedirectUris] = useState([]);
const [clientType, setClientType] = useState('confidential');
const [grantTypes, setGrantTypes] = useState([]);
const [allowedGrantTypes, setAllowedGrantTypes] = useState([
CLIENT_CREDENTIALS,
AUTH_CODE,
'refresh_token',
]);
// ClientInfoModal 状态
const [showClientInfo, setShowClientInfo] = useState(false);
const [clientInfo, setClientInfo] = useState({
clientId: '',
clientSecret: '',
});
const isEdit = client?.id !== undefined;
const [mode, setMode] = useState('create'); // 'create' | 'edit'
useEffect(() => {
if (visible) {
setMode(isEdit ? 'edit' : 'create');
}
}, [visible, isEdit]);
const getInitValues = () => ({
name: '',
description: '',
client_type: 'confidential',
grant_types: [],
scopes: [],
require_pkce: true,
status: 1,
});
// 加载后端允许的授权类型
useEffect(() => {
let mounted = true;
(async () => {
try {
const res = await API.get('/api/option/');
const { success, data } = res.data || {};
if (!success || !Array.isArray(data)) return;
const found = data.find((i) => i.key === 'oauth2.allowed_grant_types');
if (!found) return;
let parsed = [];
try {
parsed = JSON.parse(found.value || '[]');
} catch (_) {}
if (mounted && Array.isArray(parsed) && parsed.length) {
setAllowedGrantTypes(parsed);
}
} catch (_) {
// 忽略错误使用默认allowedGrantTypes
}
})();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
setGrantTypes((prev) => {
const normalizedPrev = Array.isArray(prev) ? prev : [];
// 移除不被允许或与客户端类型冲突的类型
let next = normalizedPrev.filter((g) => allowedGrantTypes.includes(g));
if (clientType === 'public') {
next = next.filter((g) => g !== CLIENT_CREDENTIALS);
}
return next.length ? next : [];
});
}, [clientType, allowedGrantTypes]);
// 初始化表单数据(编辑模式)
useEffect(() => {
if (client && visible && isEdit) {
setLoading(true);
// 解析授权类型
let parsedGrantTypes = [];
if (typeof client.grant_types === 'string') {
parsedGrantTypes = client.grant_types.split(',');
} else if (Array.isArray(client.grant_types)) {
parsedGrantTypes = client.grant_types;
}
// 解析Scope
let parsedScopes = [];
if (typeof client.scopes === 'string') {
parsedScopes = client.scopes.split(',');
} else if (Array.isArray(client.scopes)) {
parsedScopes = client.scopes;
}
if (!parsedScopes || parsedScopes.length === 0) {
parsedScopes = ['openid', 'profile', 'email', 'api:read'];
}
// 解析重定向URI
let parsedRedirectUris = [];
if (client.redirect_uris) {
try {
const parsed =
typeof client.redirect_uris === 'string'
? JSON.parse(client.redirect_uris)
: client.redirect_uris;
if (Array.isArray(parsed) && parsed.length > 0) {
parsedRedirectUris = parsed;
}
} catch (e) {}
}
// 过滤不被允许或不兼容的授权类型
const filteredGrantTypes = (parsedGrantTypes || []).filter((g) =>
allowedGrantTypes.includes(g),
);
const finalGrantTypes =
client.client_type === 'public'
? filteredGrantTypes.filter((g) => g !== CLIENT_CREDENTIALS)
: filteredGrantTypes;
setClientType(client.client_type);
setGrantTypes(finalGrantTypes);
// 不自动新增空白URI保持与创建模式一致的手动添加体验
setRedirectUris(parsedRedirectUris);
// 设置表单值
const formValues = {
id: client.id,
name: client.name,
description: client.description,
client_type: client.client_type,
grant_types: finalGrantTypes,
scopes: parsedScopes,
require_pkce: !!client.require_pkce,
status: client.status,
};
setTimeout(() => {
if (formApiRef.current) {
formApiRef.current.setValues(formValues);
}
setLoading(false);
}, 100);
} else if (visible && !isEdit) {
// 创建模式,重置状态
setClientType('confidential');
setGrantTypes([]);
setRedirectUris([]);
if (formApiRef.current) {
formApiRef.current.setValues(getInitValues());
}
}
}, [client, visible, isEdit, allowedGrantTypes]);
const isAuthCodeSelected = grantTypes.includes(AUTH_CODE);
const isGrantTypeDisabled = (value) => {
if (!allowedGrantTypes.includes(value)) return true;
if (clientType === 'public' && value === CLIENT_CREDENTIALS) return true;
return false;
};
// URL校验允许 httpshttp 仅限本地开发域名
const isValidRedirectUri = (uri) => {
if (!uri || !uri.trim()) return false;
try {
const u = new URL(uri.trim());
if (u.protocol === 'https:') return true;
if (u.protocol === 'http:') {
const host = u.hostname;
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host.endsWith('.local')
);
}
return false;
} catch (_) {
return false;
}
};
// 处理提交
const handleSubmit = async (values) => {
setLoading(true);
try {
// 过滤空的重定向URI
const validRedirectUris = redirectUris
.map((u) => (u || '').trim())
.filter((u) => u.length > 0);
// 业务校验
if (!grantTypes.length) {
showError(t('请至少选择一种授权类型'));
setLoading(false);
return;
}
// 校验是否包含不被允许的授权类型
const invalids = grantTypes.filter((g) => !allowedGrantTypes.includes(g));
if (invalids.length) {
showError(
t('不被允许的授权类型: {{types}}', { types: invalids.join(', ') }),
);
setLoading(false);
return;
}
if (clientType === 'public' && grantTypes.includes(CLIENT_CREDENTIALS)) {
showError(t('公开客户端不允许使用client_credentials授权类型'));
setLoading(false);
return;
}
if (grantTypes.includes(AUTH_CODE)) {
if (!validRedirectUris.length) {
showError(t('选择授权码授权类型时必须填写至少一个重定向URI'));
setLoading(false);
return;
}
const allValid = validRedirectUris.every(isValidRedirectUri);
if (!allValid) {
showError(t('重定向URI格式不合法仅支持https或本地开发使用http'));
setLoading(false);
return;
}
}
// 避免把 Radio 组件对象形式的 client_type 直接传给后端
const { client_type: _formClientType, ...restValues } = values || {};
const payload = {
...restValues,
client_type: clientType,
grant_types: grantTypes,
redirect_uris: validRedirectUris,
};
let res;
if (isEdit) {
res = await API.put('/api/oauth_clients/', payload);
} else {
res = await API.post('/api/oauth_clients/', payload);
}
const { success, message, client_id, client_secret } = res.data;
if (success) {
if (isEdit) {
showSuccess(t('OAuth2客户端更新成功'));
resetForm();
onSuccess();
} else {
showSuccess(t('OAuth2客户端创建成功'));
// 显示客户端信息
setClientInfo({
clientId: client_id,
clientSecret: client_secret,
});
setShowClientInfo(true);
}
} else {
showError(message);
}
} catch (error) {
showError(isEdit ? t('更新OAuth2客户端失败') : t('创建OAuth2客户端失败'));
} finally {
setLoading(false);
}
};
// 重置表单
const resetForm = () => {
if (formApiRef.current) {
formApiRef.current.reset();
}
setClientType('confidential');
setGrantTypes([]);
setRedirectUris([]);
};
// 处理ClientInfoModal关闭
const handleClientInfoClose = () => {
setShowClientInfo(false);
setClientInfo({ clientId: '', clientSecret: '' });
resetForm();
onSuccess();
};
// 处理取消
const handleCancel = () => {
resetForm();
onCancel();
};
// 添加重定向URI
const addRedirectUri = () => {
setRedirectUris([...redirectUris, '']);
};
// 删除重定向URI
const removeRedirectUri = (index) => {
setRedirectUris(redirectUris.filter((_, i) => i !== index));
};
// 更新重定向URI
const updateRedirectUri = (index, value) => {
const newUris = [...redirectUris];
newUris[index] = value;
setRedirectUris(newUris);
};
// 填入示例重定向URI模板
const fillRedirectUriTemplate = () => {
const template = [
'https://your-app.com/auth/callback',
'https://localhost:3000/callback',
];
setRedirectUris(template);
};
// 授权类型变化处理(清理非法项,只设置一次)
const handleGrantTypesChange = (values) => {
const allowed = Array.isArray(values)
? values.filter((v) => allowedGrantTypes.includes(v))
: [];
const sanitized =
clientType === 'public'
? allowed.filter((v) => v !== CLIENT_CREDENTIALS)
: allowed;
setGrantTypes(sanitized);
if (formApiRef.current) {
formApiRef.current.setValue('grant_types', sanitized);
}
};
// 客户端类型变化处理(兼容 RadioGroup 事件对象与直接值)
const handleClientTypeChange = (next) => {
const value = next && next.target ? next.target.value : next;
setClientType(value);
// 公开客户端自动移除 client_credentials并同步表单字段
const current = Array.isArray(grantTypes) ? grantTypes : [];
const sanitized =
value === 'public'
? current.filter((g) => g !== CLIENT_CREDENTIALS)
: current;
if (sanitized !== current) {
setGrantTypes(sanitized);
if (formApiRef.current) {
formApiRef.current.setValue('grant_types', sanitized);
}
}
};
return (
<SideSheet
placement={mode === 'edit' ? 'right' : 'left'}
title={
<Space>
{mode === 'edit' ? (
<Tag color='blue' shape='circle'>
{t('编辑')}
</Tag>
) : (
<Tag color='green' shape='circle'>
{t('创建')}
</Tag>
)}
<Title heading={4} className='m-0'>
{mode === 'edit' ? t('编辑OAuth2客户端') : t('创建OAuth2客户端')}
</Title>
</Space>
}
bodyStyle={{ padding: '0' }}
visible={visible}
width={isMobile ? '100%' : 700}
footer={
<div className='flex justify-end bg-white'>
<Space>
<Button
theme='solid'
className='!rounded-lg'
onClick={() => formApiRef.current?.submitForm()}
icon={<IconSave />}
loading={loading}
>
{isEdit ? t('保存') : t('创建')}
</Button>
<Button
theme='light'
className='!rounded-lg'
type='primary'
onClick={handleCancel}
icon={<IconClose />}
>
{t('取消')}
</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={handleCancel}
>
<Spin spinning={loading}>
<Form
key={isEdit ? `edit-${client?.id}` : 'create'}
initValues={getInitValues()}
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={handleSubmit}
>
{() => (
<div className='p-2'>
{/* 表单内容 */}
{/* 基本信息 */}
<Card className='!rounded-2xl shadow-sm border-0'>
<div className='flex items-center mb-4'>
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
<IconKey size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
<div className='text-xs text-gray-600'>
{t('设置客户端的基本信息')}
</div>
</div>
</div>
{isEdit && (
<>
<Form.Select
field='status'
label={t('状态')}
rules={[{ required: true, message: t('请选择状态') }]}
required
>
<Option value={1}>{t('启用')}</Option>
<Option value={2}>{t('禁用')}</Option>
</Form.Select>
<Form.Input field='id' label={t('客户端ID')} disabled />
</>
)}
<Form.Input
field='name'
label={t('客户端名称')}
placeholder={t('输入客户端名称')}
rules={[{ required: true, message: t('请输入客户端名称') }]}
required
showClear
/>
<Form.TextArea
field='description'
label={t('描述')}
placeholder={t('输入客户端描述')}
rows={3}
showClear
/>
<Form.RadioGroup
label={t('客户端类型')}
field='client_type'
value={clientType}
onChange={handleClientTypeChange}
type='card'
aria-label={t('选择客户端类型')}
disabled={isEdit}
rules={[{ required: true, message: t('请选择客户端类型') }]}
required
>
<Radio
value='confidential'
extra={t('服务器端应用,安全地存储客户端密钥')}
style={{ width: isMobile ? '100%' : 'auto' }}
>
{t('机密客户端Confidential')}
</Radio>
<Radio
value='public'
extra={t('移动应用或单页应用,无法安全存储密钥')}
style={{ width: isMobile ? '100%' : 'auto' }}
>
{t('公开客户端Public')}
</Radio>
</Form.RadioGroup>
<Form.Select
field='grant_types'
label={t('允许的授权类型')}
multiple
value={grantTypes}
onChange={handleGrantTypesChange}
rules={[
{ required: true, message: t('请选择至少一种授权类型') },
]}
required
placeholder={t('请选择授权类型(可多选)')}
>
{clientType !== 'public' && (
<Option
value={CLIENT_CREDENTIALS}
disabled={isGrantTypeDisabled(CLIENT_CREDENTIALS)}
>
{t('Client Credentials客户端凭证')}
</Option>
)}
<Option
value={AUTH_CODE}
disabled={isGrantTypeDisabled(AUTH_CODE)}
>
{t('Authorization Code授权码')}
</Option>
<Option
value='refresh_token'
disabled={isGrantTypeDisabled('refresh_token')}
>
{t('Refresh Token刷新令牌')}
</Option>
</Form.Select>
<Form.Select
field='scopes'
label={t('允许的权限范围Scope')}
multiple
rules={[
{ required: true, message: t('请选择至少一个权限范围') },
]}
required
placeholder={t('请选择权限范围(可多选)')}
>
<Option value='openid'>{t('openidOIDC 基础身份)')}</Option>
<Option value='profile'>
{t('profile用户名/昵称等)')}
</Option>
<Option value='email'>{t('email邮箱信息')}</Option>
<Option value='api:read'>
{`api:read (${t('读取API')})`}
</Option>
<Option value='api:write'>
{`api:write (${t('写入API')})`}
</Option>
<Option value='admin'>{t('admin管理员权限')}</Option>
</Form.Select>
<Form.Switch
field='require_pkce'
label={t('强制PKCE验证')}
size='large'
extraText={t(
'PKCEProof Key for Code Exchange可提高授权码流程的安全性。',
)}
/>
</Card>
{/* 重定向URI */}
<RedirectUriCard
t={t}
isAuthCodeSelected={isAuthCodeSelected}
redirectUris={redirectUris}
onAdd={addRedirectUri}
onUpdate={updateRedirectUri}
onRemove={removeRedirectUri}
onFillTemplate={fillRedirectUriTemplate}
/>
</div>
)}
</Form>
</Spin>
{/* 客户端信息展示模态框 */}
<ClientInfoModal
visible={showClientInfo}
onClose={handleClientInfoClose}
clientId={clientInfo.clientId}
clientSecret={clientInfo.clientSecret}
/>
</SideSheet>
);
};
export default OAuth2ClientModal;

View File

@@ -1,57 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React from 'react';
import { Modal, Banner, Typography } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const SecretDisplayModal = ({ visible, onClose, secret }) => {
const { t } = useTranslation();
return (
<Modal
title={t('客户端密钥已重新生成')}
visible={visible}
onCancel={onClose}
onOk={onClose}
cancelText=''
okText={t('我已复制保存')}
width={650}
bodyStyle={{ padding: '20px 24px' }}
>
<Banner
type='success'
closeIcon={null}
description={t(
'新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。',
)}
className='mb-5 !rounded-lg'
/>
<div className='flex justify-center items-center'>
<Text code copyable>
{secret}
</Text>
</div>
</Modal>
);
};
export default SecretDisplayModal;

View File

@@ -1,72 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect } from 'react';
import { Modal } from '@douyinfe/semi-ui';
import { API, showError } from '../../../../helpers';
import { useTranslation } from 'react-i18next';
import CodeViewer from '../../../common/ui/CodeViewer';
const ServerInfoModal = ({ visible, onClose }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [serverInfo, setServerInfo] = useState(null);
const loadServerInfo = async () => {
setLoading(true);
try {
const res = await API.get('/api/oauth/server-info');
setServerInfo(res.data);
} catch (error) {
showError(t('获取服务器信息失败'));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
loadServerInfo();
}
}, [visible]);
return (
<Modal
title={t('OAuth2 服务器信息')}
visible={visible}
onCancel={onClose}
onOk={onClose}
cancelText=''
okText={t('关闭')}
width={650}
bodyStyle={{ padding: '20px 24px' }}
confirmLoading={loading}
>
<CodeViewer
content={
serverInfo ? JSON.stringify(serverInfo, null, 2) : t('加载中...')
}
title={t('OAuth2 服务器配置')}
language='json'
/>
</Modal>
);
};
export default ServerInfoModal;

View File

@@ -40,7 +40,7 @@ import {
showSuccess,
showError,
} from '../../../../helpers';
import CodeViewer from '../../../common/ui/CodeViewer';
import CodeViewer from '../../../playground/CodeViewer';
import { StatusContext } from '../../../../context/Status';
import { UserContext } from '../../../../context/User';
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';

View File

@@ -802,9 +802,7 @@ const EditChannelModal = (props) => {
delete localInputs.key;
}
} else {
localInputs.key = batch
? JSON.stringify(keys)
: JSON.stringify(keys[0]);
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
}
}
}
@@ -1200,10 +1198,7 @@ const EditChannelModal = (props) => {
value={inputs.vertex_key_type || 'json'}
onChange={(value) => {
// 更新设置中的 vertex_key_type
handleChannelOtherSettingsChange(
'vertex_key_type',
value,
);
handleChannelOtherSettingsChange('vertex_key_type', value);
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
if (value === 'api_key') {
setBatch(false);
@@ -1223,8 +1218,7 @@ const EditChannelModal = (props) => {
/>
)}
{batch ? (
inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
<Form.Upload
field='vertex_files'
label={t('密钥文件 (.json)')}
@@ -1288,8 +1282,7 @@ const EditChannelModal = (props) => {
)
) : (
<>
{inputs.type === 41 &&
(inputs.vertex_key_type || 'json') === 'json' ? (
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
<>
{!batch && (
<div className='flex items-center justify-between mb-3'>

View File

@@ -30,8 +30,7 @@ import {
Space,
Row,
Col,
Spin,
Tooltip,
Spin, Tooltip
} from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
@@ -267,8 +266,7 @@ const RechargeCard = ({
{payMethods && payMethods.length > 0 ? (
<Space wrap>
{payMethods.map((payMethod) => {
const minTopupVal =
Number(payMethod.min_topup) || 0;
const minTopupVal = Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const disabled =
(!enableOnlineTopUp && !isStripe) ||
@@ -282,9 +280,7 @@ const RechargeCard = ({
type='tertiary'
onClick={() => preTopUp(payMethod.type)}
disabled={disabled}
loading={
paymentLoading && payWay === payMethod.type
}
loading={paymentLoading && payWay === payMethod.type}
icon={
payMethod.type === 'alipay' ? (
<SiAlipay size={18} color='#1677FF' />
@@ -295,10 +291,7 @@ const RechargeCard = ({
) : (
<CreditCard
size={18}
color={
payMethod.color ||
'var(--semi-color-text-2)'
}
color={payMethod.color || 'var(--semi-color-text-2)'}
/>
)
}
@@ -308,22 +301,12 @@ const RechargeCard = ({
</Button>
);
return disabled &&
minTopupVal > Number(topUpCount || 0) ? (
<Tooltip
content={
t('此支付方式最低充值金额为') +
' ' +
minTopupVal
}
key={payMethod.type}
>
return disabled && minTopupVal > Number(topUpCount || 0) ? (
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
{buttonEl}
</Tooltip>
) : (
<React.Fragment key={payMethod.type}>
{buttonEl}
</React.Fragment>
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
);
})}
</Space>
@@ -341,27 +324,23 @@ const RechargeCard = ({
<Form.Slot label={t('选择充值额度')}>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => {
const discount =
preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0;
const actualPay = discountedPrice;
const save = originalPrice - discountedPrice;
return (
<Card
key={index}
style={{
cursor: 'pointer',
border:
selectedPreset === preset.value
? '2px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
border: selectedPreset === preset.value
? '2px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
height: '100%',
width: '100%',
width: '100%'
}}
bodyStyle={{ padding: '12px' }}
onClick={() => {
@@ -373,35 +352,24 @@ const RechargeCard = ({
}}
>
<div style={{ textAlign: 'center' }}>
<Typography.Title
heading={6}
style={{ margin: '0 0 8px 0' }}
>
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
<Coins size={18} />
{formatLargeNumber(preset.value)}
{hasDiscount && (
<Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off')
? (
(1 - parseFloat(discount)) *
100
).toFixed(1)
: (discount * 10).toFixed(1)}
{t('折')}
</Tag>
<Tag style={{ marginLeft: 4 }} color="green">
{t('折').includes('off') ?
((1 - parseFloat(discount)) * 100).toFixed(1) :
(discount * 10).toFixed(1)}{t('折')}
</Tag>
)}
</Typography.Title>
<div
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
margin: '4px 0',
}}
>
<div style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
margin: '4px 0'
}}>
{t('实付')} {actualPay.toFixed(2)}
{hasDiscount
? `${t('节省')} ${save.toFixed(2)}`
: `${t('节省')} 0.00`}
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
</div>
</div>
</Card>

View File

@@ -80,11 +80,11 @@ const TopUp = () => {
// 预设充值额度选项
const [presetAmounts, setPresetAmounts] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null);
// 充值配置信息
const [topupInfo, setTopupInfo] = useState({
amount_options: [],
discount: {},
discount: {}
});
const topUp = async () => {
@@ -262,9 +262,9 @@ const TopUp = () => {
if (success) {
setTopupInfo({
amount_options: data.amount_options || [],
discount: data.discount || {},
discount: data.discount || {}
});
// 处理支付方式
let payMethods = data.pay_methods || [];
try {
@@ -280,15 +280,10 @@ const TopUp = () => {
payMethods = payMethods.map((method) => {
// 规范化最小充值数
const normalizedMinTopup = Number(method.min_topup);
method.min_topup = Number.isFinite(normalizedMinTopup)
? normalizedMinTopup
: 0;
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
// Stripe 的最小充值从后端字段回填
if (
method.type === 'stripe' &&
(!method.min_topup || method.min_topup <= 0)
) {
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
const stripeMin = Number(data.stripe_min_topup);
if (Number.isFinite(stripeMin)) {
method.min_topup = stripeMin;
@@ -318,11 +313,7 @@ const TopUp = () => {
setPayMethods(payMethods);
const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_topup || false;
const minTopUpValue = enableOnlineTopUp
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: 1;
const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setMinTopUp(minTopUpValue);
@@ -339,12 +330,12 @@ const TopUp = () => {
console.log('解析支付方式失败:', e);
setPayMethods([]);
}
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
if (data.amount_options && data.amount_options.length > 0) {
const customPresets = data.amount_options.map((amount) => ({
const customPresets = data.amount_options.map(amount => ({
value: amount,
discount: data.discount[amount] || 1.0,
discount: data.discount[amount] || 1.0
}));
setPresetAmounts(customPresets);
}
@@ -492,7 +483,7 @@ const TopUp = () => {
const selectPresetAmount = (preset) => {
setTopUpCount(preset.value);
setSelectedPreset(preset.value);
// 计算实际支付金额,考虑折扣
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
const discountedAmount = preset.value * priceRatio * discount;

View File

@@ -40,10 +40,9 @@ const PaymentConfirmModal = ({
amountNumber,
discountRate,
}) => {
const hasDiscount =
discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
return (
<Modal
title={

View File

@@ -36,11 +36,7 @@ export const AuthRedirect = ({ children }) => {
const user = localStorage.getItem('user');
if (user) {
// 优先使用登录页上的 next 参数(仅允许站内相对路径)
const sp = new URLSearchParams(window.location.search);
const next = sp.get('next');
const isSafeInternalPath = next && next.startsWith('/') && !next.startsWith('//');
return <Navigate to={isSafeInternalPath ? next : '/console'} replace />;
return <Navigate to='/console' replace />;
}
return children;

View File

@@ -1027,6 +1027,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
imageGenerationCall = false,
imageGenerationCallPrice = 0,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1069,7 +1071,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageGenerationCallPrice * groupRatio);
return (
<>
@@ -1131,7 +1134,13 @@ export function renderModelPrice(
})}
</p>
)}
<p></p>
{imageGenerationCall && imageGenerationCallPrice > 0 && (
<p>
{i18next.t('图片生成调用:${{price}} / 1次', {
price: imageGenerationCallPrice,
})}
</p>
)}
<p>
{(() => {
// 构建输入部分描述
@@ -1211,6 +1220,16 @@ export function renderModelPrice(
},
)
: '',
imageGenerationCall && imageGenerationCallPrice > 0
? i18next.t(
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
{
price: imageGenerationCallPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
].join('');
return i18next.t(

View File

@@ -447,6 +447,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
);
}
expandDataLocal.push({

View File

@@ -444,7 +444,7 @@
"其他设置": "Other Settings",
"项目仓库地址": "Project Repository Address",
"可在设置页面设置关于内容,支持 HTML & Markdown": "The About content can be set on the settings page, supporting HTML & Markdown",
"由": "by",
"由": "developed by",
"开发,基于": "based on",
"MIT 协议": "MIT License",
"充值额度": "Recharge Quota",
@@ -519,20 +519,6 @@
"2025年5月10日后添加的渠道不需要再在部署的时候移除模型名称中的\".\"": "After May 10, 2025, channels added do not need to remove the dot in the model name during deployment",
"模型映射必须是合法的 JSON 格式!": "Model mapping must be in valid JSON format!",
"取消": "Cancel",
"授权": "Authorize",
"授权后将重定向到": "You will be redirected to",
"域名": "Domain",
"请先登录后再继续授权。": "Please log in first to continue authorization.",
"暂时无法加载授权信息": "Unable to load authorization information for now",
"客户端ID": "Client ID",
"公开应用": "Public app",
"机密应用": "Confidential app",
"授权类型": "Response type",
"授权码": "Authorization Code",
"未知域": "unknown domain",
"想要访问你的": "wants to access your",
"切换账户": "Switch account",
"加载授权信息中...": "Loading authorization info...",
"重置": "Reset",
"请输入新的剩余额度": "Please enter the new remaining quota",
"请输入单个兑换码中包含的额度": "Please enter the quota included in a single redemption code",
@@ -828,7 +814,7 @@
"删除所选令牌": "Delete selected token",
"请先选择要删除的令牌!": "Please select the token to be deleted!",
"已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
"删除失败-oauth2clients": "Delete failed",
"删除失败": "Delete failed",
"复制令牌": "Copy token",
"请选择你的复制方式": "Please select your copy method",
"名称+密钥": "Name + key",
@@ -1012,7 +998,7 @@
"防失联-定期通知": "Prevent loss of contact - regular notifications",
"订阅事件后,当事件触发时,您将会收到相应的通知": "After subscribing to the event, you will receive the corresponding notification when the event is triggered.",
"当余额低于 ": "When the balance is lower than",
"保存": "Save",
"保存": "save",
"计费说明": "Billing instructions",
"高稳定性": "High stability",
"没有账号请先": "If you don't have an account, please",
@@ -2098,162 +2084,5 @@
"原价": "Original price",
"优惠": "Discount",
"折": "% off",
"节省": "Save",
"OAuth2 客户端管理": "OAuth2 Clients",
"重定向URI配置": "Redirect URI Configuration",
"用于授权码流程的重定向地址": "Redirect URIs for authorization code flow",
"填入示例模板": "Fill Template Example",
"暂无重定向URI点击下方按钮添加": "No redirect URIs yet. Click the button below to add one.",
"例如https://your-app.com/callback": "e.g.: https://your-app.com/callback",
"添加重定向URI": "Add Redirect URI",
"用户授权后将重定向到这些URI。必须使用HTTPS本地开发可使用HTTP仅限localhost/127.0.0.1": "After authorization, the user will be redirected to these URIs. HTTPS is required (HTTP is allowed only for localhost/127.0.0.1 during local development).",
"仅在选择“授权码”授权类型时需要配置重定向URI": "Redirect URIs are only required when Authorization Code is selected.",
"请至少选择一种授权类型": "Please select at least one grant type",
"不被允许的授权类型: {{types}}": "Disallowed grant types: {{types}}",
"公开客户端不允许使用client_credentials授权类型": "Public clients cannot use the client_credentials grant type.",
"选择授权码授权类型时必须填写至少一个重定向URI": "At least one Redirect URI is required when Authorization Code is selected.",
"重定向URI格式不合法仅支持https或本地开发使用http": "Invalid Redirect URI: only HTTPS is supported, or HTTP for local development.",
"OAuth2客户端更新成功": "OAuth2 client updated successfully",
"OAuth2客户端创建成功": "OAuth2 client created successfully",
"客户端创建成功": "Client Created Successfully",
"请妥善保存以下信息:": "Please keep the following information secure:",
"客户端信息如下,请立即复制保存。关闭此窗口后将无法再次查看密钥。": "Client information is shown below. Please copy and save immediately. The secret will not be viewable again after closing this window.",
"客户端密钥(仅此一次显示)": "Client Secret (shown only once)",
"客户端密钥仅显示一次,请立即复制保存。": "The client secret is shown only once. Please copy and save it immediately.",
"公开客户端无需密钥。": "Public clients do not require a client secret.",
"更新OAuth2客户端失败": "Failed to update OAuth2 client",
"创建OAuth2客户端失败": "Failed to create OAuth2 client",
"创建": "Create",
"编辑OAuth2客户端": "Edit OAuth2 Client",
"创建OAuth2客户端": "Create OAuth2 Client",
"设置客户端的基本信息": "Set the client's basic information",
"输入客户端名称": "Enter client name",
"请输入客户端名称": "Please enter the client name",
"输入客户端描述": "Enter client description",
"客户端类型": "Client Type",
"选择客户端类型": "Select client type",
"请选择客户端类型": "Please select client type",
"服务器端应用,安全地存储客户端密钥": "Server-side app, can securely store the client secret",
"机密客户端Confidential": "Confidential Client",
"移动应用或单页应用,无法安全存储密钥": "Mobile or single-page app, cannot securely store a secret",
"公开客户端Public": "Public Client",
"请选择授权类型(可多选)": "Select grant types (multiple)",
"请选择至少一个权限范围": "Please select at least one scope",
"请选择权限范围(可多选)": "Select scopes (multiple)",
"openidOIDC 基础身份)": "openid (OIDC basic identity)",
"profile用户名/昵称等)": "profile (username/nickname, etc.)",
"email邮箱信息": "email (email information)",
"读取API": "read API",
"写入API": "write API",
"admin管理员权限": "admin (administrator permission)",
"PKCEProof Key for Code Exchange可提高授权码流程的安全性。": "PKCE (Proof Key for Code Exchange) improves the security of the authorization code flow.",
"请选择状态": "Please select status",
"加载OAuth2客户端失败": "Failed to load OAuth2 clients",
"删除成功": "Deleted successfully",
"删除失败": "Delete failed",
"重新生成密钥失败": "Failed to regenerate secret",
"OAuth2 服务器信息": "OAuth2 Server Info",
"授权服务器配置": "Authorization server configuration",
"获取服务器信息失败": "Failed to get server info",
"JWKS 信息": "JWKS Info",
"JSON Web Key Set": "JSON Web Key Set",
"获取JWKS失败": "Failed to get JWKS",
"客户端信息": "Client Info",
"机密客户端": "Confidential",
"公开客户端": "Public",
"客户端凭证": "Client Credentials",
"刷新令牌": "Refresh Token",
"编辑客户端": "Edit Client",
"确认重新生成客户端密钥?": "Confirm regenerating client secret?",
"客户端": "Client",
"操作不可撤销,旧密钥将立即失效。": "This action cannot be undone. The old secret will be invalid immediately.",
"确认": "Confirm",
"重新生成密钥": "Regenerate Secret",
"请再次确认删除该客户端": "Please confirm deleting this client",
"删除后无法恢复,相关 API 调用将立即失效。": "This operation cannot be undone. Related API calls will stop working immediately.",
"确定删除": "Confirm Delete",
"删除客户端": "Delete Client",
"管理OAuth2客户端应用程序每个客户端代表一个可以访问API的应用程序。机密客户端用于服务器端应用公开客户端用于移动应用或单页应用。": "Manage OAuth2 client applications. Each client represents an application that can access APIs. Confidential clients are for server-side apps; public clients are for mobile apps or SPAs.",
"搜索客户端名称、ID或描述": "Search client name, ID or description",
"服务器信息": "Server Info",
"查看JWKS": "View JWKS",
"创建客户端": "Create Client",
"第 {{start}}-{{end}} 条,共 {{total}} 条": "Items {{start}}-{{end}} of {{total}}",
"暂无OAuth2客户端": "No OAuth2 clients",
"还没有创建任何客户端,点击下方按钮创建第一个客户端": "No clients yet. Click the button below to create the first one.",
"创建第一个客户端": "Create first client",
"客户端密钥已重新生成": "Client secret regenerated",
"我已复制保存": "I have copied and saved",
"新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。": "The new client secret is shown below. Copy and save it now. You will not be able to view it again after closing this window.",
"OAuth2 服务端管理": "OAuth2 Server",
"运行正常": "Healthy",
"配置中": "Configuring",
"保存配置": "Save Configuration",
"密钥管理": "Key Management",
"打开密钥管理": "Open Key Management",
"尚未准备签名密钥,建议立即初始化或轮换以发布 JWKS。签名密钥用于 JWT 令牌的安全签发。": "Signing key not prepared. Initialize or rotate to publish JWKS. Signing keys are used to securely issue JWT tokens.",
"启用 OAuth2 & SSO": "Enable OAuth2 & SSO",
"开启后将允许以 OAuth2/OIDC 标准进行授权与登录": "Enables OAuth2/OIDC standard based authorization and login",
"发行人 (Issuer)": "Issuer",
"为空则按请求自动推断(含 X-Forwarded-Proto": "Leave empty to infer from request (including X-Forwarded-Proto)",
"令牌配置": "Token Settings",
"访问令牌有效期": "Access token TTL",
"访问令牌的有效时间建议较短10-60分钟": "Access token lifetime. Recommended: short (1060 minutes)",
"刷新令牌有效期": "Refresh token TTL",
"刷新令牌的有效时间建议较长12-720小时": "Refresh token lifetime. Recommended: long (12720 hours)",
"JWKS历史保留上限": "JWKS history retention limit",
"轮换后最多保留的历史签名密钥数量": "Max number of historical signing keys to keep after rotation",
"JWT签名算法": "JWT signing algorithm",
"JWT令牌的签名算法推荐使用RS256": "Signing algorithm for JWT tokens. RS256 is recommended",
"JWT密钥ID": "JWT key ID",
"用于标识JWT签名密钥支持密钥轮换": "Identifier for JWT signing key; supports key rotation",
"授权配置": "Authorization Settings",
"允许的授权类型": "Allowed grant types",
"允许的权限范围Scope": "Allowed scopes",
"选择允许的OAuth2授权流程": "Select allowed OAuth2 grant flows",
"Client Credentials客户端凭证": "Client Credentials",
"Authorization Code授权码": "Authorization Code",
"Refresh Token刷新令牌": "Refresh Token",
"强制PKCE验证": "Enforce PKCE",
"为授权码流程强制启用PKCE提高安全性": "Enforce PKCE for authorization code flow to improve security",
"OAuth2 服务器提供标准的 API 认证与授权": "OAuth2 server provides standard API authentication and authorization",
"支持 Client Credentials、Authorization Code + PKCE 等标准流程": "Supports standard flows such as Client Credentials and Authorization Code + PKCE",
"配置保存后多数项即时生效;签名密钥轮换与 JWKS 发布为即时操作": "Most settings take effect immediately after saving; key rotation and JWKS publication are instantaneous",
"生产环境务必启用 HTTPS并妥善管理 JWT 签名密钥": "Enable HTTPS in production and manage JWT signing keys properly",
"个客户端": "clients",
"请妥善保管此密钥,用于应用程序的身份验证": "Please keep this secret safe, it is used for application authentication",
"客户端名称": "Client Name",
"暂无描述": "No description",
"OAuth2 服务器配置": "OAuth2 Server Configuration",
"JWKS 密钥集": "JWKS Key Set",
"获取密钥列表失败": "Failed to fetch key list",
"签名密钥已轮换:{{kid}}": "Signing key rotated: {{kid}}",
"密钥轮换失败": "Key rotation failed",
"已删除:{{kid}}": "Deleted: {{kid}}",
"请粘贴 PEM 私钥": "Please paste PEM private key",
"已导入私钥并切换到 kid={{kid}}": "Private key imported and switched to kid={{kid}}",
"导入失败": "Import failed",
"请填写保存路径": "Please enter the save path",
"已生成并生效:{{path}}": "Generated and applied: {{path}}",
"生成失败": "Generation failed",
"当前": "Current",
"历史": "History",
"确定删除密钥 {{kid}} ": "Confirm delete key {{kid}}?",
"删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)": "Tokens issued with this kid may still be verifiable (external JWKS caches may retain the key)",
"轮换密钥": "Rotate key",
"初始化密钥": "Initialize key",
"导入并生效": "Import and apply",
"生成并生效": "Generate and apply",
"提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。": "Tip: The current key is used to sign JWT tokens. Rotate keys regularly for security. Only historical keys can be deleted.",
"暂无密钥": "No keys yet",
"建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。": "Recommendation: Prefer in-memory signing keys with JWKS rotation; import external private keys only when required for compliance. Ensure the private key source is trusted.",
"自定义 KID": "Custom KID",
"可留空自动生成": "Optional, auto-generate if empty",
"PEM 私钥": "PEM private key",
"建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600并妥善备份。": "Recommendation: Use file-based private keys only when required for compliance. Ensure directory permissions are secure (0600 recommended) and back up properly.",
"保存路径": "Save path",
"JWKS 管理": "JWKS Management",
"密钥列表": "Key list",
"导入 PEM 私钥": "Import PEM private key",
"生成 PEM 文件": "Generate PEM file"
"节省": "Save"
}

View File

@@ -1,391 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
Card,
Button,
Typography,
Spin,
Banner,
Avatar,
Divider,
Popover,
} from '@douyinfe/semi-ui';
import { Link, Dot, Key, User, Mail, Eye, Pencil, Shield } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { API, getLogo } from '../../helpers';
import { stringToColor } from '../../helpers/render';
const { Title, Text } = Typography;
function useQuery() {
const { search } = useLocation();
return useMemo(() => new URLSearchParams(search), [search]);
}
// 获取scope对应的图标
function getScopeIcon(scopeName) {
switch (scopeName) {
case 'openid':
return Key;
case 'profile':
return User;
case 'email':
return Mail;
case 'api:read':
return Eye;
case 'api:write':
return Pencil;
case 'admin':
return Shield;
default:
return Dot;
}
}
// 权限项组件
function ScopeItem({ name, description }) {
const Icon = getScopeIcon(name);
return (
<div className='flex items-start gap-3 py-2'>
<div className='w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5'>
<Icon size={24} />
</div>
<div className='flex-1 min-w-0'>
<Text strong className='block'>
{name}
</Text>
{description && (
<Text type='tertiary' size='small' className='block mt-1'>
{description}
</Text>
)}
</div>
</div>
);
}
export default function OAuthConsent() {
const { t } = useTranslation();
const query = useQuery();
const [loading, setLoading] = useState(true);
const [info, setInfo] = useState(null);
const [error, setError] = useState('');
const params = useMemo(() => {
const allowed = [
'response_type',
'client_id',
'redirect_uri',
'scope',
'state',
'code_challenge',
'code_challenge_method',
'nonce',
];
const obj = {};
allowed.forEach((k) => {
const v = query.get(k);
if (v) obj[k] = v;
});
if (!obj.response_type) obj.response_type = 'code';
return obj;
}, [query]);
useEffect(() => {
(async () => {
setLoading(true);
try {
const res = await API.get('/api/oauth/authorize', {
params: { ...params, mode: 'prepare' },
// skip error toast, we'll handle gracefully
skipErrorHandler: true,
});
setInfo(res.data);
setError('');
} catch (e) {
// 401 login required or other error
setError(e?.response?.data?.error || 'failed');
} finally {
setLoading(false);
}
})();
}, [params]);
const handleAction = (action) => {
const u = new URL(window.location.origin + '/api/oauth/authorize');
Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
u.searchParams.set(action, '1');
window.location.href = u.toString();
};
return (
<div className='min-h-screen flex items-center justify-center px-3 sm:px-4 py-16 sm:py-20'>
<div className='w-full max-w-sm sm:max-w-lg'>
{loading ? (
<Card className='text-center py-8'>
<Spin size='large' />
<Text type='tertiary' className='block mt-4'>
{t('加载授权信息中...')}
</Text>
</Card>
) : error ? (
<Banner
type='warning'
closeIcon={null}
className='!rounded-lg'
description={
error === 'login_required'
? t('请先登录后再继续授权。')
: t('暂时无法加载授权信息')
}
/>
) : (
info && (
<>
<Card
className='!rounded-2xl border-0'
footer={
<div className='space-y-3 px-2 sm:px-0'>
<div className='flex flex-col sm:flex-row gap-2'>
<Button
theme='outline'
onClick={() => handleAction('deny')}
className='w-full'
>
{t('取消')}
</Button>
<Button
type='primary'
theme='solid'
onClick={() => handleAction('approve')}
className='w-full'
>
{t('授权')} {info?.user?.name || t('用户')}
</Button>
</div>
<div className='text-center'>
<Text type='tertiary' size='small' className='block'>
{t('授权后将重定向到')}
</Text>
<Text type='tertiary' size='small' className='block'>
{info?.redirect_uri?.length > 60
? info.redirect_uri.slice(0, 60) + '...'
: info?.redirect_uri}
</Text>
</div>
</div>
}
>
{/* 头部:应用 → 链接 → 站点Logo */}
<div className='text-center py-6 sm:py-8 px-3 sm:px-0'>
<div className='flex items-center justify-center gap-4 sm:gap-6 mb-4 sm:mb-6'>
{/* 应用图标 */}
<Popover
content={
<div className='max-w-xs p-2'>
<Text strong className='block text-sm mb-1'>
{info?.client?.name || info?.client?.id}
</Text>
{info?.client?.desc && (
<Text
type='tertiary'
size='small'
className='block'
>
{info.client.desc}
</Text>
)}
{info?.client?.domain && (
<Text
type='tertiary'
size='small'
className='block mt-1'
>
{t('域名')}: {info.client.domain}
</Text>
)}
</div>
}
trigger='hover'
position='top'
>
<Avatar
size={36}
style={{
backgroundColor: stringToColor(
info?.client?.name || info?.client?.id || 'A',
),
cursor: 'pointer',
}}
>
{String(info?.client?.name || info?.client?.id || 'A')
.slice(0, 1)
.toUpperCase()}
</Avatar>
</Popover>
{/* 链接图标 */}
<div className='w-10 h-10 rounded-full flex items-center justify-center'>
<Link size={20} />
</div>
{/* 站点Logo */}
<div className='w-12 h-12 rounded-full overflow-hidden flex items-center justify-center'>
<img
src={getLogo()}
alt='Site Logo'
className='w-full h-full object-cover'
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
<div
className='w-full h-full rounded-full flex items-center justify-center'
style={{
backgroundColor: stringToColor(
window.location.hostname || 'S',
),
display: 'none',
}}
>
<Text className='font-bold text-lg'>
{window.location.hostname.charAt(0).toUpperCase()}
</Text>
</div>
</div>
</div>
<Title heading={4}>
{t('授权')} {info?.client?.name || info?.client?.id}
</Title>
</div>
<Divider margin='0' />
{/* 用户信息 */}
<div className='px-3 sm:px-5 py-3'>
<div className='flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3'>
<div className='flex-1 min-w-0'>
<Text className='block text-sm sm:text-base'>
<Text strong>
{info?.client?.name || info?.client?.id}
</Text>{' '}
{t('由')}{' '}
<Text strong>
{info?.client?.domain || t('未知域')}
</Text>
</Text>
<Text type='tertiary' size='small' className='block mt-1'>
{t('想要访问你的')}{' '}
<Text strong>{info?.user?.name || ''}</Text> {t('账户')}
</Text>
</div>
<Button
size='small'
theme='outline'
type='tertiary'
className='w-full sm:w-auto flex-shrink-0'
onClick={() => {
const u = new URL(window.location.origin + '/login');
u.searchParams.set(
'next',
'/oauth/consent' + window.location.search,
);
window.location.href = u.toString();
}}
>
{t('切换账户')}
</Button>
</div>
</div>
<Divider margin='0' />
{/* 权限列表 */}
<div className='px-3 sm:px-5 py-3'>
<div className='space-y-2'>
{info?.scope_info?.length ? (
info.scope_info.map((scope) => (
<ScopeItem
key={scope.Name}
name={scope.Name}
description={scope.Description}
/>
))
) : (
<div className='space-y-1'>
{info?.scope_list?.map((name) => (
<ScopeItem key={name} name={name} />
))}
</div>
)}
</div>
</div>
</Card>
{/* Meta信息Card */}
<Card bordered={false}>
<div className='text-center'>
<div className='flex flex-wrap justify-center gap-x-2 gap-y-1 items-center'>
<Text size='small'>
{t('客户端ID')}: {info?.client?.id?.slice(-8) || 'N/A'}
</Text>
<Dot size={16} />
<Text size='small'>
{t('类型')}:{' '}
{info?.client?.type === 'public'
? t('公开应用')
: t('机密应用')}
</Text>
{info?.response_type && (
<>
<Dot size={16} />
<Text size='small'>
{t('授权类型')}:{' '}
{info.response_type === 'code'
? t('授权码')
: info.response_type}
</Text>
</>
)}
{info?.require_pkce && (
<>
<Dot size={16} />
<Text size='small'>PKCE: {t('已启用')}</Text>
</>
)}
</div>
{info?.state && (
<div className='mt-2'>
<Text type='tertiary' size='small' className='font-mono'>
State: {info.state}
</Text>
</div>
)}
</div>
</Card>
</>
)
)}
</div>
</div>
);
}

View File

@@ -130,20 +130,19 @@ export default function GeneralSettings(props) {
showClear
/>
</Col>
{inputs.QuotaPerUnit !== '500000' &&
inputs.QuotaPerUnit !== 500000 && (
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'QuotaPerUnit'}
label={t('单位美元额度')}
initValue={''}
placeholder={t('一单位货币能兑换的额度')}
onChange={handleFieldChange('QuotaPerUnit')}
showClear
onClick={() => setShowQuotaWarning(true)}
/>
</Col>
)}
{inputs.QuotaPerUnit !== '500000' && inputs.QuotaPerUnit !== 500000 && (
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'QuotaPerUnit'}
label={t('单位美元额度')}
initValue={''}
placeholder={t('一单位货币能兑换的额度')}
onChange={handleFieldChange('QuotaPerUnit')}
showClear
onClick={() => setShowQuotaWarning(true)}
/>
</Col>
)}
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Input
field={'USDExchangeRate'}

View File

@@ -128,8 +128,7 @@ export default function SettingsMonitoring(props) {
onChange={(value) =>
setInputs({
...inputs,
'monitor_setting.auto_test_channel_minutes':
parseInt(value),
'monitor_setting.auto_test_channel_minutes': parseInt(value),
})
}
/>

View File

@@ -118,20 +118,14 @@ export default function SettingsPaymentGateway(props) {
}
}
if (
originInputs['AmountOptions'] !== inputs.AmountOptions &&
inputs.AmountOptions.trim() !== ''
) {
if (originInputs['AmountOptions'] !== inputs.AmountOptions && inputs.AmountOptions.trim() !== '') {
if (!verifyJSON(inputs.AmountOptions)) {
showError(t('自定义充值数量选项不是合法的 JSON 数组'));
return;
}
}
if (
originInputs['AmountDiscount'] !== inputs.AmountDiscount &&
inputs.AmountDiscount.trim() !== ''
) {
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount && inputs.AmountDiscount.trim() !== '') {
if (!verifyJSON(inputs.AmountDiscount)) {
showError(t('充值金额折扣配置不是合法的 JSON 对象'));
return;
@@ -169,16 +163,10 @@ export default function SettingsPaymentGateway(props) {
options.push({ key: 'PayMethods', value: inputs.PayMethods });
}
if (originInputs['AmountOptions'] !== inputs.AmountOptions) {
options.push({
key: 'payment_setting.amount_options',
value: inputs.AmountOptions,
});
options.push({ key: 'payment_setting.amount_options', value: inputs.AmountOptions });
}
if (originInputs['AmountDiscount'] !== inputs.AmountDiscount) {
options.push({
key: 'payment_setting.amount_discount',
value: inputs.AmountDiscount,
});
options.push({ key: 'payment_setting.amount_discount', value: inputs.AmountDiscount });
}
// 发送请求
@@ -285,7 +273,7 @@ export default function SettingsPaymentGateway(props) {
placeholder={t('为一个 JSON 文本')}
autosize
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
@@ -294,17 +282,13 @@ export default function SettingsPaymentGateway(props) {
<Form.TextArea
field='AmountOptions'
label={t('自定义充值数量选项')}
placeholder={t(
'为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]',
)}
placeholder={t('为一个 JSON 数组,例如:[10, 20, 50, 100, 200, 500]')}
autosize
extraText={t(
'设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]',
)}
extraText={t('设置用户可选择的充值数量选项,例如:[10, 20, 50, 100, 200, 500]')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
@@ -313,17 +297,13 @@ export default function SettingsPaymentGateway(props) {
<Form.TextArea
field='AmountDiscount'
label={t('充值金额折扣配置')}
placeholder={t(
'为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
)}
placeholder={t('为一个 JSON 对象,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
autosize
extraText={t(
'设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}',
)}
extraText={t('设置不同充值金额对应的折扣,键为充值金额,值为折扣率,例如:{"100": 0.95, "200": 0.9, "500": 0.85}')}
/>
</Col>
</Row>
<Button onClick={submitPayAddress}>{t('更新支付设置')}</Button>
</Form.Section>
</Form>

View File

@@ -32,7 +32,6 @@ import {
MessageSquare,
Palette,
CreditCard,
Shield,
} from 'lucide-react';
import SystemSetting from '../../components/settings/SystemSetting';
@@ -46,7 +45,6 @@ import RatioSetting from '../../components/settings/RatioSetting';
import ChatsSetting from '../../components/settings/ChatsSetting';
import DrawingSetting from '../../components/settings/DrawingSetting';
import PaymentSetting from '../../components/settings/PaymentSetting';
import OAuth2Setting from '../../components/settings/OAuth2Setting';
const Setting = () => {
const { t } = useTranslation();
@@ -136,16 +134,6 @@ const Setting = () => {
content: <ModelSetting />,
itemKey: 'models',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<Shield size={18} />
{t('OAuth2 & SSO')}
</span>
),
content: <OAuth2Setting />,
itemKey: 'oauth2',
});
panes.push({
tab: (
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>