Compare commits

..

69 Commits

Author SHA1 Message Date
CaIon
7794788b1e Merge remote-tracking branch 'origin/main' 2025-09-27 13:56:44 +08:00
CaIon
2f74cc077b feat: 多密钥管理新增针对单个密钥的删除操作 2025-09-27 13:56:07 +08:00
Seefs
d2defa1253 Merge pull request #1882 from ShibaInu64/feature/nova-model
feat: 新增支持目前已发布的amazon nova model
2025-09-26 17:25:40 +08:00
huanghejian
127029d62d feat: amazon nova model 2025-09-26 15:55:00 +08:00
huanghejian
6c5181977d feat: amazon nova model 2025-09-26 15:32:59 +08:00
IcedTangerine
6992fd2b66 Merge pull request #1809 from QuentinHsu/feature/date-shortcut
feat: add date range preset constants and use them in the log filter
2025-09-23 22:24:05 +08:00
IcedTangerine
92895ebe5a Merge branch 'main' into feature/date-shortcut 2025-09-23 22:21:25 +08:00
IcedTangerine
c0fb3bf95f Merge pull request #1810 from QuentinHsu/feature/alias-path
feat: add jsconfig.json and configure path aliases
2025-09-23 22:13:20 +08:00
Seefs
abe31f216f Merge pull request #1830 from MyPrototypeWhat/fix/UserArea-Dropdown
fix(UserArea): Enhance UserArea dropdown positioning with useRef
2025-09-22 12:59:54 +08:00
Seefs
44bc65691e Merge pull request #1777 from heimoshuiyu/feat/token-display-thoundsand-seperator
feat: add thousand separators to token display in dashboard
2025-09-22 12:56:49 +08:00
IcedTangerine
7c27558de9 Merge pull request #1841 from x-Ai/fix/sidebar-permissions
fix: 个人设置中修改边栏设置不立即生效
2025-09-21 15:08:18 +08:00
Seefs
51ef19a3fb Merge pull request #1850 from seefs001/fix/claude-system-prompt-overwrite
fix: claude & gemini endpoint system prompt overwrite
2025-09-20 14:00:38 +08:00
Seefs
8e7301b79a fix: gemini system prompt overwrite 2025-09-20 13:38:44 +08:00
CaIon
ec98a21933 feat: change ParallelToolCalls and Store fields to json.RawMessage type 2025-09-20 13:28:33 +08:00
CaIon
1dd59f5d08 feat: add PromptCacheKey field to openai_request struct 2025-09-20 13:27:32 +08:00
Seefs
ea084e775e fix: claude system prompt overwrite 2025-09-20 00:22:54 +08:00
creamlike1024
41be436c04 Merge branch 'feitianbubu-pr/vidu-add-first-end-reference-video' 2025-09-19 22:47:29 +08:00
feitianbubu
b73b16e102 feat: vidu video add starEnd and reference gen video show type 2025-09-19 18:54:48 +08:00
feitianbubu
8f9960bcc7 feat: vidu video add starEnd and reference gen video 2025-09-19 18:54:45 +08:00
feitianbubu
3c70617060 feat: vidu video support multi images 2025-09-19 18:54:40 +08:00
F。
3a98ae3f70 改进"侧边栏"权限控制-1
This reverts commit d798db5953906aa5ff76cf6f2b641eb204d279b0.
2025-09-19 16:24:37 +08:00
F。
1894ddc786 改进"侧边栏"权限控制 2025-09-19 16:24:31 +08:00
F。
f23be16e98 修复"边栏"权限控制问题 2025-09-19 16:24:27 +08:00
F。
b882dfa8f6 修复"边栏"隐藏后无法即时生效问题 2025-09-19 16:24:23 +08:00
CaIon
d491cbd3d2 feat: update labels for ratio settings to clarify model support 2025-09-19 14:23:08 +08:00
CaIon
334ba555fc fix: cast option.Value to string for ratio updates 2025-09-19 14:21:32 +08:00
CaIon
ba632d0b4d CI 2025-09-19 14:20:35 +08:00
CaIon
b5d3e87ea2 Merge branch 'alpha' 2025-09-19 14:20:15 +08:00
Calcium-Ion
f22ea6e0a8 Merge pull request #1834 from QuantumNous/gemini-embedding-001
feat: 支持 gemini-embedding-001
2025-09-19 00:40:40 +08:00
creamlike1024
9f1ab16aa5 feat: 支持 gemini-embedding-001 2025-09-19 00:24:01 +08:00
Seefs
0dd475d2ff Merge pull request #1833 from seefs001/feature/deepseek-claude-code
fix: deepseek claude response
2025-09-18 16:37:51 +08:00
Seefs
dd374cdd9b feat: deepseek claude endpoint 2025-09-18 16:32:29 +08:00
Calcium-Ion
daf3ef9848 Merge pull request #1832 from seefs001/feature/deepseek-claude-code
feat: deepseek claude endpoint
2025-09-18 16:30:01 +08:00
Seefs
23ee0fc3b4 feat: deepseek claude endpoint 2025-09-18 16:19:44 +08:00
Calcium-Ion
08638b18ce Merge pull request #1831 from seefs001/fix/kimi-claude-code
fix: kimi claude code
2025-09-18 16:15:41 +08:00
Seefs
d331f0fb2a fix: kimi claude code 2025-09-18 16:14:25 +08:00
CaIon
4b98fceb6e CI 2025-09-18 13:53:58 +08:00
Calcium-Ion
ef63416098 Merge commit from fork
feat: implement SSRF protection settings and update related references
2025-09-18 13:41:44 +08:00
CaIon
50a432180d feat: add experimental IP filtering for domains and update related settings 2025-09-18 13:40:52 +08:00
CaIon
2ea7634549 Merge branch 'main' into ssrf
# Conflicts:
#	service/cf_worker.go
2025-09-18 13:29:11 +08:00
MyPrototypeWhat
10da082412 refactor: Enhance UserArea dropdown positioning with useRef
- Added useRef to manage dropdown positioning in UserArea component.
- Wrapped Dropdown in a div with a ref to ensure correct popup container.
- Minor adjustments to maintain existing functionality and styling.
2025-09-18 12:01:35 +08:00
creamlike1024
31c8ead1d4 feat: 移除多余的说明文本 2025-09-17 23:54:34 +08:00
creamlike1024
00f4594062 fix: use u.Hostname() instead of u.Host to avoid ipv6 host parse failed 2025-09-17 23:47:59 +08:00
creamlike1024
467e584359 feat: 添加域名启用ip过滤开关 2025-09-17 23:46:04 +08:00
creamlike1024
f635fc3ae6 feat: remove ValidateURLWithDefaults 2025-09-17 23:29:18 +08:00
creamlike1024
168ebb1cd4 feat: ssrf支持域名和ip黑白名单过滤模式 2025-09-17 15:41:21 +08:00
creamlike1024
b7bc609a7a feat: 添加域名和ip过滤模式设置 2025-09-16 22:40:40 +08:00
Calcium-Ion
046c8b27b6 Merge pull request #1816 from QuantumNous/fix/setup-err-display
🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
2025-09-16 17:43:57 +08:00
t0ng7u
4be61d00e4 🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.

Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
  predictable errors in POST /api/setup, including:
  - already initialized
  - invalid payload
  - username too long
  - password mismatch
  - password too short
  - password hashing failure
  - root user creation failure
  - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
  - setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
  previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
  (no Axios response.data parsing required).

Why
- Keep API behavior consistent across endpoints so the UI can rely on the
  success flag and message in the normal .then() flow instead of falling
  into Axios 4xx errors that only show a generic "400".

Impact
- UI now displays specific server messages during initialization without
  frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
  should inspect the JSON body (success/message) instead.

No changes to the happy path; initialization success responses are unchanged.
2025-09-16 17:21:22 +08:00
t0ng7u
4ac7d94026 Merge remote-tracking branch 'origin/alpha' into alpha 2025-09-16 16:56:26 +08:00
t0ng7u
9af71caf73 🛠️ fix: Align setup API errors to HTTP 200 with {success:false, message}
Unify the setup initialization endpoint’s error contract to match the rest
of the project and keep the frontend unchanged.

Changes
- controller/setup.go: Return HTTP 200 with {success:false, message} for all
  predictable errors in POST /api/setup, including:
  - already initialized
  - invalid payload
  - username too long
  - password mismatch
  - password too short
  - password hashing failure
  - root user creation failure
  - option persistence failures (SelfUseModeEnabled, DemoSiteEnabled)
  - setup record creation failure
- web/src/components/setup/SetupWizard.jsx: Restore catch handler to the
  previous generic toast (frontend logic unchanged).
- web/src/helpers/utils.jsx: Restore the original showError implementation
  (no Axios response.data parsing required).

Why
- Keep API behavior consistent across endpoints so the UI can rely on the
  success flag and message in the normal .then() flow instead of falling
  into Axios 4xx errors that only show a generic "400".

Impact
- UI now displays specific server messages during initialization without
  frontend adaptations.
- Note: clients relying solely on HTTP status codes for error handling
  should inspect the JSON body (success/message) instead.

No changes to the happy path; initialization success responses are unchanged.
2025-09-16 16:55:35 +08:00
Xyfacai
91e57a4c69 feat: jimeng kling 支持new api 嵌套中转 2025-09-16 16:28:27 +08:00
Calcium-Ion
45a6a779e5 Merge pull request #1814 from ShibaInu64/fix/volcengine-image-model
fix: VolcEngine渠道-图片生成 API-渠道测试报错
2025-09-16 15:36:56 +08:00
huanghejian
49c7a0dee5 Merge branch 'main' into fix/volcengine-image-model 2025-09-16 14:58:59 +08:00
huanghejian
956244c742 fix: VolcEngine doubao-seedream-4-0-250828 2025-09-16 14:30:12 +08:00
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
QuentinHsu
dfa27f3412 feat: add jsconfig.json and configure path aliases 2025-09-15 22:30:41 +08:00
QuentinHsu
e34b5def60 feat: add date range preset constants and use them in the log filter 2025-09-15 21:59:25 +08:00
Xyfacai
63f94e7669 fix: 非openai 渠道使用 SystemPrompt 设置会panic 2025-09-15 19:38:31 +08:00
IcedTangerine
05c2dde38f Merge pull request #1698 from QuantumNous/imageratio-and-audioratio-edit
feat: 图像倍率,音频倍率和音频补全倍率配置
2025-09-15 14:20:05 +08:00
creamlike1024
0ee5670be6 Merge branch 'alpha' into imageratio-and-audioratio-edit 2025-09-15 14:12:24 +08:00
Xyfacai
9790e2c4f6 fix: gemini support webp file 2025-09-15 01:01:48 +08:00
CaIon
72d5b35d3f feat: implement SSRF protection settings and update related references 2025-09-13 18:15:03 +08:00
Seefs
aea732ab92 Merge pull request #1795 from seefs001/fix/veo3
Fix/veo3
2025-09-13 16:31:55 +08:00
heimoshuiyu
3e9be07db4 feat: add thousand separators to token display in dashboard 2025-09-11 10:34:51 +08:00
creamlike1024
d15718a87e feat: improve ratio update 2025-08-30 23:53:46 +08:00
creamlike1024
da5aace109 feat: 图像倍率,音频倍率和音频补全倍率配置 2025-08-30 23:28:09 +08:00
100 changed files with 1866 additions and 7541 deletions

22
common/ip.go Normal file
View File

@@ -0,0 +1,22 @@
package common
import "net"
func IsPrivateIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
private := []net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
}
for _, privateNet := range private {
if privateNet.Contains(ip) {
return true
}
}
return false
}

327
common/ssrf_protection.go Normal file
View File

@@ -0,0 +1,327 @@
package common
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// SSRFProtection SSRF防护配置
type SSRFProtection struct {
AllowPrivateIp bool
DomainFilterMode bool // true: 白名单, false: 黑名单
DomainList []string // domain format, e.g. example.com, *.example.com
IpFilterMode bool // true: 白名单, false: 黑名单
IpList []string // CIDR or single IP
AllowedPorts []int // 允许的端口范围
ApplyIPFilterForDomain bool // 对域名启用IP过滤
}
// DefaultSSRFProtection 默认SSRF防护配置
var DefaultSSRFProtection = &SSRFProtection{
AllowPrivateIp: false,
DomainFilterMode: true,
DomainList: []string{},
IpFilterMode: true,
IpList: []string{},
AllowedPorts: []int{},
}
// isPrivateIP 检查IP是否为私有地址
func isPrivateIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// 检查私有网段
private := []net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
}
for _, privateNet := range private {
if privateNet.Contains(ip) {
return true
}
}
// 检查IPv6私有地址
if ip.To4() == nil {
// IPv6 loopback
if ip.Equal(net.IPv6loopback) {
return true
}
// IPv6 link-local
if strings.HasPrefix(ip.String(), "fe80:") {
return true
}
// IPv6 unique local
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
return true
}
}
return false
}
// parsePortRanges 解析端口范围配置
// 支持格式: "80", "443", "8000-9000"
func parsePortRanges(portConfigs []string) ([]int, error) {
var ports []int
for _, config := range portConfigs {
config = strings.TrimSpace(config)
if config == "" {
continue
}
if strings.Contains(config, "-") {
// 处理端口范围 "8000-9000"
parts := strings.Split(config, "-")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid port range format: %s", config)
}
startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
}
endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
}
if startPort > endPort {
return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
}
if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
}
// 添加范围内的所有端口
for port := startPort; port <= endPort; port++ {
ports = append(ports, port)
}
} else {
// 处理单个端口 "80"
port, err := strconv.Atoi(config)
if err != nil {
return nil, fmt.Errorf("invalid port number: %s", config)
}
if port < 1 || port > 65535 {
return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
}
ports = append(ports, port)
}
}
return ports, nil
}
// isAllowedPort 检查端口是否被允许
func (p *SSRFProtection) isAllowedPort(port int) bool {
if len(p.AllowedPorts) == 0 {
return true // 如果没有配置端口限制,则允许所有端口
}
for _, allowedPort := range p.AllowedPorts {
if port == allowedPort {
return true
}
}
return false
}
// isDomainWhitelisted 检查域名是否在白名单中
func isDomainListed(domain string, list []string) bool {
if len(list) == 0 {
return false
}
domain = strings.ToLower(domain)
for _, item := range list {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
// 精确匹配
if domain == item {
return true
}
// 通配符匹配 (*.example.com)
if strings.HasPrefix(item, "*.") {
suffix := strings.TrimPrefix(item, "*.")
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
return true
}
}
}
return false
}
func (p *SSRFProtection) isDomainAllowed(domain string) bool {
listed := isDomainListed(domain, p.DomainList)
if p.DomainFilterMode { // 白名单
return listed
}
// 黑名单
return !listed
}
// isIPWhitelisted 检查IP是否在白名单中
func isIPListed(ip net.IP, list []string) bool {
if len(list) == 0 {
return false
}
for _, whitelistCIDR := range list {
_, network, err := net.ParseCIDR(whitelistCIDR)
if err != nil {
// 尝试作为单个IP处理
if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
if ip.Equal(whitelistIP) {
return true
}
}
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
// IsIPAccessAllowed 检查IP是否允许访问
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
// 私有IP限制
if isPrivateIP(ip) && !p.AllowPrivateIp {
return false
}
listed := isIPListed(ip, p.IpList)
if p.IpFilterMode { // 白名单
return listed
}
// 黑名单
return !listed
}
// ValidateURL 验证URL是否安全
func (p *SSRFProtection) ValidateURL(urlStr string) error {
// 解析URL
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL format: %v", err)
}
// 只允许HTTP/HTTPS协议
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
}
// 解析主机和端口
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
// 没有端口,使用默认端口
host = u.Hostname()
if u.Scheme == "https" {
portStr = "443"
} else {
portStr = "80"
}
}
// 验证端口
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("invalid port: %s", portStr)
}
if !p.isAllowedPort(port) {
return fmt.Errorf("port %d is not allowed", port)
}
// 如果 host 是 IP则跳过域名检查
if ip := net.ParseIP(host); ip != nil {
if !p.IsIPAccessAllowed(ip) {
if isPrivateIP(ip) {
return fmt.Errorf("private IP address not allowed: %s", ip.String())
}
if p.IpFilterMode {
return fmt.Errorf("ip not in whitelist: %s", ip.String())
}
return fmt.Errorf("ip in blacklist: %s", ip.String())
}
return nil
}
// 先进行域名过滤
if !p.isDomainAllowed(host) {
if p.DomainFilterMode {
return fmt.Errorf("domain not in whitelist: %s", host)
}
return fmt.Errorf("domain in blacklist: %s", host)
}
// 若未启用对域名应用IP过滤则到此通过
if !p.ApplyIPFilterForDomain {
return nil
}
// 解析域名对应IP并检查
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
}
for _, ip := range ips {
if !p.IsIPAccessAllowed(ip) {
if isPrivateIP(ip) && !p.AllowPrivateIp {
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
}
if p.IpFilterMode {
return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
}
return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
}
}
return nil
}
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
// 如果SSRF防护被禁用直接返回成功
if !enableSSRFProtection {
return nil
}
// 解析端口范围配置
allowedPortInts, err := parsePortRanges(allowedPorts)
if err != nil {
return fmt.Errorf("request reject - invalid port configuration: %v", err)
}
protection := &SSRFProtection{
AllowPrivateIp: allowPrivateIp,
DomainFilterMode: domainFilterMode,
DomainList: domainList,
IpFilterMode: ipFilterMode,
IpList: ipList,
AllowedPorts: allowedPortInts,
ApplyIPFilterForDomain: applyIPFilterForDomain,
}
return protection.ValidateURL(urlStr)
}

View File

@@ -11,8 +11,10 @@ const (
SunoActionMusic = "MUSIC"
SunoActionLyrics = "LYRICS"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionGenerate = "generate"
TaskActionTextGenerate = "textGenerate"
TaskActionFirstTailGenerate = "firstTailGenerate"
TaskActionReferenceGenerate = "referenceGenerate"
)
var SunoModel2Action = map[string]string{

View File

@@ -90,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult {
requestPath = "/v1/embeddings" // 修改请求路径
}
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
c.Request = &http.Request{
Method: "POST",
URL: &url.URL{Path: requestPath}, // 使用动态路径
@@ -109,6 +114,21 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
}
// 重新检查模型类型并更新请求路径
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") ||
strings.Contains(testModel, "bge-") ||
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI {
requestPath = "/v1/embeddings"
c.Request.URL.Path = requestPath
}
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
c.Request.URL.Path = requestPath
}
cache, err := model.GetUserCache(1)
if err != nil {
return testResult{
@@ -140,6 +160,9 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding
}
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
}
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
@@ -201,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult {
}
// 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
// 创建一个 ImageRequest
prompt := "cat"
if request.Prompt != nil {
if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
prompt = promptStr
}
}
imageRequest := dto.ImageRequest{
Prompt: prompt,
Model: request.Model,
N: uint(request.N),
Size: request.Size,
}
// 调用专门用于图像生成的转换函数
convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else {
// 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)

View File

@@ -501,9 +501,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
}
type AddChannelRequest struct {
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
Channel *model.Channel `json:"channel"`
Mode string `json:"mode"`
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
Channel *model.Channel `json:"channel"`
}
func getVertexArrayKeys(keys string) ([]string, error) {
@@ -616,6 +617,13 @@ func AddChannel(c *gin.Context) {
}
localChannel := addChannelRequest.Channel
localChannel.Key = key
if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
keyPrefix := localChannel.Key
if len(localChannel.Key) > 8 {
keyPrefix = localChannel.Key[:8]
}
localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
}
channels = append(channels, *localChannel)
}
err = model.BatchInsertChannels(channels)
@@ -1093,8 +1101,8 @@ func CopyChannel(c *gin.Context) {
// MultiKeyManageRequest represents the request for multi-key management operations
type MultiKeyManageRequest struct {
ChannelId int `json:"channel_id"`
Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions
Action string `json:"action"` // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status"
KeyIndex *int `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions
Page int `json:"page,omitempty"` // for get_key_status pagination
PageSize int `json:"page_size,omitempty"` // for get_key_status pagination
Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
@@ -1422,6 +1430,86 @@ func ManageMultiKeys(c *gin.Context) {
})
return
case "delete_key":
if request.KeyIndex == nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "未指定要删除的密钥索引",
})
return
}
keyIndex := *request.KeyIndex
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "密钥索引超出范围",
})
return
}
keys := channel.GetKeys()
var remainingKeys []string
var newStatusList = make(map[int]int)
var newDisabledTime = make(map[int]int64)
var newDisabledReason = make(map[int]string)
newIndex := 0
for i, key := range keys {
// 跳过要删除的密钥
if i == keyIndex {
continue
}
remainingKeys = append(remainingKeys, key)
// 保留其他密钥的状态信息,重新索引
if channel.ChannelInfo.MultiKeyStatusList != nil {
if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 {
newStatusList[newIndex] = status
}
}
if channel.ChannelInfo.MultiKeyDisabledTime != nil {
if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
newDisabledTime[newIndex] = t
}
}
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
newDisabledReason[newIndex] = r
}
}
newIndex++
}
if len(remainingKeys) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "不能删除最后一个密钥",
})
return
}
// Update channel with remaining keys
channel.Key = strings.Join(remainingKeys, "\n")
channel.ChannelInfo.MultiKeySize = len(remainingKeys)
channel.ChannelInfo.MultiKeyStatusList = newStatusList
channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
err = channel.Update()
if err != nil {
common.ApiError(c, err)
return
}
model.InitChannelCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "密钥已删除",
})
return
case "delete_disabled_keys":
keys := channel.GetKeys()
var remainingKeys []string

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

@@ -128,6 +128,33 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "图片倍率设置失败: " + err.Error(),
})
return
}
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "音频倍率设置失败: " + err.Error(),
})
return
}
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "音频补全倍率设置失败: " + err.Error(),
})
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {

View File

@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) {
// Check if setup is already completed
if constant.Setup {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统已经初始化完成",
})
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "请求参数有误",
})
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
if !rootExists {
// Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "用户名长度不能超过12个字符",
})
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
}
// Validate password
if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "两次输入的密码不一致",
})
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
}
if len(req.Password) < 8 {
c.JSON(400, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "密码长度至少为8个字符",
})
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
// Create root user
hashedPassword, err := common.Password2Hash(req.Password)
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统错误: " + err.Error(),
})
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&rootUser).Error
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "创建管理员账号失败: " + err.Error(),
})
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "保存自用模式设置失败: " + err.Error(),
})
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "保存演示站点模式设置失败: " + err.Error(),
})
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
}
err = model.DB.Create(&setup).Error
if err != nil {
c.JSON(500, gin.H{
c.JSON(200, gin.H{
"success": false,
"message": "系统初始化失败: " + err.Error(),
})

View File

@@ -772,11 +772,12 @@ type OpenAIResponsesRequest struct {
Instructions json.RawMessage `json:"instructions,omitempty"`
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store bool `json:"store,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`

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

@@ -112,6 +112,9 @@ func InitOptionMap() {
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
common.OptionMap["TopUpLink"] = common.TopUpLink
//common.OptionMap["ChatLink"] = common.ChatLink
//common.OptionMap["ChatLink2"] = common.ChatLink2
@@ -397,6 +400,12 @@ func updateOptionMap(key string, value string) (err error) {
err = ratio_setting.UpdateModelPriceByJSONString(value)
case "CacheRatio":
err = ratio_setting.UpdateCacheRatioByJSONString(value)
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(value)
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(value)
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
case "TopUpLink":
common.TopUpLink = value
//case "ChatLink":

View File

@@ -21,6 +21,10 @@ var awsModelIDMap = map[string]string{
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
"nova-pro-v1:0": "amazon.nova-pro-v1:0",
"nova-premier-v1:0": "amazon.nova-premier-v1:0",
"nova-canvas-v1:0": "amazon.nova-canvas-v1:0",
"nova-reel-v1:0": "amazon.nova-reel-v1:0",
"nova-reel-v1:1": "amazon.nova-reel-v1:1",
"nova-sonic-v1:0": "amazon.nova-sonic-v1:0",
}
var awsModelCanCrossRegionMap = map[string]map[string]bool{
@@ -82,10 +86,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"apac": true,
},
"amazon.nova-premier-v1:0": {
"us": true,
},
"amazon.nova-canvas-v1:0": {
"us": true,
"eu": true,
"apac": true,
}}
},
"amazon.nova-reel-v1:0": {
"us": true,
"eu": true,
"apac": true,
},
"amazon.nova-reel-v1:1": {
"us": true,
},
"amazon.nova-sonic-v1:0": {
"us": true,
"eu": true,
"apac": true,
},
}
var awsRegionCrossModelPrefixMap = map[string]string{
"us": "us",

View File

@@ -3,17 +3,17 @@ package deepseek
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"io"
"net/http"
"one-api/dto"
"one-api/relay/channel"
"one-api/relay/channel/claude"
"one-api/relay/channel/openai"
relaycommon "one-api/relay/common"
"one-api/relay/constant"
"one-api/types"
"strings"
"github.com/gin-gonic/gin"
)
type Adaptor struct {
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}
@@ -44,14 +44,19 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
fimBaseUrl := info.ChannelBaseUrl
if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
fimBaseUrl += "/beta"
}
switch info.RelayMode {
case constant.RelayModeCompletions:
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
switch info.RelayFormat {
case types.RelayFormatClaude:
return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil
default:
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
fimBaseUrl += "/beta"
}
switch info.RelayMode {
case constant.RelayModeCompletions:
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
default:
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
}
}
@@ -87,12 +92,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.IsStream {
usage, err = openai.OaiStreamHandler(c, info, resp)
} else {
usage, err = openai.OpenaiHandler(c, info, resp)
switch info.RelayFormat {
case types.RelayFormatClaude:
if info.IsStream {
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
} else {
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
}
default:
adaptor := openai.Adaptor{}
return adaptor.DoResponse(c, resp, info)
}
return
}
func (a *Adaptor) GetModelList() []string {

View File

@@ -215,8 +215,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode == constant.RelayModeGemini {
if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
if strings.Contains(info.RequestURLPath, ":embedContent") ||
strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
return NativeGeminiEmbeddingHandler(c, resp, info)
}
if info.IsStream {

View File

@@ -23,6 +23,7 @@ import (
"github.com/gin-gonic/gin"
)
// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob
var geminiSupportedMimeTypes = map[string]bool{
"application/pdf": true,
"audio/mpeg": true,
@@ -30,6 +31,7 @@ var geminiSupportedMimeTypes = map[string]bool{
"audio/wav": true,
"image/png": true,
"image/jpeg": true,
"image/webp": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,

View File

@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
adaptor := openai.Adaptor{}
adaptor := claude.Adaptor{}
return adaptor.ConvertClaudeRequest(c, info, req)
}

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

@@ -94,6 +94,9 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
if isNewAPIRelay(info.ApiKey) {
return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
}
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
}
@@ -101,7 +104,12 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return a.signRequest(req, a.accessKey, a.secretKey)
if isNewAPIRelay(info.ApiKey) {
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
} else {
return a.signRequest(req, a.accessKey, a.secretKey)
}
return nil
}
// BuildRequestBody converts request into Jimeng specific format.
@@ -161,6 +169,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
}
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
if isNewAPIRelay(key) {
uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.baseURL)
}
payload := map[string]string{
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
"task_id": taskID,
@@ -178,17 +189,20 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
keyParts := strings.Split(key, "|")
if len(keyParts) != 2 {
return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
}
accessKey := strings.TrimSpace(keyParts[0])
secretKey := strings.TrimSpace(keyParts[1])
if isNewAPIRelay(key) {
req.Header.Set("Authorization", "Bearer "+key)
} else {
keyParts := strings.Split(key, "|")
if len(keyParts) != 2 {
return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
}
accessKey := strings.TrimSpace(keyParts[0])
secretKey := strings.TrimSpace(keyParts[1])
if err := a.signRequest(req, accessKey, secretKey); err != nil {
return nil, errors.Wrap(err, "sign request failed")
if err := a.signRequest(req, accessKey, secretKey); err != nil {
return nil, errors.Wrap(err, "sign request failed")
}
}
return service.GetHttpClient().Do(req)
}
@@ -384,3 +398,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
taskResult.Url = resTask.Data.VideoUrl
return &taskResult, nil
}
func isNewAPIRelay(apiKey string) bool {
return strings.HasPrefix(apiKey, "sk-")
}

View File

@@ -117,6 +117,11 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
if isNewAPIRelay(info.ApiKey) {
return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil
}
return fmt.Sprintf("%s%s", a.baseURL, path), nil
}
@@ -199,6 +204,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
}
path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
if isNewAPIRelay(key) {
url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID)
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
@@ -304,8 +312,13 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
//}
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
if isNewAPIRelay(apiKey) {
return apiKey, nil // new api relay
}
keyParts := strings.Split(apiKey, "|")
if len(keyParts) != 2 {
return "", errors.New("invalid api_key, required format is accessKey|secretKey")
}
accessKey := strings.TrimSpace(keyParts[0])
if len(keyParts) == 1 {
return accessKey, nil
@@ -352,3 +365,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
}
return taskInfo, nil
}
func isNewAPIRelay(apiKey string) bool {
return strings.HasPrefix(apiKey, "sk-")
}

View File

@@ -80,8 +80,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
// Use the unified validation method for TaskSubmitReq with image-based action determination
return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
}
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
@@ -112,6 +111,10 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
switch info.Action {
case constant.TaskActionGenerate:
path = "/img2video"
case constant.TaskActionFirstTailGenerate:
path = "/start-end2video"
case constant.TaskActionReferenceGenerate:
path = "/reference2video"
default:
path = "/text2video"
}
@@ -187,14 +190,9 @@ func (a *TaskAdaptor) GetChannelName() string {
// ============================
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
var images []string
if req.Image != "" {
images = []string{req.Image}
}
r := requestPayload{
Model: defaultString(req.Model, "viduq1"),
Images: images,
Images: req.Images,
Prompt: req.Prompt,
Duration: defaultInt(req.Duration, 5),
Resolution: defaultString(req.Size, "1080p"),

View File

@@ -41,6 +41,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
switch info.RelayMode {
case constant.RelayModeImagesGenerations:
return request, nil
case constant.RelayModeImagesEdits:
var requestBody bytes.Buffer

View File

@@ -8,6 +8,7 @@ var ModelList = []string{
"Doubao-lite-32k",
"Doubao-lite-4k",
"Doubao-embedding",
"doubao-seedream-4-0-250828",
}
var ChannelName = "volcengine"

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
@@ -69,6 +70,31 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
info.UpstreamModelName = request.Model
}
if info.ChannelSetting.SystemPrompt != "" {
if request.System == nil {
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
} else if info.ChannelSetting.SystemPromptOverride {
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
if request.IsStringSystem() {
existing := strings.TrimSpace(request.GetStringSystem())
if existing == "" {
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
} else {
request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing)
}
} else {
systemContents := request.ParseSystem()
newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
newSystem.SetText(info.ChannelSetting.SystemPrompt)
if len(systemContents) == 0 {
request.System = []dto.ClaudeMediaMessage{newSystem}
} else {
request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...)
}
}
}
}
var requestBody io.Reader
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
body, err := common.GetRequestBody(c)

View File

@@ -79,34 +79,18 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
req.Images = []string{req.Image}
}
if req.HasImage() {
action = constant.TaskActionGenerate
if info.ChannelType == constant.ChannelTypeVidu {
// vidu 增加 首尾帧生视频和参考图生视频
if len(req.Images) == 2 {
action = constant.TaskActionFirstTailGenerate
} else if len(req.Images) > 2 {
action = constant.TaskActionReferenceGenerate
}
}
}
storeTaskRequest(c, info, action, req)
return nil
}
func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError {
hasPrompt, ok := requestObj.(HasPrompt)
if !ok {
return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true)
}
if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil {
return taskErr
}
action := constant.TaskActionTextGenerate
if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() {
action = constant.TaskActionGenerate
}
storeTaskRequest(c, info, action, requestObj)
return nil
}
func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError {
var req TaskSubmitReq
if err := c.ShouldBindJSON(&req); err != nil {
return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false)
}
return ValidateTaskRequestWithImage(c, info, req)
}

View File

@@ -90,41 +90,43 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if info.ChannelSetting.SystemPrompt != "" {
// 如果有系统提示,则将其添加到请求中
request := convertedRequest.(*dto.GeneralOpenAIRequest)
containSystemPrompt := false
for _, message := range request.Messages {
if message.Role == request.GetSystemRoleName() {
containSystemPrompt = true
break
}
}
if !containSystemPrompt {
// 如果没有系统提示,则添加系统提示
systemMessage := dto.Message{
Role: request.GetSystemRoleName(),
Content: info.ChannelSetting.SystemPrompt,
}
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
} else if info.ChannelSetting.SystemPromptOverride {
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
// 如果有系统提示,且允许覆盖,则拼接到前面
for i, message := range request.Messages {
request, ok := convertedRequest.(*dto.GeneralOpenAIRequest)
if ok {
containSystemPrompt := false
for _, message := range request.Messages {
if message.Role == request.GetSystemRoleName() {
if message.IsStringContent() {
request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
} else {
contents := message.ParseContent()
contents = append([]dto.MediaContent{
{
Type: dto.ContentTypeText,
Text: info.ChannelSetting.SystemPrompt,
},
}, contents...)
request.Messages[i].Content = contents
}
containSystemPrompt = true
break
}
}
if !containSystemPrompt {
// 如果没有系统提示,则添加系统提示
systemMessage := dto.Message{
Role: request.GetSystemRoleName(),
Content: info.ChannelSetting.SystemPrompt,
}
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
} else if info.ChannelSetting.SystemPromptOverride {
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
// 如果有系统提示,且允许覆盖,则拼接到前面
for i, message := range request.Messages {
if message.Role == request.GetSystemRoleName() {
if message.IsStringContent() {
request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
} else {
contents := message.ParseContent()
contents = append([]dto.MediaContent{
{
Type: dto.ContentTypeText,
Text: info.ChannelSetting.SystemPrompt,
},
}, contents...)
request.Messages[i].Content = contents
}
break
}
}
}
}
}
@@ -276,6 +278,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 +340,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 +440,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

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"one-api/common"
"one-api/constant"
"one-api/dto"
"one-api/logger"
"one-api/relay/channel/gemini"
@@ -94,6 +95,32 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
adaptor.Init(info)
if info.ChannelSetting.SystemPrompt != "" {
if request.SystemInstructions == nil {
request.SystemInstructions = &dto.GeminiChatContent{
Parts: []dto.GeminiPart{
{Text: info.ChannelSetting.SystemPrompt},
},
}
} else if len(request.SystemInstructions.Parts) == 0 {
request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}
} else if info.ChannelSetting.SystemPromptOverride {
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
merged := false
for i := range request.SystemInstructions.Parts {
if request.SystemInstructions.Parts[i].Text == "" {
continue
}
request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text
merged = true
break
}
if !merged {
request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...)
}
}
}
// Clean up empty system instruction
if request.SystemInstructions != nil {
hasContent := false

View File

@@ -52,6 +52,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
var cacheRatio float64
var imageRatio float64
var cacheCreationRatio float64
var audioRatio float64
var audioCompletionRatio float64
if !usePrice {
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
if meta.MaxTokens != 0 {
@@ -73,6 +75,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
} else {
@@ -90,6 +94,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
UsePrice: usePrice,
CacheRatio: cacheRatio,
ImageRatio: imageRatio,
AudioRatio: audioRatio,
AudioCompletionRatio: audioCompletionRatio,
CacheCreationRatio: cacheCreationRatio,
ShouldPreConsumedQuota: preConsumedQuota,
}

View File

@@ -21,7 +21,11 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt
case types.RelayFormatOpenAI:
request, err = GetAndValidateTextRequest(c, relayMode)
case types.RelayFormatGemini:
request, err = GetAndValidateGeminiRequest(c)
if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") {
request, err = GetAndValidateGeminiEmbeddingRequest(c)
} else {
request, err = GetAndValidateGeminiRequest(c)
}
case types.RelayFormatClaude:
request, err = GetAndValidateClaudeRequest(c)
case types.RelayFormatOpenAIResponses:
@@ -288,7 +292,6 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
}
func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
request := &dto.GeminiChatRequest{}
err := common.UnmarshalBodyReusable(c, request)
if err != nil {
@@ -304,3 +307,12 @@ func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error)
return request, nil
}
func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) {
request := &dto.GeminiEmbeddingRequest{}
err := common.UnmarshalBodyReusable(c, request)
if err != nil {
return nil, err
}
return request, nil
}

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

@@ -28,6 +28,12 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
return nil, fmt.Errorf("only support https url")
}
// SSRF防护验证请求URL
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
workerUrl := system_setting.WorkerUrl
if !strings.HasSuffix(workerUrl, "/") {
workerUrl += "/"
@@ -51,7 +57,13 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
}
return DoWorkerRequest(req)
} else {
common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
// SSRF防护验证请求URL非Worker模式
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, fmt.Errorf("request reject: %v", err)
}
common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
return http.Get(originUrl)
}
}

View File

@@ -19,7 +19,7 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
gopool.Go(func() {
relayInfoCopy := *relayInfo
err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
if err != nil {
common.SysLog("error return pre-consumed quota: " + err.Error())
}

View File

@@ -113,6 +113,12 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
}
} else {
// SSRF防护验证Bark URL非Worker模式
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
// 直接发送请求
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
if err != nil {

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"one-api/common"
"one-api/dto"
"one-api/setting/system_setting"
"time"
@@ -86,6 +87,12 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
}
} else {
// SSRF防护验证Webhook URL非Worker模式
fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return fmt.Errorf("request reject: %v", err)
}
req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create webhook request: %v", err)

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

@@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
"gemini-2.5-flash-lite-preview-06-17": 0.05,
"gemini-2.5-flash": 0.15,
"gemini-embedding-001": 0.075,
"text-embedding-004": 0.001,
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
@@ -278,6 +279,18 @@ var defaultModelPrice = map[string]float64{
"mj_upload": 0.05,
}
var defaultAudioRatio = map[string]float64{
"gpt-4o-audio-preview": 16,
"gpt-4o-mini-audio-preview": 66.67,
"gpt-4o-realtime-preview": 8,
"gpt-4o-mini-realtime-preview": 16.67,
}
var defaultAudioCompletionRatio = map[string]float64{
"gpt-4o-realtime": 2,
"gpt-4o-mini-realtime": 2,
}
var (
modelPriceMap map[string]float64 = nil
modelPriceMapMutex = sync.RWMutex{}
@@ -326,6 +339,15 @@ func InitRatioSettings() {
imageRatioMap = defaultImageRatio
imageRatioMapMutex.Unlock()
// initialize audioRatioMap
audioRatioMapMutex.Lock()
audioRatioMap = defaultAudioRatio
audioRatioMapMutex.Unlock()
// initialize audioCompletionRatioMap
audioCompletionRatioMapMutex.Lock()
audioCompletionRatioMap = defaultAudioCompletionRatio
audioCompletionRatioMapMutex.Unlock()
}
func GetModelPriceMap() map[string]float64 {
@@ -417,6 +439,18 @@ func GetDefaultModelRatioMap() map[string]float64 {
return defaultModelRatio
}
func GetDefaultImageRatioMap() map[string]float64 {
return defaultImageRatio
}
func GetDefaultAudioRatioMap() map[string]float64 {
return defaultAudioRatio
}
func GetDefaultAudioCompletionRatioMap() map[string]float64 {
return defaultAudioCompletionRatio
}
func GetCompletionRatioMap() map[string]float64 {
CompletionRatioMutex.RLock()
defer CompletionRatioMutex.RUnlock()
@@ -584,32 +618,22 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
func GetAudioRatio(name string) float64 {
if strings.Contains(name, "-realtime") {
if strings.HasSuffix(name, "gpt-4o-realtime-preview") {
return 8
} else if strings.Contains(name, "gpt-4o-mini-realtime-preview") {
return 10 / 0.6
} else {
return 20
}
}
if strings.Contains(name, "-audio") {
if strings.HasPrefix(name, "gpt-4o-audio-preview") {
return 40 / 2.5
} else if strings.HasPrefix(name, "gpt-4o-mini-audio-preview") {
return 10 / 0.15
} else {
return 40
}
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
name = FormatMatchingModelName(name)
if ratio, ok := audioRatioMap[name]; ok {
return ratio
}
return 20
}
func GetAudioCompletionRatio(name string) float64 {
if strings.HasPrefix(name, "gpt-4o-realtime") {
return 2
} else if strings.HasPrefix(name, "gpt-4o-mini-realtime") {
return 2
audioCompletionRatioMapMutex.RLock()
defer audioCompletionRatioMapMutex.RUnlock()
name = FormatMatchingModelName(name)
if ratio, ok := audioCompletionRatioMap[name]; ok {
return ratio
}
return 2
}
@@ -630,6 +654,14 @@ var defaultImageRatio = map[string]float64{
}
var imageRatioMap map[string]float64
var imageRatioMapMutex sync.RWMutex
var (
audioRatioMap map[string]float64 = nil
audioRatioMapMutex = sync.RWMutex{}
)
var (
audioCompletionRatioMap map[string]float64 = nil
audioCompletionRatioMapMutex = sync.RWMutex{}
)
func ImageRatio2JSONString() string {
imageRatioMapMutex.RLock()
@@ -658,6 +690,71 @@ func GetImageRatio(name string) (float64, bool) {
return ratio, true
}
func AudioRatio2JSONString() string {
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
jsonBytes, err := common.Marshal(audioRatioMap)
if err != nil {
common.SysError("error marshalling audio ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateAudioRatioByJSONString(jsonStr string) error {
tmp := make(map[string]float64)
if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
return err
}
audioRatioMapMutex.Lock()
audioRatioMap = tmp
audioRatioMapMutex.Unlock()
InvalidateExposedDataCache()
return nil
}
func GetAudioRatioCopy() map[string]float64 {
audioRatioMapMutex.RLock()
defer audioRatioMapMutex.RUnlock()
copyMap := make(map[string]float64, len(audioRatioMap))
for k, v := range audioRatioMap {
copyMap[k] = v
}
return copyMap
}
func AudioCompletionRatio2JSONString() string {
audioCompletionRatioMapMutex.RLock()
defer audioCompletionRatioMapMutex.RUnlock()
jsonBytes, err := common.Marshal(audioCompletionRatioMap)
if err != nil {
common.SysError("error marshalling audio completion ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
tmp := make(map[string]float64)
if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
return err
}
audioCompletionRatioMapMutex.Lock()
audioCompletionRatioMap = tmp
audioCompletionRatioMapMutex.Unlock()
InvalidateExposedDataCache()
return nil
}
func GetAudioCompletionRatioCopy() map[string]float64 {
audioCompletionRatioMapMutex.RLock()
defer audioCompletionRatioMapMutex.RUnlock()
copyMap := make(map[string]float64, len(audioCompletionRatioMap))
for k, v := range audioCompletionRatioMap {
copyMap[k] = v
}
return copyMap
}
func GetModelRatioCopy() map[string]float64 {
modelRatioMapMutex.RLock()
defer modelRatioMapMutex.RUnlock()

View File

@@ -0,0 +1,34 @@
package system_setting
import "one-api/setting/config"
type FetchSetting struct {
EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
AllowPrivateIp bool `json:"allow_private_ip"`
DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式true: 白名单模式false: 黑名单模式
IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式true: 白名单模式false: 黑名单模式
DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
IpList []string `json:"ip_list"` // CIDR format
AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤实验性
}
var defaultFetchSetting = FetchSetting{
EnableSSRFProtection: true, // 默认开启SSRF防护
AllowPrivateIp: false,
DomainFilterMode: false,
IpFilterMode: false,
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
ApplyIPFilterForDomain: false,
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting)
}
func GetFetchSetting() *FetchSetting {
return &defaultFetchSetting
}

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

@@ -122,6 +122,9 @@ func (e *NewAPIError) MaskSensitiveError() string {
return string(e.errorCode)
}
errStr := e.Err.Error()
if e.errorCode == ErrorCodeCountTokenFailed {
return errStr
}
return common.MaskSensitiveInfo(errStr)
}
@@ -153,8 +156,9 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
Code: e.errorCode,
}
}
result.Message = common.MaskSensitiveInfo(result.Message)
if e.errorCode != ErrorCodeCountTokenFailed {
result.Message = common.MaskSensitiveInfo(result.Message)
}
return result
}
@@ -178,7 +182,9 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
Type: string(e.errorType),
}
}
result.Message = common.MaskSensitiveInfo(result.Message)
if e.errorCode != ErrorCodeCountTokenFailed {
result.Message = common.MaskSensitiveInfo(result.Message)
}
return result
}

View File

@@ -15,6 +15,8 @@ type PriceData struct {
CacheRatio float64
CacheCreationRatio float64
ImageRatio float64
AudioRatio float64
AudioCompletionRatio float64
UsePrice bool
ShouldPreConsumedQuota int
GroupRatioInfo GroupRatioInfo
@@ -27,5 +29,5 @@ type PerCallPriceData struct {
}
func (p PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
}

9
web/jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}

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

@@ -17,7 +17,7 @@ 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 React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { ChevronDown } from 'lucide-react';
@@ -39,6 +39,7 @@ const UserArea = ({
navigate,
t,
}) => {
const dropdownRef = useRef(null);
if (isLoading) {
return (
<SkeletonWrapper
@@ -52,90 +53,93 @@ const UserArea = ({
if (userState.user) {
return (
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
<Dropdown.Item
onClick={() => {
navigate('/console/personal');
}}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconUserSetting
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('个人设置')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/token');
}}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconKey
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('令牌管理')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/topup');
}}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconCreditCard
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('钱包管理')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={logout}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconExit
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('退出')}</span>
</div>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
theme='borderless'
type='tertiary'
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
<div className='relative' ref={dropdownRef}>
<Dropdown
position='bottomRight'
getPopupContainer={() => dropdownRef.current}
render={
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
<Dropdown.Item
onClick={() => {
navigate('/console/personal');
}}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconUserSetting
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('个人设置')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/token');
}}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconKey
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('令牌管理')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/topup');
}}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconCreditCard
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('钱包管理')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={logout}
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
>
<div className='flex items-center gap-2'>
<IconExit
size='small'
className='text-gray-500 dark:text-gray-400'
/>
<span>{t('退出')}</span>
</div>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Avatar
size='extra-small'
color={stringToColor(userState.user.username)}
className='mr-1'
<Button
theme='borderless'
type='tertiary'
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
>
{userState.user.username[0].toUpperCase()}
</Avatar>
<span className='hidden md:inline'>
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
{userState.user.username}
</Typography.Text>
</span>
<ChevronDown
size={14}
className='text-xs text-semi-color-text-2 dark:text-gray-400'
/>
</Button>
</Dropdown>
<Avatar
size='extra-small'
color={stringToColor(userState.user.username)}
className='mr-1'
>
{userState.user.username[0].toUpperCase()}
</Avatar>
<span className='hidden md:inline'>
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
{userState.user.username}
</Typography.Text>
</span>
<ChevronDown
size={14}
className='text-xs text-semi-color-text-2 dark:text-gray-400'
/>
</Button>
</Dropdown>
</div>
);
} else {
const showRegisterButton = !isSelfUseMode;

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

@@ -39,6 +39,9 @@ const RatioSetting = () => {
CompletionRatio: '',
GroupRatio: '',
GroupGroupRatio: '',
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
ExposeRatioEnabled: false,
@@ -61,7 +64,10 @@ const RatioSetting = () => {
item.key === 'UserUsableGroups' ||
item.key === 'CompletionRatio' ||
item.key === 'ModelPrice' ||
item.key === 'CacheRatio'
item.key === 'CacheRatio' ||
item.key === 'ImageRatio' ||
item.key === 'AudioRatio' ||
item.key === 'AudioCompletionRatio'
) {
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);

View File

@@ -29,6 +29,7 @@ import {
TagInput,
Spin,
Card,
Radio,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
@@ -44,6 +45,7 @@ import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
@@ -87,6 +89,15 @@ const SystemSetting = () => {
LinuxDOClientSecret: '',
LinuxDOMinimumTrustLevel: '',
ServerAddress: '',
// SSRF防护配置
'fetch_setting.enable_ssrf_protection': true,
'fetch_setting.allow_private_ip': '',
'fetch_setting.domain_filter_mode': false, // true 白名单false 黑名单
'fetch_setting.ip_filter_mode': false, // true 白名单false 黑名单
'fetch_setting.domain_list': [],
'fetch_setting.ip_list': [],
'fetch_setting.allowed_ports': [],
'fetch_setting.apply_ip_filter_for_domain': false,
});
const [originInputs, setOriginInputs] = useState({});
@@ -98,6 +109,11 @@ const SystemSetting = () => {
useState(false);
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
const [emailToAdd, setEmailToAdd] = useState('');
const [domainFilterMode, setDomainFilterMode] = useState(true);
const [ipFilterMode, setIpFilterMode] = useState(true);
const [domainList, setDomainList] = useState([]);
const [ipList, setIpList] = useState([]);
const [allowedPorts, setAllowedPorts] = useState([]);
const getOptions = async () => {
setLoading(true);
@@ -113,6 +129,37 @@ const SystemSetting = () => {
case 'EmailDomainWhitelist':
setEmailDomainWhitelist(item.value ? item.value.split(',') : []);
break;
case 'fetch_setting.allow_private_ip':
case 'fetch_setting.enable_ssrf_protection':
case 'fetch_setting.domain_filter_mode':
case 'fetch_setting.ip_filter_mode':
case 'fetch_setting.apply_ip_filter_for_domain':
item.value = toBoolean(item.value);
break;
case 'fetch_setting.domain_list':
try {
const domains = item.value ? JSON.parse(item.value) : [];
setDomainList(Array.isArray(domains) ? domains : []);
} catch (e) {
setDomainList([]);
}
break;
case 'fetch_setting.ip_list':
try {
const ips = item.value ? JSON.parse(item.value) : [];
setIpList(Array.isArray(ips) ? ips : []);
} catch (e) {
setIpList([]);
}
break;
case 'fetch_setting.allowed_ports':
try {
const ports = item.value ? JSON.parse(item.value) : [];
setAllowedPorts(Array.isArray(ports) ? ports : []);
} catch (e) {
setAllowedPorts(['80', '443', '8080', '8443']);
}
break;
case 'PasswordLoginEnabled':
case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled':
@@ -140,6 +187,13 @@ const SystemSetting = () => {
});
setInputs(newInputs);
setOriginInputs(newInputs);
// 同步模式布尔到本地状态
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
}
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']);
}
if (formApiRef.current) {
formApiRef.current.setValues(newInputs);
}
@@ -276,6 +330,46 @@ const SystemSetting = () => {
}
};
const submitSSRF = async () => {
const options = [];
// 处理域名过滤模式与列表
options.push({
key: 'fetch_setting.domain_filter_mode',
value: domainFilterMode,
});
if (Array.isArray(domainList)) {
options.push({
key: 'fetch_setting.domain_list',
value: JSON.stringify(domainList),
});
}
// 处理IP过滤模式与列表
options.push({
key: 'fetch_setting.ip_filter_mode',
value: ipFilterMode,
});
if (Array.isArray(ipList)) {
options.push({
key: 'fetch_setting.ip_list',
value: JSON.stringify(ipList),
});
}
// 处理端口配置
if (Array.isArray(allowedPorts)) {
options.push({
key: 'fetch_setting.allowed_ports',
value: JSON.stringify(allowedPorts),
});
}
if (options.length > 0) {
await updateOptions(options);
}
};
const handleAddEmail = () => {
if (emailToAdd && emailToAdd.trim() !== '') {
const domain = emailToAdd.trim();
@@ -587,6 +681,179 @@ const SystemSetting = () => {
</Form.Section>
</Card>
<Card>
<Form.Section text={t('SSRF防护设置')}>
<Text extraText={t('SSRF防护详细说明')}>
{t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')}
</Text>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field='fetch_setting.enable_ssrf_protection'
noLabel
extraText={t('SSRF防护开关详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
}
>
{t('启用SSRF防护推荐开启以保护服务器安全')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field='fetch_setting.allow_private_ip'
noLabel
extraText={t('私有IP访问详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.allow_private_ip', e)
}
>
{t('允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Form.Checkbox
field='fetch_setting.apply_ip_filter_for_domain'
noLabel
extraText={t('域名IP过滤详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
}
style={{ marginBottom: 8 }}
>
{t('对域名启用 IP 过滤(实验性)')}
</Form.Checkbox>
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('支持通配符格式example.com, *.api.example.com')}
</Text>
<Radio.Group
type='button'
value={domainFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
const selected = val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist';
setDomainFilterMode(isWhitelist);
setInputs(prev => ({
...prev,
'fetch_setting.domain_filter_mode': isWhitelist,
}));
}}
style={{ marginBottom: 8 }}
>
<Radio value='whitelist'>{t('白名单')}</Radio>
<Radio value='blacklist'>{t('黑名单')}</Radio>
</Radio.Group>
<TagInput
value={domainList}
onChange={(value) => {
setDomainList(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
'fetch_setting.domain_list': value
}));
}}
placeholder={t('输入域名后回车example.com')}
style={{ width: '100%' }}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Text strong>
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('支持CIDR格式8.8.8.8, 192.168.1.0/24')}
</Text>
<Radio.Group
type='button'
value={ipFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
const selected = val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist';
setIpFilterMode(isWhitelist);
setInputs(prev => ({
...prev,
'fetch_setting.ip_filter_mode': isWhitelist,
}));
}}
style={{ marginBottom: 8 }}
>
<Radio value='whitelist'>{t('白名单')}</Radio>
<Radio value='blacklist'>{t('黑名单')}</Radio>
</Radio.Group>
<TagInput
value={ipList}
onChange={(value) => {
setIpList(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
'fetch_setting.ip_list': value
}));
}}
placeholder={t('输入IP地址后回车8.8.8.8')}
style={{ width: '100%' }}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Text strong>{t('允许的端口')}</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('支持单个端口和端口范围80, 443, 8000-8999')}
</Text>
<TagInput
value={allowedPorts}
onChange={(value) => {
setAllowedPorts(value);
// 触发Form的onChange事件
setInputs(prev => ({
...prev,
'fetch_setting.allowed_ports': value
}));
}}
placeholder={t('输入端口后回车80 或 8000-8999')}
style={{ width: '100%' }}
/>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('端口配置详细说明')}
</Text>
</Col>
</Row>
<Button onClick={submitSSRF} style={{ marginTop: 16 }}>
{t('更新SSRF防护设置')}
</Button>
</Form.Section>
</Card>
<Card>
<Form.Section text={t('配置登录注册')}>
<Row

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,10 +40,11 @@ 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';
import { useSidebar } from '../../../../hooks/common/useSidebar';
const NotificationSettings = ({
t,
@@ -97,6 +98,9 @@ const NotificationSettings = ({
isSidebarModuleAllowed,
} = useUserPermissions();
// 使用useSidebar钩子获取刷新方法
const { refreshUserConfig } = useSidebar();
// 左侧边栏设置处理函数
const handleSectionChange = (sectionKey) => {
return (checked) => {
@@ -132,6 +136,9 @@ const NotificationSettings = ({
});
if (res.data.success) {
showSuccess(t('侧边栏设置保存成功'));
// 刷新useSidebar钩子中的用户配置实现实时更新
await refreshUserConfig();
} else {
showError(res.data.message);
}
@@ -334,7 +341,7 @@ const NotificationSettings = ({
loading={sidebarLoading}
className='!rounded-lg'
>
{t('保存边栏设置')}
{t('保存设置')}
</Button>
</>
) : (

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

@@ -247,6 +247,32 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
}
};
// Delete a specific key
const handleDeleteKey = async (keyIndex) => {
const operationId = `delete_${keyIndex}`;
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
try {
const res = await API.post('/api/channel/multi_key/manage', {
channel_id: channel.id,
action: 'delete_key',
key_index: keyIndex,
});
if (res.data.success) {
showSuccess(t('密钥已删除'));
await loadKeyStatus(currentPage, pageSize); // Reload current page
onRefresh && onRefresh(); // Refresh parent component
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('删除密钥失败'));
} finally {
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
}
};
// Handle page change
const handlePageChange = (page) => {
setCurrentPage(page);
@@ -384,7 +410,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
title: t('操作'),
key: 'action',
fixed: 'right',
width: 100,
width: 150,
render: (_, record) => (
<Space>
{record.status === 1 ? (
@@ -406,6 +432,21 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
{t('启用')}
</Button>
)}
<Popconfirm
title={t('确定要删除此密钥吗?')}
content={t('此操作不可撤销,将永久删除该密钥')}
onConfirm={() => handleDeleteKey(record.index)}
okType={'danger'}
position={'topRight'}
>
<Button
type='danger'
size='small'
loading={operationLoading[`delete_${record.index}`]}
>
{t('删除')}
</Button>
</Popconfirm>
</Space>
),
},

View File

@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
const MjLogsFilters = ({
formInitValues,
setFormApi,
@@ -54,6 +56,11 @@ const MjLogsFilters = ({
showClear
pure
size='small'
presets={DATE_RANGE_PRESETS.map(preset => ({
text: t(preset.text),
start: preset.start(),
end: preset.end()
}))}
/>
</div>

View File

@@ -35,8 +35,9 @@ import {
Sparkles,
} from 'lucide-react';
import {
TASK_ACTION_GENERATE,
TASK_ACTION_TEXT_GENERATE,
TASK_ACTION_FIRST_TAIL_GENERATE,
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
TASK_ACTION_TEXT_GENERATE
} from '../../../constants/common.constant';
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
@@ -111,6 +112,18 @@ const renderType = (type, t) => {
{t('文生视频')}
</Tag>
);
case TASK_ACTION_FIRST_TAIL_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('首尾生视频')}
</Tag>
);
case TASK_ACTION_REFERENCE_GENERATE:
return (
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
{t('参照生视频')}
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
@@ -343,7 +356,9 @@ export const getTaskLogsColumns = ({
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
const isVideoTask =
record.action === TASK_ACTION_GENERATE ||
record.action === TASK_ACTION_TEXT_GENERATE;
record.action === TASK_ACTION_TEXT_GENERATE ||
record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
record.action === TASK_ACTION_REFERENCE_GENERATE;
const isSuccess = record.status === 'SUCCESS';
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
if (isSuccess && isVideoTask && isUrl) {

View File

@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
const TaskLogsFilters = ({
formInitValues,
setFormApi,
@@ -54,6 +56,11 @@ const TaskLogsFilters = ({
showClear
pure
size='small'
presets={DATE_RANGE_PRESETS.map(preset => ({
text: t(preset.text),
start: preset.start(),
end: preset.end()
}))}
/>
</div>

View File

@@ -21,6 +21,8 @@ import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
const LogsFilters = ({
formInitValues,
setFormApi,
@@ -55,6 +57,11 @@ const LogsFilters = ({
showClear
pure
size='small'
presets={DATE_RANGE_PRESETS.map(preset => ({
text: t(preset.text),
start: preset.start(),
end: preset.end()
}))}
/>
</div>

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

@@ -40,3 +40,5 @@ export const API_ENDPOINTS = [
export const TASK_ACTION_GENERATE = 'generate';
export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate';
export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate';

View File

@@ -0,0 +1,49 @@
/*
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 dayjs from 'dayjs';
// ========== 日期预设常量 ==========
export const DATE_RANGE_PRESETS = [
{
text: '今天',
start: () => dayjs().startOf('day').toDate(),
end: () => dayjs().endOf('day').toDate()
},
{
text: '近 7 天',
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
end: () => dayjs().endOf('day').toDate()
},
{
text: '本周',
start: () => dayjs().startOf('week').toDate(),
end: () => dayjs().endOf('week').toDate()
},
{
text: '近 30 天',
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
end: () => dayjs().endOf('day').toDate()
},
{
text: '本月',
start: () => dayjs().startOf('month').toDate(),
end: () => dayjs().endOf('month').toDate()
},
];

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

@@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react';
import { StatusContext } from '../../context/Status';
import { API } from '../../helpers';
// 创建一个全局事件系统来同步所有useSidebar实例
const sidebarEventTarget = new EventTarget();
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
export const useSidebar = () => {
const [statusState] = useContext(StatusContext);
const [userConfig, setUserConfig] = useState(null);
@@ -124,9 +128,12 @@ export const useSidebar = () => {
// 刷新用户配置的方法(供外部调用)
const refreshUserConfig = async () => {
if (Object.keys(adminConfig).length > 0) {
if (Object.keys(adminConfig).length > 0) {
await loadUserConfig();
}
// 触发全局刷新事件通知所有useSidebar实例更新
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
};
// 加载用户配置
@@ -137,6 +144,21 @@ export const useSidebar = () => {
}
}, [adminConfig]);
// 监听全局刷新事件
useEffect(() => {
const handleRefresh = () => {
if (Object.keys(adminConfig).length > 0) {
loadUserConfig();
}
};
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
return () => {
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
};
}, [adminConfig]);
// 计算最终的显示配置
const finalConfig = useMemo(() => {
const result = {};

View File

@@ -102,7 +102,7 @@ export const useDashboardStats = (
},
{
title: t('统计Tokens'),
value: isNaN(consumeTokens) ? 0 : consumeTokens,
value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
icon: <IconTextStroked />,
avatarColor: 'pink',
trendData: trendData.tokens,

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",
@@ -1903,6 +1889,10 @@
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
"删除自动禁用密钥": "Delete auto disabled keys",
"确定要删除此密钥吗?": "Are you sure you want to delete this key?",
"此操作不可撤销,将永久删除该密钥": "This operation cannot be undone, and the key will be permanently deleted.",
"密钥已删除": "Key has been deleted",
"删除密钥失败": "Failed to delete key",
"图标": "Icon",
"模型图标": "Model icon",
"请输入图标名称": "Please enter the icon name",
@@ -2013,6 +2003,16 @@
"查看渠道密钥": "View channel key",
"渠道密钥信息": "Channel key information",
"密钥获取成功": "Key acquisition successful",
"模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
"图片倍率": "Image ratio",
"音频倍率": "Audio ratio",
"音频补全倍率": "Audio completion ratio",
"图片输入相关的倍率设置,键为模型名称,值为倍率": "Image input related ratio settings, key is model name, value is ratio",
"音频输入相关的倍率设置,键为模型名称,值为倍率": "Audio input related ratio settings, key is model name, value is ratio",
"音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Audio output completion related ratio settings, key is model name, value is ratio",
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}",
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}",
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}",
"顶栏管理": "Header Management",
"控制顶栏模块显示状态,全局生效": "Control header module display status, global effect",
"用户主页,展示系统信息": "User homepage, displaying system information",
@@ -2072,7 +2072,7 @@
"需要登录访问": "Require Login",
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
"参与官方同步": "Participate in official sync",
"关闭后,此模型将不会被同步官方自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
"关闭后,此模型将不会被\"同步官方\"自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
"同步": "Sync",
"同步向导": "Sync Wizard",
"选择方式": "Select method",
@@ -2099,161 +2099,35 @@
"优惠": "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"
"今天": "Today",
"近 7 天": "Last 7 Days",
"本周": "This Week",
"本月": "This Month",
"近 30 天": "Last 30 Days",
"代理设置": "Proxy Settings",
"更新Worker设置": "Update Worker Settings",
"SSRF防护设置": "SSRF Protection Settings",
"配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "Configure Server-Side Request Forgery (SSRF) protection to secure internal network resources",
"SSRF防护详细说明": "SSRF protection prevents malicious users from using your server to access internal network resources. Configure whitelists for trusted domains/IPs and restrict allowed ports. Applies to file downloads, webhooks, and notifications.",
"启用SSRF防护推荐开启以保护服务器安全": "Enable SSRF Protection (Recommended for server security)",
"SSRF防护开关详细说明": "Master switch controls whether SSRF protection is enabled. When disabled, all SSRF checks are bypassed, allowing access to any URL. ⚠️ Only disable this feature in completely trusted environments.",
"允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址": "Allow access to private IP addresses (127.0.0.1, 192.168.x.x and other internal addresses)",
"私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.",
"域名白名单": "Domain Whitelist",
"支持通配符格式example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com",
"域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.",
"输入域名后回车example.com": "Enter domain and press Enter, e.g.: example.com",
"支持CIDR格式8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24",
"IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.",
"输入IP地址后回车8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
"允许的端口": "Allowed Ports",
"支持单个端口和端口范围80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999",
"端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.",
"输入端口后回车80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999",
"更新SSRF防护设置": "Update SSRF Protection Settings",
"对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)",
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
"域名黑名单": "Domain Blacklist",
"白名单": "Whitelist",
"黑名单": "Blacklist"
}

View File

@@ -9,5 +9,28 @@
"语言": "语言",
"展开侧边栏": "展开侧边栏",
"关闭侧边栏": "关闭侧边栏",
"注销成功!": "注销成功!"
"注销成功!": "注销成功!",
"代理设置": "代理设置",
"更新Worker设置": "更新Worker设置",
"SSRF防护设置": "SSRF防护设置",
"配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全",
"SSRF防护详细说明": "SSRF防护可防止恶意用户利用您的服务器访问内网资源。您可以配置受信任域名/IP的白名单并限制允许的端口。适用于文件下载、Webhook回调和通知功能。",
"启用SSRF防护推荐开启以保护服务器安全": "启用SSRF防护推荐开启以保护服务器安全",
"SSRF防护开关详细说明": "总开关控制是否启用SSRF防护功能。关闭后将跳过所有SSRF检查允许访问任意URL。⚠ 仅在完全信任环境中关闭此功能。",
"允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址": "允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址",
"私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。",
"域名白名单": "域名白名单",
"支持通配符格式example.com, *.api.example.com": "支持通配符格式example.com, *.api.example.com",
"域名白名单详细说明": "白名单中的域名将绕过所有SSRF检查直接允许访问。支持精确域名example.com或通配符*.api.example.com匹配子域名。白名单为空时所有域名都需要通过SSRF检查。",
"输入域名后回车example.com": "输入域名后回车example.com",
"IP白名单": "IP白名单",
"支持CIDR格式8.8.8.8, 192.168.1.0/24": "支持CIDR格式8.8.8.8, 192.168.1.0/24",
"IP白名单详细说明": "控制允许访问的IP地址。支持单个IP8.8.8.8或CIDR网段192.168.1.0/24。空白名单允许所有IP但仍受私有IP设置限制非空白名单仅允许列表中的IP访问。",
"输入IP地址后回车8.8.8.8": "输入IP地址后回车8.8.8.8",
"允许的端口": "允许的端口",
"支持单个端口和端口范围80, 443, 8000-8999": "支持单个端口和端口范围80, 443, 8000-8999",
"端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口80, 443或端口范围8000-8999。空列表允许所有端口。默认包含常用Web端口。",
"输入端口后回车80 或 8000-8999": "输入端口后回车80 或 8000-8999",
"更新SSRF防护设置": "更新SSRF防护设置",
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。"
}

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

@@ -44,6 +44,9 @@ export default function ModelRatioSettings(props) {
ModelRatio: '',
CacheRatio: '',
CompletionRatio: '',
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
ExposeRatioEnabled: false,
});
const refForm = useRef();
@@ -219,6 +222,72 @@ export default function ModelRatioSettings(props) {
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('图片输入倍率(仅部分模型支持该计费)')}
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
field={'ImageRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, ImageRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('音频倍率(仅部分模型支持该计费)')}
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
field={'AudioRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, AudioRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
label={t('音频补全倍率(仅部分模型支持该计费)')}
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
field={'AudioCompletionRatio'}
autosize={{ minRows: 6, maxRows: 12 }}
trigger='blur'
stopValidateWithError
rules={[
{
validator: (rule, value) => verifyJSON(value),
message: '不是合法的 JSON 字符串',
},
]}
onChange={(value) =>
setInputs({ ...inputs, AudioCompletionRatio: value })
}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Switch

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' }}>

View File

@@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com
import react from '@vitejs/plugin-react';
import { defineConfig, transformWithEsbuild } from 'vite';
import pkg from '@douyinfe/vite-plugin-semi';
import path from 'path';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
{
name: 'treat-js-files-as-jsx',