Compare commits
16 Commits
refactor/s
...
feature/ss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380e1b7d56 | ||
|
|
63828349de | ||
|
|
5706f0ee9f | ||
|
|
e9e1dbff5e | ||
|
|
315eabc1e7 | ||
|
|
359dbc9d94 | ||
|
|
e157ea6ba2 | ||
|
|
dc3dba0665 | ||
|
|
81272da9ac | ||
|
|
926cad87b3 | ||
|
|
418ce449b7 | ||
|
|
4a02ab23ce | ||
|
|
984097c60b | ||
|
|
5550ec017e | ||
|
|
9e6752e0ee | ||
|
|
91a0eb7031 |
4
.gitignore
vendored
@@ -11,6 +11,4 @@ web/dist
|
||||
one-api
|
||||
.DS_Store
|
||||
tiktoken_cache
|
||||
.eslintcache
|
||||
.cursor
|
||||
*.mdc
|
||||
.eslintcache
|
||||
22
common/ip.go
@@ -1,22 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -11,10 +11,8 @@ const (
|
||||
SunoActionMusic = "MUSIC"
|
||||
SunoActionLyrics = "LYRICS"
|
||||
|
||||
TaskActionGenerate = "generate"
|
||||
TaskActionTextGenerate = "textGenerate"
|
||||
TaskActionFirstTailGenerate = "firstTailGenerate"
|
||||
TaskActionReferenceGenerate = "referenceGenerate"
|
||||
TaskActionGenerate = "generate"
|
||||
TaskActionTextGenerate = "textGenerate"
|
||||
)
|
||||
|
||||
var SunoModel2Action = map[string]string{
|
||||
|
||||
@@ -90,11 +90,6 @@ 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}, // 使用动态路径
|
||||
@@ -114,21 +109,6 @@ 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{
|
||||
@@ -160,9 +140,6 @@ 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)
|
||||
|
||||
@@ -224,22 +201,6 @@ 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)
|
||||
|
||||
@@ -501,10 +501,9 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
||||
}
|
||||
|
||||
type AddChannelRequest struct {
|
||||
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"`
|
||||
Mode string `json:"mode"`
|
||||
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
||||
Channel *model.Channel `json:"channel"`
|
||||
}
|
||||
|
||||
func getVertexArrayKeys(keys string) ([]string, error) {
|
||||
@@ -617,13 +616,6 @@ 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)
|
||||
|
||||
375
controller/oauth.go
Normal file
@@ -0,0 +1,375 @@
|
||||
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)
|
||||
}
|
||||
374
controller/oauth_client.go
Normal file
@@ -0,0 +1,374 @@
|
||||
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
|
||||
}
|
||||
89
controller/oauth_keys.go
Normal file
@@ -0,0 +1,89 @@
|
||||
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})
|
||||
}
|
||||
@@ -128,33 +128,6 @@ 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 {
|
||||
|
||||
@@ -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(200, gin.H{
|
||||
c.JSON(400, 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(200, gin.H{
|
||||
c.JSON(400, 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(200, gin.H{
|
||||
c.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"message": "用户名长度不能超过12个字符",
|
||||
})
|
||||
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
|
||||
}
|
||||
// Validate password
|
||||
if req.Password != req.ConfirmPassword {
|
||||
c.JSON(200, gin.H{
|
||||
c.JSON(400, gin.H{
|
||||
"success": false,
|
||||
"message": "两次输入的密码不一致",
|
||||
})
|
||||
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(200, gin.H{
|
||||
c.JSON(400, 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(200, gin.H{
|
||||
c.JSON(500, 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(200, gin.H{
|
||||
c.JSON(500, 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(200, gin.H{
|
||||
c.JSON(500, 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(200, gin.H{
|
||||
c.JSON(500, 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(200, gin.H{
|
||||
c.JSON(500, gin.H{
|
||||
"success": false,
|
||||
"message": "系统初始化失败: " + err.Error(),
|
||||
})
|
||||
|
||||
@@ -772,12 +772,11 @@ type OpenAIResponsesRequest struct {
|
||||
Instructions json.RawMessage `json:"instructions,omitempty"`
|
||||
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
||||
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
Store json.RawMessage `json:"store,omitempty"`
|
||||
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||
Store bool `json:"store,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
|
||||
@@ -6,10 +6,6 @@ import (
|
||||
"one-api/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
|
||||
)
|
||||
|
||||
type SimpleResponse struct {
|
||||
Usage `json:"usage"`
|
||||
Error any `json:"error"`
|
||||
@@ -277,42 +273,6 @@ 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"`
|
||||
}
|
||||
@@ -323,8 +283,6 @@ 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 {
|
||||
|
||||
326
examples/oauth/oauth-demo.html
Normal file
@@ -0,0 +1,326 @@
|
||||
<!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 Challenge(S256)</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 Token(JWT)</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>
|
||||
181
examples/oauth/oauth2_test_client.go
Normal file
@@ -0,0 +1,181 @@
|
||||
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
@@ -11,20 +11,24 @@ 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-20220118071334-3db87571198b
|
||||
github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6
|
||||
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
|
||||
@@ -38,6 +42,7 @@ 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
|
||||
@@ -55,6 +60,7 @@ 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
|
||||
@@ -65,7 +71,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.2 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // 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
|
||||
@@ -79,14 +85,25 @@ 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
|
||||
@@ -94,7 +111,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.30.0 // indirect
|
||||
golang.org/x/sys v0.31.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
@@ -1,5 +1,7 @@
|
||||
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=
|
||||
@@ -23,8 +25,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-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
|
||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
|
||||
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/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=
|
||||
@@ -39,16 +41,22 @@ 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=
|
||||
@@ -67,6 +75,10 @@ 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=
|
||||
@@ -90,20 +102,26 @@ 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.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/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/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=
|
||||
@@ -112,6 +130,8 @@ 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=
|
||||
@@ -132,6 +152,10 @@ 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=
|
||||
@@ -148,6 +172,18 @@ 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=
|
||||
@@ -160,6 +196,8 @@ 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=
|
||||
@@ -184,10 +222,18 @@ 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=
|
||||
@@ -200,21 +246,35 @@ 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=
|
||||
@@ -229,8 +289,24 @@ 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=
|
||||
@@ -247,6 +323,8 @@ 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=
|
||||
@@ -257,12 +335,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-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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=
|
||||
|
||||
9
main.go
@@ -14,6 +14,7 @@ import (
|
||||
"one-api/router"
|
||||
"one-api/service"
|
||||
"one-api/setting/ratio_setting"
|
||||
"one-api/src/oauth"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
@@ -203,5 +204,13 @@ 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
|
||||
}
|
||||
@@ -8,11 +8,14 @@ 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 {
|
||||
@@ -177,6 +180,7 @@ 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
|
||||
@@ -235,6 +239,11 @@ 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
|
||||
}
|
||||
@@ -288,6 +297,74 @@ 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")
|
||||
|
||||
291
middleware/oauth_jwt.go
Normal file
@@ -0,0 +1,291 @@
|
||||
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")
|
||||
}
|
||||
@@ -265,6 +265,7 @@ func migrateDB() error {
|
||||
&Setup{},
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
&OAuthClient{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
183
model/oauth_client.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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
|
||||
}
|
||||
57
model/oauth_revoked_token.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
@@ -112,9 +112,6 @@ 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
|
||||
@@ -400,12 +397,6 @@ 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":
|
||||
|
||||
@@ -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 := claude.Adaptor{}
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
@@ -44,19 +44,14 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
fimBaseUrl := info.ChannelBaseUrl
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
return fmt.Sprintf("%s/anthropic/v1/messages", 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:
|
||||
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
|
||||
}
|
||||
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +87,12 @@ 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) {
|
||||
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)
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetModelList() []string {
|
||||
|
||||
@@ -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.Contains(info.RequestURLPath, ":embedContent") ||
|
||||
strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
|
||||
if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
|
||||
strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
|
||||
return NativeGeminiEmbeddingHandler(c, resp, info)
|
||||
}
|
||||
if info.IsStream {
|
||||
|
||||
@@ -23,7 +23,6 @@ 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,
|
||||
@@ -31,7 +30,6 @@ 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,
|
||||
|
||||
@@ -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 := claude.Adaptor{}
|
||||
adaptor := openai.Adaptor{}
|
||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,6 @@ 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)
|
||||
|
||||
@@ -86,25 +80,18 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
||||
sendResponsesStreamData(c, streamResponse, data)
|
||||
switch streamResponse.Type {
|
||||
case "response.completed":
|
||||
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 != nil && streamResponse.Response.Usage != nil {
|
||||
if streamResponse.Response.Usage.InputTokens != 0 {
|
||||
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
||||
}
|
||||
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())
|
||||
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
|
||||
}
|
||||
}
|
||||
case "response.output_text.delta":
|
||||
|
||||
@@ -94,9 +94,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -104,12 +101,7 @@ 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")
|
||||
if isNewAPIRelay(info.ApiKey) {
|
||||
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
|
||||
} else {
|
||||
return a.signRequest(req, a.accessKey, a.secretKey)
|
||||
}
|
||||
return nil
|
||||
return a.signRequest(req, a.accessKey, a.secretKey)
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Jimeng specific format.
|
||||
@@ -169,9 +161,6 @@ 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,
|
||||
@@ -189,20 +178,17 @@ 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")
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
return service.GetHttpClient().Do(req)
|
||||
}
|
||||
|
||||
@@ -398,7 +384,3 @@ 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-")
|
||||
}
|
||||
|
||||
@@ -117,11 +117,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -204,9 +199,6 @@ 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 {
|
||||
@@ -312,13 +304,8 @@ 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
|
||||
@@ -365,7 +352,3 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
|
||||
}
|
||||
return taskInfo, nil
|
||||
}
|
||||
|
||||
func isNewAPIRelay(apiKey string) bool {
|
||||
return strings.HasPrefix(apiKey, "sk-")
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||||
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
|
||||
// Use the unified validation method for TaskSubmitReq with image-based action determination
|
||||
return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
|
||||
}
|
||||
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
@@ -111,10 +112,6 @@ 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"
|
||||
}
|
||||
@@ -190,9 +187,14 @@ 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: req.Images,
|
||||
Images: images,
|
||||
Prompt: req.Prompt,
|
||||
Duration: defaultInt(req.Duration, 5),
|
||||
Resolution: defaultString(req.Size, "1080p"),
|
||||
|
||||
@@ -41,8 +41,6 @@ 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
|
||||
|
||||
@@ -8,7 +8,6 @@ var ModelList = []string{
|
||||
"Doubao-lite-32k",
|
||||
"Doubao-lite-4k",
|
||||
"Doubao-embedding",
|
||||
"doubao-seedream-4-0-250828",
|
||||
}
|
||||
|
||||
var ChannelName = "volcengine"
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
@@ -70,31 +69,6 @@ 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)
|
||||
|
||||
@@ -79,18 +79,34 @@ 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)
|
||||
}
|
||||
|
||||
@@ -90,41 +90,39 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
|
||||
if info.ChannelSetting.SystemPrompt != "" {
|
||||
// 如果有系统提示,则将其添加到请求中
|
||||
request, ok := convertedRequest.(*dto.GeneralOpenAIRequest)
|
||||
if ok {
|
||||
containSystemPrompt := false
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == request.GetSystemRoleName() {
|
||||
containSystemPrompt = true
|
||||
break
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,13 +276,6 @@ 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
|
||||
|
||||
@@ -340,8 +331,6 @@ 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
|
||||
@@ -440,10 +429,6 @@ 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,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/constant"
|
||||
"one-api/dto"
|
||||
"one-api/logger"
|
||||
"one-api/relay/channel/gemini"
|
||||
@@ -95,32 +94,6 @@ 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
|
||||
|
||||
@@ -52,8 +52,6 @@ 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 {
|
||||
@@ -75,8 +73,6 @@ 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 {
|
||||
@@ -94,8 +90,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
UsePrice: usePrice,
|
||||
CacheRatio: cacheRatio,
|
||||
ImageRatio: imageRatio,
|
||||
AudioRatio: audioRatio,
|
||||
AudioCompletionRatio: audioCompletionRatio,
|
||||
CacheCreationRatio: cacheCreationRatio,
|
||||
ShouldPreConsumedQuota: preConsumedQuota,
|
||||
}
|
||||
|
||||
@@ -21,11 +21,7 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt
|
||||
case types.RelayFormatOpenAI:
|
||||
request, err = GetAndValidateTextRequest(c, relayMode)
|
||||
case types.RelayFormatGemini:
|
||||
if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") {
|
||||
request, err = GetAndValidateGeminiEmbeddingRequest(c)
|
||||
} else {
|
||||
request, err = GetAndValidateGeminiRequest(c)
|
||||
}
|
||||
request, err = GetAndValidateGeminiRequest(c)
|
||||
case types.RelayFormatClaude:
|
||||
request, err = GetAndValidateClaudeRequest(c)
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
@@ -292,6 +288,7 @@ 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 {
|
||||
@@ -307,12 +304,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,21 @@ 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)
|
||||
@@ -40,6 +55,17 @@ 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)
|
||||
@@ -78,7 +104,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
adminRoute.Use(middleware.AdminAuth())
|
||||
adminRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
adminRoute.GET("/", controller.GetAllUsers)
|
||||
adminRoute.GET("/search", controller.SearchUsers)
|
||||
@@ -94,7 +120,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
}
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
optionRoute.Use(middleware.RootAuth())
|
||||
optionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.RootAuth())
|
||||
{
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.PUT("/", controller.UpdateOption)
|
||||
@@ -108,7 +134,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
|
||||
}
|
||||
channelRoute := apiRouter.Group("/channel")
|
||||
channelRoute.Use(middleware.AdminAuth())
|
||||
channelRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
channelRoute.GET("/", controller.GetAllChannels)
|
||||
channelRoute.GET("/search", controller.SearchChannels)
|
||||
@@ -159,7 +185,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
}
|
||||
|
||||
redemptionRoute := apiRouter.Group("/redemption")
|
||||
redemptionRoute.Use(middleware.AdminAuth())
|
||||
redemptionRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
redemptionRoute.GET("/", controller.GetAllRedemptions)
|
||||
redemptionRoute.GET("/search", controller.SearchRedemptions)
|
||||
@@ -187,13 +213,13 @@ func SetApiRouter(router *gin.Engine) {
|
||||
logRoute.GET("/token", controller.GetLogByKey)
|
||||
}
|
||||
groupRoute := apiRouter.Group("/group")
|
||||
groupRoute.Use(middleware.AdminAuth())
|
||||
groupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
groupRoute.GET("/", controller.GetGroups)
|
||||
}
|
||||
|
||||
prefillGroupRoute := apiRouter.Group("/prefill_group")
|
||||
prefillGroupRoute.Use(middleware.AdminAuth())
|
||||
prefillGroupRoute.Use(middleware.OptionalOAuthAuth(), middleware.RequireOAuthScopeIfPresent("admin"), middleware.AdminAuth())
|
||||
{
|
||||
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
|
||||
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
|
||||
@@ -235,5 +261,17 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,6 @@ 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 += "/"
|
||||
@@ -57,13 +51,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
|
||||
}
|
||||
return DoWorkerRequest(req)
|
||||
} else {
|
||||
// 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, ", ")))
|
||||
common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
|
||||
return http.Get(originUrl)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
|
||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
|
||||
if err != nil {
|
||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -113,12 +113,6 @@ 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 {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/setting/system_setting"
|
||||
"time"
|
||||
@@ -87,12 +86,6 @@ 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)
|
||||
|
||||
@@ -10,18 +10,6 @@ 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
|
||||
@@ -77,31 +65,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -178,7 +178,6 @@ 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
|
||||
@@ -279,18 +278,6 @@ 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{}
|
||||
@@ -339,15 +326,6 @@ 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 {
|
||||
@@ -439,18 +417,6 @@ 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()
|
||||
@@ -618,22 +584,32 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
}
|
||||
|
||||
func GetAudioRatio(name string) float64 {
|
||||
audioRatioMapMutex.RLock()
|
||||
defer audioRatioMapMutex.RUnlock()
|
||||
name = FormatMatchingModelName(name)
|
||||
if ratio, ok := audioRatioMap[name]; ok {
|
||||
return ratio
|
||||
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
|
||||
}
|
||||
}
|
||||
return 20
|
||||
}
|
||||
|
||||
func GetAudioCompletionRatio(name string) float64 {
|
||||
audioCompletionRatioMapMutex.RLock()
|
||||
defer audioCompletionRatioMapMutex.RUnlock()
|
||||
name = FormatMatchingModelName(name)
|
||||
if ratio, ok := audioCompletionRatioMap[name]; ok {
|
||||
|
||||
return ratio
|
||||
if strings.HasPrefix(name, "gpt-4o-realtime") {
|
||||
return 2
|
||||
} else if strings.HasPrefix(name, "gpt-4o-mini-realtime") {
|
||||
return 2
|
||||
}
|
||||
return 2
|
||||
}
|
||||
@@ -654,14 +630,6 @@ 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()
|
||||
@@ -690,71 +658,6 @@ 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()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
74
setting/system_setting/oauth2.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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{}
|
||||
}
|
||||
1069
src/oauth/server.go
Normal file
82
src/oauth/store.go
Normal file
@@ -0,0 +1,82 @@
|
||||
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) }
|
||||
59
src/oauth/util.go
Normal file
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
}
|
||||
@@ -122,9 +122,6 @@ func (e *NewAPIError) MaskSensitiveError() string {
|
||||
return string(e.errorCode)
|
||||
}
|
||||
errStr := e.Err.Error()
|
||||
if e.errorCode == ErrorCodeCountTokenFailed {
|
||||
return errStr
|
||||
}
|
||||
return common.MaskSensitiveInfo(errStr)
|
||||
}
|
||||
|
||||
@@ -156,9 +153,8 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
|
||||
Code: e.errorCode,
|
||||
}
|
||||
}
|
||||
if e.errorCode != ErrorCodeCountTokenFailed {
|
||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||
}
|
||||
|
||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -182,9 +178,7 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
|
||||
Type: string(e.errorType),
|
||||
}
|
||||
}
|
||||
if e.errorCode != ErrorCodeCountTokenFailed {
|
||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||
}
|
||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ type PriceData struct {
|
||||
CacheRatio float64
|
||||
CacheCreationRatio float64
|
||||
ImageRatio float64
|
||||
AudioRatio float64
|
||||
AudioCompletionRatio float64
|
||||
UsePrice bool
|
||||
ShouldPreConsumedQuota int
|
||||
GroupRatioInfo GroupRatioInfo
|
||||
@@ -29,5 +27,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, 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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
VITE_CLERK_PUBLISHABLE_KEY=
|
||||
128
web/.github/CODE_OF_CONDUCT.md
vendored
@@ -1,128 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
101
web/.github/CONTRIBUTING.md
vendored
@@ -1,101 +0,0 @@
|
||||
# Contributing to Shadcn-Admin
|
||||
|
||||
Thank you for considering contributing to **shadcn-admin**! Every contribution is valuable, whether it's reporting bugs, suggesting improvements, adding features, or refining README.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Getting Started](#getting-started)
|
||||
2. [How to Contribute](#how-to-contribute)
|
||||
3. [Code Standards](#code-standards)
|
||||
4. [Pull Request Guidelines](#pull-request-guidelines)
|
||||
5. [Reporting Issues](#reporting-issues)
|
||||
6. [Community Guidelines](#community-guidelines)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork** the repository.
|
||||
2. **Clone** your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/shadcn-admin.git
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Run the project locally:**
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
5. Create a new branch for your contribution:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Contribute
|
||||
|
||||
- **Feature Requests:** Open an issue or start a discussion to discuss the feature before implementation.
|
||||
- **Bug Fixes:** Provide clear reproduction steps in your issue.
|
||||
- **Documentation:** Improvements to the documentation (README) are always appreciated.
|
||||
|
||||
> **Note:** Pull Requests adding new features without a prior issue or discussion will **not be accepted**.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
- Follow the existing **ESLint** and **Prettier** configurations.
|
||||
- Ensure your code is **type-safe** with **TypeScript**.
|
||||
- Maintain consistency with the existing code structure.
|
||||
|
||||
> **Tips!** Before submitting your changes, run the following commands:
|
||||
|
||||
```bash
|
||||
pnpm lint && pnpm format && pnpm knip && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- **Follow the [PR Template](./PULL_REQUEST_TEMPLATE.md):**
|
||||
- Description
|
||||
- Types of changes
|
||||
- Checklist
|
||||
- Further comments
|
||||
- Related Issue
|
||||
- Ensure your changes pass **CI checks**.
|
||||
- Keep PRs **focused** and **concise**.
|
||||
- Reference related issues in your PR description.
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Clearly describe the issue.
|
||||
- Provide reproduction steps if applicable.
|
||||
- Include screenshots or code examples if relevant.
|
||||
|
||||
---
|
||||
|
||||
## Community Guidelines
|
||||
|
||||
- Be respectful and constructive.
|
||||
- Follow the [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
- Stay on topic in discussions.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make **shadcn-admin** better! 🚀
|
||||
|
||||
If you have any questions, feel free to reach out via [Discussions](https://github.com/satnaing/shadcn-admin/discussions).
|
||||
14
web/.github/FUNDING.yml
vendored
@@ -1,14 +0,0 @@
|
||||
github: [satnaing]
|
||||
buy_me_a_coffee: satnaing
|
||||
|
||||
# patreon: # Replace with a single Patreon username
|
||||
# open_collective: # Replace with a single Open Collective username
|
||||
# ko_fi: # Replace with a single Ko-fi username
|
||||
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
# liberapay: # Replace with a single Liberapay username
|
||||
# issuehunt: # Replace with a single IssueHunt username
|
||||
# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
# polar: # Replace with a single Polar username
|
||||
# thanks_dev: # Replace with a single thanks.dev username
|
||||
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
5
web/.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Shadcn-Admin Discussions
|
||||
url: https://github.com/satnaing/shadcn-admin/discussions
|
||||
about: Please ask and answer questions here.
|
||||
19
web/.github/ISSUE_TEMPLATE/✨-feature-request.md
vendored
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: "✨ Feature Request"
|
||||
about: Suggest an idea for improving Shadcn-Admin
|
||||
title: "[Feature Request]: "
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
27
web/.github/ISSUE_TEMPLATE/🐞-bug-report.md
vendored
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: "\U0001F41E Bug report"
|
||||
about: Report a bug or unexpected behavior in Shadcn-Admin
|
||||
title: "[BUG]: "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
27
web/.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,27 +0,0 @@
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of what the pull request does. Include any relevant motivation and background. -->
|
||||
|
||||
## Types of changes
|
||||
|
||||
<!-- What types of changes does your code introduce to AstroPaper? Put an `x` in the boxes that apply -->
|
||||
|
||||
- [ ] Bug Fix (non-breaking change which fixes an issue)
|
||||
- [ ] New Feature (non-breaking change which adds functionality)
|
||||
- [ ] Others (any other types not listed above)
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Please follow this checklist and put an x in each of the boxes, like this: [x]. You can also fill these out after creating the PR. This is simply a reminder of what we are going to look for before merging your code. -->
|
||||
|
||||
- [ ] I have read the [Contributing Guide](https://github.com/satnaing/shadcn-admin/blob/main/.github/CONTRIBUTING.md)
|
||||
|
||||
## Further comments
|
||||
|
||||
<!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- If this PR is related to an existing issue, link to it here. -->
|
||||
|
||||
Closes: #<!-- Issue number, if applicable -->
|
||||
41
web/.github/workflows/ci.yml
vendored
@@ -1,41 +0,0 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-lint-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint the code
|
||||
run: pnpm lint
|
||||
|
||||
# - name: Analyze unused files and dependencies
|
||||
# run: pnpm knip
|
||||
|
||||
- name: Run Prettier check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Build the project
|
||||
run: pnpm build
|
||||
29
web/.github/workflows/stale.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: Close inactive issues/PR
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '38 18 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-stale: 120
|
||||
days-before-issue-close: 120
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 120 days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 120 days since being marked as stale."
|
||||
days-before-pr-stale: 120
|
||||
days-before-pr-close: 120
|
||||
stale-pr-label: "stale"
|
||||
stale-pr-message: "This PR is stale because it has been open for 120 days with no activity."
|
||||
close-pr-message: "This PR was closed because it has been inactive for 120 days since being marked as stale."
|
||||
operations-per-run: 0
|
||||
44
web/.gitignore
vendored
@@ -1,26 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -1,18 +0,0 @@
|
||||
# Ignore everything
|
||||
/*
|
||||
|
||||
# Except these files & folders
|
||||
!/src
|
||||
!index.html
|
||||
!package.json
|
||||
!tailwind.config.js
|
||||
!tsconfig.json
|
||||
!tsconfig.node.json
|
||||
!vite.config.ts
|
||||
!.prettierrc
|
||||
!README.md
|
||||
!eslint.config.js
|
||||
!postcss.config.js
|
||||
|
||||
# Ignore auto generated routeTree.gen.ts
|
||||
/src/routeTree.gen.ts
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"importOrder": [
|
||||
"^path$",
|
||||
"^vite$",
|
||||
"^@vitejs/(.*)$",
|
||||
"^react$",
|
||||
"^react-dom/client$",
|
||||
"^react/(.*)$",
|
||||
"^globals$",
|
||||
"^zod$",
|
||||
"^axios$",
|
||||
"^date-fns$",
|
||||
"^react-hook-form$",
|
||||
"^use-intl$",
|
||||
"^@radix-ui/(.*)$",
|
||||
"^@hookform/resolvers/zod$",
|
||||
"^@tanstack/react-query$",
|
||||
"^@tanstack/react-router$",
|
||||
"^@tanstack/react-table$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/assets/(.*)",
|
||||
"^@/api/(.*)$",
|
||||
"^@/stores/(.*)$",
|
||||
"^@/lib/(.*)$",
|
||||
"^@/utils/(.*)$",
|
||||
"^@/constants/(.*)$",
|
||||
"^@/context/(.*)$",
|
||||
"^@/hooks/(.*)$",
|
||||
"^@/components/layouts/(.*)$",
|
||||
"^@/components/ui/(.*)$",
|
||||
"^@/components/errors/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/features/(.*)$",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
||||
297
web/CHANGELOG.md
@@ -1,297 +0,0 @@
|
||||
## v2.1.0 (2025-08-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- enhance data table pagination with page numbers (#207)
|
||||
- enhance auth flow with sign-out dialogs and redirect functionality (#206)
|
||||
|
||||
### Refactor
|
||||
|
||||
- reorganize utility files into `lib/` folder (#209)
|
||||
- extract data-table components and reorganize structure (#208)
|
||||
|
||||
## v2.0.0 (2025-08-16)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- CSS file structure has been reorganized
|
||||
|
||||
### Feat
|
||||
|
||||
- add search param sync in apps route (#200)
|
||||
- improve tables and sync table states with search param (#199)
|
||||
- add data table bulk action toolbar (#196)
|
||||
- add config drawer and update overall layout (#186)
|
||||
- RTL support (#179)
|
||||
|
||||
### Fix
|
||||
|
||||
- adjust layout styles in search and top nav in dashboard page
|
||||
- update spacing and layout styles
|
||||
- update faceted icon color
|
||||
- improve user table hover & selected styles (#195)
|
||||
- add max-width for large screens to improve responsiveness (#194)
|
||||
- adjust chat border radius for better responsiveness (#193)
|
||||
- update hard-coded or inconsistent colors (#191)
|
||||
- use variable for inset layout height calculation
|
||||
- faded-bottom overflow issue in inset layout
|
||||
- hide unnecessary configs on mobile (#189)
|
||||
- adjust file input text vertical alignment (#188)
|
||||
|
||||
### Refactor
|
||||
|
||||
- enforce consistency and code quality (#198)
|
||||
- improve code quality and consistency (#197)
|
||||
- update error routes (#192)
|
||||
- remove DirSwitch component and its usage in Tasks (#190)
|
||||
- standardize using cookie as persist state (#187)
|
||||
- separate CSS into modular theme and base styles (#185)
|
||||
- replace tabler icons with lucide icons (#183)
|
||||
|
||||
## v1.4.2 (2025-07-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- remove unnecessary transitions in table (#176)
|
||||
- overflow background in tables (#175)
|
||||
|
||||
## v1.4.1 (2025-06-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- user list overflow in chat (#160)
|
||||
- prevent showing collapsed menu on mobile (#155)
|
||||
- white background select dropdown in dark mode (#149)
|
||||
|
||||
### Refactor
|
||||
|
||||
- update font config guide in fonts.ts (#164)
|
||||
|
||||
## v1.4.0 (2025-05-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- **clerk**: add Clerk for auth and protected route (#146)
|
||||
|
||||
### Fix
|
||||
|
||||
- add an indicator for nested pages in search (#147)
|
||||
- update faded-bottom color with css variable (#139)
|
||||
|
||||
## v1.3.0 (2025-04-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- replace custom otp with input-otp component (#131)
|
||||
- disable layout animation on mobile (#130)
|
||||
- upgrade react-day-picker and update calendar component (#129)
|
||||
|
||||
### Others
|
||||
|
||||
- upgrade Tailwind CSS to v4 (#125)
|
||||
- upgrade dependencies (#128)
|
||||
- configure automatic code-splitting (#127)
|
||||
|
||||
## v1.2.0 (2025-04-12)
|
||||
|
||||
### Feat
|
||||
|
||||
- add loading indicator during page transitions (#119)
|
||||
- add light favicons and theme-based switching (#112)
|
||||
- add new chat dialog in chats page (#90)
|
||||
|
||||
### Fix
|
||||
|
||||
- add fallback font for fontFamily (#110)
|
||||
- broken focus behavior in add user dialog (#113)
|
||||
|
||||
## v1.1.0 (2025-01-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- allow changing font family in setting
|
||||
|
||||
### Fix
|
||||
|
||||
- update sidebar color in dark mode for consistent look (#87)
|
||||
- use overflow-clip in table paginations (#86)
|
||||
- **style**: update global scrollbar style (#82)
|
||||
- toolbar filter placeholder typo in user table (#76)
|
||||
|
||||
## v1.0.3 (2024-12-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- add gap between buttons in import task dialog (#70)
|
||||
- hide button sort if column cannot be hidden & update filterFn (#69)
|
||||
- nav links added in profile dropdown (#68)
|
||||
|
||||
### Refactor
|
||||
|
||||
- optimize states in users/tasks context (#71)
|
||||
|
||||
## v1.0.2 (2024-12-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- update overall layout due to scroll-lock bug (#66)
|
||||
|
||||
### Refactor
|
||||
|
||||
- analyze and remove unused files/exports with knip (#67)
|
||||
|
||||
## v1.0.1 (2024-12-14)
|
||||
|
||||
### Fix
|
||||
|
||||
- merge two button components into one (#60)
|
||||
- loading all tabler-icon chunks in dev mode (#59)
|
||||
- display menu dropdown when sidebar collapsed (#58)
|
||||
- update spacing & alignment in dialogs/drawers
|
||||
- update border & transition of sticky columns in user table
|
||||
- update heading alignment to left in user dialogs
|
||||
- add height and scroll area in user mutation dialogs
|
||||
- update `/dashboard` route to just `/`
|
||||
- **build**: replace require with import in tailwind.config.js
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove unnecessary layout-backup file
|
||||
|
||||
## v1.0.0 (2024-12-09)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- Restructured the entire folder
|
||||
hierarchy to adopt a feature-based structure. This
|
||||
change improves code modularity and maintainability
|
||||
but introduces breaking changes.
|
||||
|
||||
### Feat
|
||||
|
||||
- implement task dialogs
|
||||
- implement user invite dialog
|
||||
- implement users CRUD
|
||||
- implement global command/search
|
||||
- implement custom sidebar trigger
|
||||
- implement coming-soon page
|
||||
|
||||
### Fix
|
||||
|
||||
- uncontrolled issue in account setting
|
||||
- card layout issue in app integrations page
|
||||
- remove form reset logic from useEffect in task import
|
||||
- update JSX types due to react 19
|
||||
- prevent card stretch in filtered app layout
|
||||
- layout wrap issue in tasks page on mobile
|
||||
- update user column hover and selected colors
|
||||
- add setTimeout in user dialog closing
|
||||
- layout shift issue in dropdown modal
|
||||
- z-axis overflow issue in header
|
||||
- stretch search bar only in mobile
|
||||
- language dropdown issue in account setting
|
||||
- update overflow contents with scroll area
|
||||
|
||||
### Refactor
|
||||
|
||||
- update layouts and extract common layout
|
||||
- reorganize project to feature-based structure
|
||||
|
||||
## v1.0.0-beta.5 (2024-11-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- add multiple language support (#37)
|
||||
|
||||
### Fix
|
||||
|
||||
- ensure site syncs with system theme changes (#49)
|
||||
- recent sales responsive on ipad view (#40)
|
||||
|
||||
## v1.0.0-beta.4 (2024-09-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- upgrade theme button to theme dropdown (#33)
|
||||
- **a11y**: add "Skip to Main" button to improve keyboard navigation (#27)
|
||||
|
||||
### Fix
|
||||
|
||||
- optimize onComplete/onIncomplete invocation (#32)
|
||||
- solve asChild attribute issue in custom button (#31)
|
||||
- improve custom Button component (#28)
|
||||
|
||||
## v1.0.0-beta.3 (2024-08-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- implement chat page (#21)
|
||||
- add 401 error page (#12)
|
||||
- implement apps page
|
||||
- add otp page
|
||||
|
||||
### Fix
|
||||
|
||||
- prevent focus zoom on mobile devices (#20)
|
||||
- resolve eslint script issue (#18)
|
||||
- **a11y**: update default aria-label of each pin-input
|
||||
- resolve OTP paste issue in multi-digit pin-input
|
||||
- update layouts and solve overflow issues (#11)
|
||||
- sync pin inputs programmatically
|
||||
|
||||
## v1.0.0-beta.2 (2024-03-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- implement custom pin-input component (#2)
|
||||
|
||||
## v1.0.0-beta.1 (2024-02-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- update theme-color meta tag when theme is updated
|
||||
- add coming soon page in broken pages
|
||||
- implement tasks table and page
|
||||
- add remaining settings pages
|
||||
- add example error page for settings
|
||||
- update general error page to be more flexible
|
||||
- implement settings layout and settings profile page
|
||||
- add error pages
|
||||
- add password-input custom component
|
||||
- add sign-up page
|
||||
- add forgot-password page
|
||||
- add box sign in page
|
||||
- add email + password sign in page
|
||||
- make sidebar responsive and accessible
|
||||
- add tailwind prettier plugin
|
||||
- make sidebar collapsed state in local storage
|
||||
- add check current active nav hook
|
||||
- add loader component ui
|
||||
- update dropdown nav by default if child is active
|
||||
- add main-panel in dashboard
|
||||
- **ui**: add dark mode
|
||||
- **ui**: implement side nav ui
|
||||
|
||||
### Fix
|
||||
|
||||
- update incorrect overflow side nav height
|
||||
- exclude shadcn components from linting and remove unused props
|
||||
- solve text overflow issue when nav text is long
|
||||
- replace nav with dropdown in mobile topnav
|
||||
- make sidebar scrollable when overflow
|
||||
- update nav link keys
|
||||
- **ui**: update label style
|
||||
|
||||
### Refactor
|
||||
|
||||
- move password-input component into custom component dir
|
||||
- add custom button component
|
||||
- extract redundant codes into layout component
|
||||
- update react-router to use new api for routing
|
||||
- update main panel layout
|
||||
- update major layouts and styling
|
||||
- update main panel to be responsive
|
||||
- update sidebar collapsed state to false in mobile
|
||||
- update sidebar logo and title
|
||||
- **ui**: remove unnecessary spacing
|
||||
- remove unused files
|
||||
21
web/LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sat Naing
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
117
web/README.md
@@ -1,117 +0,0 @@
|
||||
# Shadcn Admin Dashboard
|
||||
|
||||
Admin Dashboard UI crafted with Shadcn and Vite. Built with responsiveness and accessibility in mind.
|
||||
|
||||

|
||||
|
||||
I've been creating dashboard UIs at work and for my personal projects. I always wanted to make a reusable collection of dashboard UI for future projects; and here it is now. While I've created a few custom components, some of the code is directly adapted from ShadcnUI examples.
|
||||
|
||||
> This is not a starter project (template) though. I'll probably make one in the future.
|
||||
|
||||
## Features
|
||||
|
||||
- Light/dark mode
|
||||
- Responsive
|
||||
- Accessible
|
||||
- With built-in Sidebar component
|
||||
- Global search command
|
||||
- 10+ pages
|
||||
- Extra custom components
|
||||
- RTL support
|
||||
|
||||
<details>
|
||||
<summary>Customized Components (click to expand)</summary>
|
||||
|
||||
This project uses Shadcn UI components, but some have been slightly modified for better RTL (Right-to-Left) support and other improvements. These customized components differ from the original Shadcn UI versions.
|
||||
|
||||
If you want to update components using the Shadcn CLI (e.g., `npx shadcn@latest add <component>`), it's generally safe for non-customized components. For the listed customized ones, you may need to manually merge changes to preserve the project's modifications and avoid overwriting RTL support or other updates.
|
||||
|
||||
> If you don't require RTL support, you can safely update the 'RTL Updated Components' via the Shadcn CLI, as these changes are primarily for RTL compatibility. The 'Modified Components' may have other customizations to consider.
|
||||
|
||||
### Modified Components
|
||||
|
||||
- scroll-area
|
||||
- sonner
|
||||
- separator
|
||||
|
||||
### RTL Updated Components
|
||||
|
||||
- alert-dialog
|
||||
- calendar
|
||||
- command
|
||||
- dialog
|
||||
- dropdown-menu
|
||||
- select
|
||||
- table
|
||||
- sheet
|
||||
- sidebar
|
||||
- switch
|
||||
|
||||
**Notes:**
|
||||
|
||||
- **Modified Components**: These have general updates, potentially including RTL adjustments.
|
||||
- **RTL Updated Components**: These have specific changes for RTL language support (e.g., layout, positioning).
|
||||
- For implementation details, check the source files in `src/components/ui/`.
|
||||
- All other Shadcn UI components in the project are standard and can be safely updated via the CLI.
|
||||
|
||||
</details>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**UI:** [ShadcnUI](https://ui.shadcn.com) (TailwindCSS + RadixUI)
|
||||
|
||||
**Build Tool:** [Vite](https://vitejs.dev/)
|
||||
|
||||
**Routing:** [TanStack Router](https://tanstack.com/router/latest)
|
||||
|
||||
**Type Checking:** [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Linting/Formatting:** [Eslint](https://eslint.org/) & [Prettier](https://prettier.io/)
|
||||
|
||||
**Icons:** [Lucide Icons](https://lucide.dev/icons/), [Tabler Icons](https://tabler.io/icons) (Brand icons only)
|
||||
|
||||
**Auth (partial):** [Clerk](https://go.clerk.com/GttUAaK)
|
||||
|
||||
## Run Locally
|
||||
|
||||
Clone the project
|
||||
|
||||
```bash
|
||||
git clone https://github.com/satnaing/shadcn-admin.git
|
||||
```
|
||||
|
||||
Go to the project directory
|
||||
|
||||
```bash
|
||||
cd shadcn-admin
|
||||
```
|
||||
|
||||
Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Start the server
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## Sponsoring this project ❤️
|
||||
|
||||
If you find this project helpful or use this in your own work, consider [sponsoring me](https://github.com/sponsors/satnaing) to support development and maintenance. You can [buy me a coffee](https://buymeacoffee.com/satnaing) as well. Don’t worry, every penny helps. Thank you! 🙏
|
||||
|
||||
For questions or sponsorship inquiries, feel free to reach out at [contact@satnaing.dev](mailto:contact@satnaing.dev).
|
||||
|
||||
### Current Sponsor
|
||||
|
||||
- [Clerk](https://go.clerk.com/GttUAaK) - for backing the implementation of Clerk in this project
|
||||
|
||||
## Author
|
||||
|
||||
Crafted with 🤍 by [@satnaing](https://github.com/satnaing)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://choosealicense.com/licenses/mit/)
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
commitizen:
|
||||
name: cz_conventional_commits
|
||||
tag_format: v$version
|
||||
update_changelog_on_bump: true
|
||||
version_provider: npm
|
||||
version_scheme: semver
|
||||
@@ -1,58 +0,0 @@
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginQuery from '@tanstack/eslint-plugin-query'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/components/ui'] },
|
||||
{
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginQuery.configs['flat/recommended'],
|
||||
],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'no-console': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
// Enforce type-only imports for TypeScript types
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
// Prevent duplicate imports from the same module
|
||||
'no-duplicate-imports': 'error',
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1,80 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/favicon.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/favicon_light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/images/favicon.png"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/images/favicon_light.png"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Shadcn Admin</title>
|
||||
<meta name="title" content="Shadcn Admin" />
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://shadcn-admin.netlify.app" />
|
||||
<meta property="og:title" content="Shadcn Admin" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
|
||||
/>
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://shadcn-admin.netlify.app" />
|
||||
<meta property="twitter:title" content="Shadcn Admin" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
|
||||
/>
|
||||
|
||||
<!-- font family -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Manrope:wght@200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#fff" />
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { KnipConfig } from 'knip';
|
||||
|
||||
const config: KnipConfig = {
|
||||
ignore: ['src/components/ui/**', 'src/routeTree.gen.ts'],
|
||||
ignoreDependencies: ["tailwindcss", "tw-animate-css"]
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,4 +0,0 @@
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
26
web/old_semiui/.gitignore
vendored
@@ -1,26 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.idea
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -1,20 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"name": "react-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "^0.27.2",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-fireworks": "^1.0.4",
|
||||
"react-i18next": "^13.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-telegram-login": "^1.1.2",
|
||||
"react-toastify": "^9.0.8",
|
||||
"react-turnstile": "^1.0.5",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sse.js": "^2.6.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "4.4.2",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
},
|
||||
"proxy": "http://localhost:3000"
|
||||
}
|
||||
@@ -1,200 +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, { useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
IconExit,
|
||||
IconUserSetting,
|
||||
IconCreditCard,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { stringToColor } from '../../../helpers';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const UserArea = ({
|
||||
userState,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isSelfUseMode,
|
||||
logout,
|
||||
navigate,
|
||||
t,
|
||||
}) => {
|
||||
const dropdownRef = useRef(null);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SkeletonWrapper
|
||||
loading={true}
|
||||
type='userArea'
|
||||
width={50}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (userState.user) {
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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'
|
||||
>
|
||||
<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;
|
||||
|
||||
const commonSizingAndLayoutClass =
|
||||
'flex items-center justify-center !py-[10px] !px-1.5';
|
||||
|
||||
const loginButtonSpecificStyling =
|
||||
'!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors';
|
||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
||||
|
||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
||||
|
||||
const loginButtonTextSpanClass =
|
||||
'!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5';
|
||||
const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5';
|
||||
|
||||
if (showRegisterButton) {
|
||||
if (isMobile) {
|
||||
loginButtonClasses += ' !rounded-full';
|
||||
} else {
|
||||
loginButtonClasses += ' !rounded-l-full !rounded-r-none';
|
||||
}
|
||||
registerButtonClasses += ' !rounded-r-full !rounded-l-none';
|
||||
} else {
|
||||
loginButtonClasses += ' !rounded-full';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Link to='/login' className='flex'>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className={loginButtonClasses}
|
||||
>
|
||||
<span className={loginButtonTextSpanClass}>{t('登录')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{showRegisterButton && (
|
||||
<div className='hidden md:block'>
|
||||
<Link to='/register' className='flex -ml-px'>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className={registerButtonClasses}
|
||||
>
|
||||
<span className={registerButtonTextSpanClass}>{t('注册')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default UserArea;
|
||||
@@ -1,49 +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 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()
|
||||
},
|
||||
];
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"首页": "首页",
|
||||
"控制台": "控制台",
|
||||
"定价": "定价",
|
||||
"关于": "关于",
|
||||
"登录": "登录",
|
||||
"注册": "注册",
|
||||
"退出": "退出",
|
||||
"语言": "语言",
|
||||
"展开侧边栏": "展开侧边栏",
|
||||
"关闭侧边栏": "关闭侧边栏",
|
||||
"注销成功!": "注销成功!",
|
||||
"代理设置": "代理设置",
|
||||
"更新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地址。支持单个IP(8.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 过滤列表覆盖这些地址,否则可能导致访问失败。"
|
||||
}
|
||||
159
web/package.json
@@ -1,86 +1,91 @@
|
||||
{
|
||||
"name": "shadcn-admin",
|
||||
"private": false,
|
||||
"version": "2.1.0",
|
||||
"name": "react-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "^0.27.2",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-fireworks": "^1.0.4",
|
||||
"react-i18next": "^13.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-telegram-login": "^1.1.2",
|
||||
"react-toastify": "^9.0.8",
|
||||
"react-turnstile": "^1.0.5",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sse.js": "^2.6.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"knip": "knip"
|
||||
"build": "vite build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/clerk-react": "^5.42.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-direction": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.3",
|
||||
"@tanstack/react-router": "^1.131.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "9.8.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "^3.1.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"zod": "^4.0.17",
|
||||
"zustand": "^5.0.7"
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@tanstack/react-query-devtools": "^5.85.3",
|
||||
"@tanstack/react-router-devtools": "^1.131.16",
|
||||
"@tanstack/router-plugin": "^1.131.16",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"knip": "^5.62.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "4.4.2",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true
|
||||
},
|
||||
"proxy": "http://localhost:3000"
|
||||
}
|
||||
|
||||
5786
web/pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 494 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
|
||||
</style></svg>
|
||||
|
Before Width: | Height: | Size: 520 B |
|
Before Width: | Height: | Size: 247 B |
@@ -1 +0,0 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1013 B |
|
Before Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
662
web/public/oauth-demo.html
Normal file
@@ -0,0 +1,662 @@
|
||||
<!-- 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 Challenge(S256)</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 Token(JWT)</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>
|
||||
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |