feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
commit
3a7e8f3b54
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,86 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
MinAccountPasswordLength = 8
|
||||
MaxAccountPasswordLength = 128
|
||||
accountPasswordMemory = 19 * 1024
|
||||
accountPasswordTime = 2
|
||||
accountPasswordSaltBytes = 16
|
||||
accountPasswordKeyBytes = 32
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccountPasswordLength = errors.New("Password must contain between 8 and 128 characters.")
|
||||
ErrAccountPasswordSame = errors.New("New password must be different from current password")
|
||||
ErrPasswordLegacyLimit = errors.New("Long passwords are unavailable until the password storage upgrade is complete.")
|
||||
)
|
||||
|
||||
// ValidateNewAccountPassword applies only when a user chooses a new password.
|
||||
// Authentication must continue to accept historical passwords without applying
|
||||
// the new policy. Do not normalize passwords, including surrounding whitespace.
|
||||
func ValidateNewAccountPassword(password string) error {
|
||||
if !utf8.ValidString(password) || utf8.RuneCountInString(password) < MinAccountPasswordLength || utf8.RuneCountInString(password) > MaxAccountPasswordLength {
|
||||
return ErrAccountPasswordLength
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashAccountPassword is for account passwords, not MFA backup codes. The
|
||||
// temporary bcrypt mode permits rolling out dual-format readers to all nodes
|
||||
// before enabling Argon2id writes. Existing hashes are never rewritten in bulk.
|
||||
func HashAccountPassword(password string) (string, error) {
|
||||
if err := ValidateNewAccountPassword(password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch os.Getenv("ACCOUNT_PASSWORD_HASH_ALGORITHM") {
|
||||
case "bcrypt":
|
||||
if len(password) > 72 {
|
||||
return "", ErrPasswordLegacyLimit
|
||||
}
|
||||
return Password2Hash(password)
|
||||
case "", "argon2id":
|
||||
default:
|
||||
return "", errors.New("Unsupported account password hashing configuration.")
|
||||
}
|
||||
salt := make([]byte, accountPasswordSaltBytes)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate account password salt: %w", err)
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, accountPasswordTime, accountPasswordMemory, 1, accountPasswordKeyBytes)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=19456,t=2,p=1$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
func validateArgon2AccountPassword(password, encoded string) bool {
|
||||
// Bound both plaintext and parameters before invoking a memory-hard KDF.
|
||||
// Only the version/parameters emitted by this application are accepted.
|
||||
if len(password) > MaxAccountPasswordLength*utf8.UTFMax || len(encoded) > 256 {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=19456,t=2,p=1" {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.Strict().DecodeString(parts[4])
|
||||
if err != nil || len(salt) != accountPasswordSaltBytes {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.Strict().DecodeString(parts[5])
|
||||
if err != nil || len(expected) != accountPasswordKeyBytes {
|
||||
return false
|
||||
}
|
||||
actual := argon2.IDKey([]byte(password), salt, accountPasswordTime, accountPasswordMemory, 1, accountPasswordKeyBytes)
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package common
|
||||
|
||||
import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
func ChannelType2APIType(channelType int) (int, bool) {
|
||||
apiType := -1
|
||||
switch channelType {
|
||||
case constant.ChannelTypeOpenAI:
|
||||
apiType = constant.APITypeOpenAI
|
||||
case constant.ChannelTypeAnthropic:
|
||||
apiType = constant.APITypeAnthropic
|
||||
case constant.ChannelTypeBaidu:
|
||||
apiType = constant.APITypeBaidu
|
||||
case constant.ChannelTypePaLM:
|
||||
apiType = constant.APITypePaLM
|
||||
case constant.ChannelTypeZhipu:
|
||||
apiType = constant.APITypeZhipu
|
||||
case constant.ChannelTypeAli:
|
||||
apiType = constant.APITypeAli
|
||||
case constant.ChannelTypeXunfei:
|
||||
apiType = constant.APITypeXunfei
|
||||
case constant.ChannelTypeAIProxyLibrary:
|
||||
apiType = constant.APITypeAIProxyLibrary
|
||||
case constant.ChannelTypeTencent:
|
||||
apiType = constant.APITypeTencent
|
||||
case constant.ChannelTypeGemini:
|
||||
apiType = constant.APITypeGemini
|
||||
case constant.ChannelTypeZhipu_v4:
|
||||
apiType = constant.APITypeZhipuV4
|
||||
case constant.ChannelTypeOllama:
|
||||
apiType = constant.APITypeOllama
|
||||
case constant.ChannelTypePerplexity:
|
||||
apiType = constant.APITypePerplexity
|
||||
case constant.ChannelTypeAws:
|
||||
apiType = constant.APITypeAws
|
||||
case constant.ChannelTypeCohere:
|
||||
apiType = constant.APITypeCohere
|
||||
case constant.ChannelTypeDify:
|
||||
apiType = constant.APITypeDify
|
||||
case constant.ChannelTypeJina:
|
||||
apiType = constant.APITypeJina
|
||||
case constant.ChannelCloudflare:
|
||||
apiType = constant.APITypeCloudflare
|
||||
case constant.ChannelTypeSiliconFlow:
|
||||
apiType = constant.APITypeSiliconFlow
|
||||
case constant.ChannelTypeVertexAi:
|
||||
apiType = constant.APITypeVertexAi
|
||||
case constant.ChannelTypeMistral:
|
||||
apiType = constant.APITypeMistral
|
||||
case constant.ChannelTypeDeepSeek:
|
||||
apiType = constant.APITypeDeepSeek
|
||||
case constant.ChannelTypeMokaAI:
|
||||
apiType = constant.APITypeMokaAI
|
||||
case constant.ChannelTypeVolcEngine:
|
||||
apiType = constant.APITypeVolcEngine
|
||||
case constant.ChannelTypeBaiduV2:
|
||||
apiType = constant.APITypeBaiduV2
|
||||
case constant.ChannelTypeOpenRouter:
|
||||
apiType = constant.APITypeOpenRouter
|
||||
case constant.ChannelTypeXinference:
|
||||
apiType = constant.APITypeXinference
|
||||
case constant.ChannelTypeXai:
|
||||
apiType = constant.APITypeXai
|
||||
case constant.ChannelTypeCoze:
|
||||
apiType = constant.APITypeCoze
|
||||
case constant.ChannelTypeJimeng:
|
||||
apiType = constant.APITypeJimeng
|
||||
case constant.ChannelTypeMoonshot:
|
||||
apiType = constant.APITypeMoonshot
|
||||
case constant.ChannelTypeSubmodel:
|
||||
apiType = constant.APITypeSubmodel
|
||||
case constant.ChannelTypeMiniMax:
|
||||
apiType = constant.APITypeMiniMax
|
||||
case constant.ChannelTypeReplicate:
|
||||
apiType = constant.APITypeReplicate
|
||||
case constant.ChannelTypeCodex:
|
||||
apiType = constant.APITypeCodex
|
||||
case constant.ChannelTypeAdvancedCustom:
|
||||
apiType = constant.APITypeAdvancedCustom
|
||||
case constant.ChannelTypeSub2API:
|
||||
apiType = constant.APITypeSub2API
|
||||
case constant.ChannelTypeNewAPI:
|
||||
apiType = constant.APITypeNewAPI
|
||||
}
|
||||
if apiType == -1 {
|
||||
// Task plugin channels are served by the task relay and must never
|
||||
// fall back to the OpenAI adaptor.
|
||||
if channelType == constant.ChannelTypeTaskPlugin {
|
||||
return -1, false
|
||||
}
|
||||
return constant.APITypeOpenAI, false
|
||||
}
|
||||
return apiType, true
|
||||
}
|
||||
|
||||
func SupportsResponsesCompact(channelType, apiType int) bool {
|
||||
switch apiType {
|
||||
case constant.APITypeOpenAI,
|
||||
constant.APITypeCodex,
|
||||
constant.APITypeAdvancedCustom,
|
||||
constant.APITypeSub2API,
|
||||
constant.APITypeNewAPI:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTaskPluginChannelHasNoOrdinaryAPIType(t *testing.T) {
|
||||
apiType, ok := ChannelType2APIType(constant.ChannelTypeTaskPlugin)
|
||||
assert.Equal(t, -1, apiType)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/abema/go-mp4"
|
||||
"github.com/go-audio/aiff"
|
||||
"github.com/go-audio/wav"
|
||||
"github.com/jfreymuth/oggvorbis"
|
||||
"github.com/mewkiz/flac"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tcolgate/mp3"
|
||||
"github.com/yapingcat/gomedia/go-codec"
|
||||
)
|
||||
|
||||
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
|
||||
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
|
||||
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
|
||||
// 根据文件扩展名选择解析器
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
duration, err = getMP3Duration(f)
|
||||
case ".wav":
|
||||
duration, err = getWAVDuration(f)
|
||||
case ".flac":
|
||||
duration, err = getFLACDuration(f)
|
||||
case ".m4a", ".mp4":
|
||||
duration, err = getM4ADuration(f)
|
||||
case ".ogg", ".oga", ".opus":
|
||||
duration, err = getOGGDuration(f)
|
||||
if err != nil {
|
||||
duration, err = getOpusDuration(f)
|
||||
}
|
||||
case ".aiff", ".aif", ".aifc":
|
||||
duration, err = getAIFFDuration(f)
|
||||
case ".webm":
|
||||
duration, err = getWebMDuration(f)
|
||||
case ".aac":
|
||||
duration, err = getAACDuration(f)
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported audio format: %s", ext)
|
||||
}
|
||||
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
|
||||
return duration, err
|
||||
}
|
||||
|
||||
// getMP3Duration 解析 MP3 文件以获取时长。
|
||||
// 注意:对于 VBR (Variable Bitrate) MP3,这个估算可能不完全精确,但通常足够好。
|
||||
// FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。
|
||||
func getMP3Duration(r io.Reader) (float64, error) {
|
||||
d := mp3.NewDecoder(r)
|
||||
var f mp3.Frame
|
||||
skipped := 0
|
||||
duration := 0.0
|
||||
|
||||
for {
|
||||
if err := d.Decode(&f, &skipped); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return 0, errors.Wrap(err, "failed to decode mp3 frame")
|
||||
}
|
||||
duration += f.Duration().Seconds()
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getWAVDuration 解析 WAV 文件头以获取时长。
|
||||
func getWAVDuration(r io.ReadSeeker) (float64, error) {
|
||||
// 1. 强制复位指针
|
||||
r.Seek(0, io.SeekStart)
|
||||
|
||||
dec := wav.NewDecoder(r)
|
||||
|
||||
// IsValidFile 会读取 fmt 块
|
||||
if !dec.IsValidFile() {
|
||||
return 0, errors.New("invalid wav file")
|
||||
}
|
||||
|
||||
// 尝试寻找 data 块
|
||||
if err := dec.FwdToPCM(); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to find PCM data chunk")
|
||||
}
|
||||
|
||||
pcmSize := int64(dec.PCMSize)
|
||||
|
||||
// 如果读出来的 Size 是 0,尝试用文件大小反推
|
||||
if pcmSize == 0 {
|
||||
// 获取文件总大小
|
||||
currentPos, _ := r.Seek(0, io.SeekCurrent) // 当前通常在 data chunk header 之后
|
||||
endPos, _ := r.Seek(0, io.SeekEnd)
|
||||
fileSize := endPos
|
||||
|
||||
// 恢复位置(虽然如果不继续读也没关系)
|
||||
r.Seek(currentPos, io.SeekStart)
|
||||
|
||||
// 数据区大小 ≈ 文件总大小 - 当前指针位置(即Header大小)
|
||||
// 注意:FwdToPCM 成功后,CurrentPos 应该刚好指向 Data 区数据的开始
|
||||
// 或者是 Data Chunk ID + Size 之后。
|
||||
// WAV Header 一般 44 字节。
|
||||
if fileSize > 44 {
|
||||
// 如果 FwdToPCM 成功,Reader 应该位于 data 块的数据起始处
|
||||
// 所以剩余的所有字节理论上都是音频数据
|
||||
pcmSize = fileSize - currentPos
|
||||
|
||||
// 简单的兜底:如果算出来还是负数或0,强制按文件大小-44计算
|
||||
if pcmSize <= 0 {
|
||||
pcmSize = fileSize - 44
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
numChans := int64(dec.NumChans)
|
||||
bitDepth := int64(dec.BitDepth)
|
||||
sampleRate := float64(dec.SampleRate)
|
||||
|
||||
if sampleRate == 0 || numChans == 0 || bitDepth == 0 {
|
||||
return 0, errors.New("invalid wav header metadata")
|
||||
}
|
||||
|
||||
bytesPerFrame := numChans * (bitDepth / 8)
|
||||
if bytesPerFrame == 0 {
|
||||
return 0, errors.New("invalid byte depth calculation")
|
||||
}
|
||||
|
||||
totalFrames := pcmSize / bytesPerFrame
|
||||
|
||||
durationSeconds := float64(totalFrames) / sampleRate
|
||||
return durationSeconds, nil
|
||||
}
|
||||
|
||||
// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
|
||||
func getFLACDuration(r io.Reader) (float64, error) {
|
||||
stream, err := flac.Parse(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to parse flac stream")
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
// 时长 = 总采样数 / 采样率
|
||||
duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。
|
||||
func getM4ADuration(r io.ReadSeeker) (float64, error) {
|
||||
// go-mp4 库需要 ReadSeeker 接口
|
||||
info, err := mp4.Probe(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to probe m4a/mp4 file")
|
||||
}
|
||||
// 时长 = Duration / Timescale
|
||||
return float64(info.Duration) / float64(info.Timescale), nil
|
||||
}
|
||||
|
||||
// getOGGDuration 解析 OGG/Vorbis 文件以获取时长。
|
||||
func getOGGDuration(r io.ReadSeeker) (float64, error) {
|
||||
// 重置 reader 到开头
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek ogg file")
|
||||
}
|
||||
|
||||
reader, err := oggvorbis.NewReader(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create ogg vorbis reader")
|
||||
}
|
||||
|
||||
// 计算时长 = 总采样数 / 采样率
|
||||
// 需要读取整个文件来获取总采样数
|
||||
channels := reader.Channels()
|
||||
sampleRate := reader.SampleRate()
|
||||
|
||||
// 估算方法:读取到文件结尾
|
||||
var totalSamples int64
|
||||
buf := make([]float32, 4096*channels)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to read ogg samples")
|
||||
}
|
||||
totalSamples += int64(n / channels)
|
||||
}
|
||||
|
||||
duration := float64(totalSamples) / float64(sampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。
|
||||
func getOpusDuration(r io.ReadSeeker) (float64, error) {
|
||||
// Opus 通常封装在 OGG 容器中
|
||||
// 我们需要解析 OGG 页面来获取时长信息
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek opus file")
|
||||
}
|
||||
|
||||
// 读取 OGG 页面头部
|
||||
var totalGranulePos int64
|
||||
buf := make([]byte, 27) // OGG 页面头部最小大小
|
||||
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to read opus/ogg page")
|
||||
}
|
||||
if n < 27 {
|
||||
break
|
||||
}
|
||||
|
||||
// 检查 OGG 页面标识 "OggS"
|
||||
if string(buf[0:4]) != "OggS" {
|
||||
// 跳过一些字节继续寻找
|
||||
if _, err := r.Seek(-26, io.SeekCurrent); err != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取 granule position (字节 6-13, 小端序)
|
||||
granulePos := int64(binary.LittleEndian.Uint64(buf[6:14]))
|
||||
if granulePos > totalGranulePos {
|
||||
totalGranulePos = granulePos
|
||||
}
|
||||
|
||||
// 读取段表大小
|
||||
numSegments := int(buf[26])
|
||||
segmentTable := make([]byte, numSegments)
|
||||
if _, err := io.ReadFull(r, segmentTable); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// 计算页面数据大小并跳过
|
||||
var pageSize int
|
||||
for _, segSize := range segmentTable {
|
||||
pageSize += int(segSize)
|
||||
}
|
||||
if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Opus 的采样率固定为 48000 Hz
|
||||
duration := float64(totalGranulePos) / 48000.0
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
// getAIFFDuration 解析 AIFF 文件头以获取时长。
|
||||
func getAIFFDuration(r io.ReadSeeker) (float64, error) {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek aiff file")
|
||||
}
|
||||
|
||||
dec := aiff.NewDecoder(r)
|
||||
if !dec.IsValidFile() {
|
||||
return 0, errors.New("invalid aiff file")
|
||||
}
|
||||
|
||||
d, err := dec.Duration()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get aiff duration")
|
||||
}
|
||||
|
||||
return d.Seconds(), nil
|
||||
}
|
||||
|
||||
// getWebMDuration 解析 WebM 文件以获取时长。
|
||||
// WebM 使用 Matroska 容器格式
|
||||
func getWebMDuration(r io.ReadSeeker) (float64, error) {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek webm file")
|
||||
}
|
||||
|
||||
// WebM/Matroska 文件的解析比较复杂
|
||||
// 这里提供一个简化的实现,读取 EBML 头部
|
||||
// 对于完整的 WebM 解析,可能需要使用专门的库
|
||||
|
||||
// 简单实现:查找 Duration 元素
|
||||
// WebM Duration 的 Element ID 是 0x4489
|
||||
// 这是一个简化版本,可能不适用于所有 WebM 文件
|
||||
buf := make([]byte, 8192)
|
||||
n, err := r.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return 0, errors.Wrap(err, "failed to read webm file")
|
||||
}
|
||||
|
||||
// 尝试查找 Duration 元素(这是一个简化的方法)
|
||||
// 实际的 WebM 解析需要完整的 EBML 解析器
|
||||
// 这里返回错误,建议使用专门的库
|
||||
if n > 0 {
|
||||
// 检查 EBML 标识
|
||||
if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 {
|
||||
// 这是一个有效的 EBML 文件
|
||||
// 但完整解析需要更复杂的逻辑
|
||||
return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)")
|
||||
}
|
||||
}
|
||||
|
||||
return 0, errors.New("failed to parse webm file")
|
||||
}
|
||||
|
||||
// getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。
|
||||
// 使用 gomedia 库来解析 AAC ADTS 帧
|
||||
func getAACDuration(r io.ReadSeeker) (float64, error) {
|
||||
if _, err := r.Seek(0, io.SeekStart); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to seek aac file")
|
||||
}
|
||||
|
||||
// 读取整个文件内容
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to read aac file")
|
||||
}
|
||||
|
||||
var totalFrames int64
|
||||
var sampleRate int
|
||||
|
||||
// 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧
|
||||
codec.SplitAACFrame(data, func(aac []byte) {
|
||||
// 解析 ADTS 头部以获取采样率信息
|
||||
if len(aac) >= 7 {
|
||||
// 使用 ConvertADTSToASC 来获取音频配置信息
|
||||
asc, err := codec.ConvertADTSToASC(aac)
|
||||
if err == nil && sampleRate == 0 {
|
||||
sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index))
|
||||
}
|
||||
totalFrames++
|
||||
}
|
||||
})
|
||||
|
||||
if sampleRate == 0 || totalFrames == 0 {
|
||||
return 0, errors.New("no valid aac frames found")
|
||||
}
|
||||
|
||||
// 每个 AAC ADTS 帧包含 1024 个采样
|
||||
totalSamples := totalFrames * 1024
|
||||
duration := float64(totalSamples) / float64(sampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BodyStorage 请求体存储接口
|
||||
type BodyStorage interface {
|
||||
io.ReadSeeker
|
||||
io.Closer
|
||||
// Bytes 获取全部内容
|
||||
Bytes() ([]byte, error)
|
||||
// Size 获取数据大小
|
||||
Size() int64
|
||||
// IsDisk 是否是磁盘存储
|
||||
IsDisk() bool
|
||||
// NewReader returns an independent reader positioned at the start of the
|
||||
// stored payload. Each call returns a reader with its own cursor, so
|
||||
// callers (e.g. http.Request.GetBody) can replay the body concurrently
|
||||
// with, or after, other readers without sharing seek state. Closing the
|
||||
// returned reader releases only that reader, never the storage itself;
|
||||
// after the storage has been closed, NewReader returns ErrStorageClosed.
|
||||
NewReader() (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ReplayableBody is an outbound request body that can report its byte size and
|
||||
// create independent readers for transport-level retries.
|
||||
type ReplayableBody interface {
|
||||
io.Reader
|
||||
Size() int64
|
||||
NewReader() (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ErrStorageClosed 存储已关闭错误
|
||||
var ErrStorageClosed = fmt.Errorf("body storage is closed")
|
||||
|
||||
// memoryStorage 内存存储实现
|
||||
type memoryStorage struct {
|
||||
data []byte
|
||||
reader *bytes.Reader
|
||||
size int64
|
||||
closed int32
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newMemoryStorage(data []byte) *memoryStorage {
|
||||
size := int64(len(data))
|
||||
IncrementMemoryBuffers(size)
|
||||
return &memoryStorage{
|
||||
data: data,
|
||||
reader: bytes.NewReader(data),
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memoryStorage) Read(p []byte) (n int, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if atomic.LoadInt32(&m.closed) == 1 {
|
||||
return 0, ErrStorageClosed
|
||||
}
|
||||
return m.reader.Read(p)
|
||||
}
|
||||
|
||||
func (m *memoryStorage) Seek(offset int64, whence int) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if atomic.LoadInt32(&m.closed) == 1 {
|
||||
return 0, ErrStorageClosed
|
||||
}
|
||||
return m.reader.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (m *memoryStorage) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if atomic.CompareAndSwapInt32(&m.closed, 0, 1) {
|
||||
DecrementMemoryBuffers(m.size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryStorage) Bytes() ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if atomic.LoadInt32(&m.closed) == 1 {
|
||||
return nil, ErrStorageClosed
|
||||
}
|
||||
return m.data, nil
|
||||
}
|
||||
|
||||
func (m *memoryStorage) NewReader() (io.ReadCloser, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if atomic.LoadInt32(&m.closed) == 1 {
|
||||
return nil, ErrStorageClosed
|
||||
}
|
||||
// A fresh bytes.Reader over the shared immutable backing array: an
|
||||
// independent cursor at zero copy cost. NopCloser keeps Close a no-op, so
|
||||
// the storage lifecycle stays owned by whoever holds the storage itself.
|
||||
return io.NopCloser(bytes.NewReader(m.data)), nil
|
||||
}
|
||||
|
||||
func (m *memoryStorage) Size() int64 {
|
||||
return m.size
|
||||
}
|
||||
|
||||
func (m *memoryStorage) IsDisk() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// diskStorage 磁盘存储实现
|
||||
type diskStorage struct {
|
||||
file *os.File
|
||||
filePath string
|
||||
size int64
|
||||
closed int32
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newDiskStorage(data []byte, cachePath string) (*diskStorage, error) {
|
||||
// 使用统一的缓存目录管理
|
||||
filePath, file, err := CreateDiskCacheFile(DiskCacheTypeBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 写入数据
|
||||
n, err := file.Write(data)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return nil, fmt.Errorf("failed to write to temp file: %w", err)
|
||||
}
|
||||
|
||||
// 重置文件指针
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return nil, fmt.Errorf("failed to seek temp file: %w", err)
|
||||
}
|
||||
|
||||
size := int64(n)
|
||||
IncrementDiskFiles(size)
|
||||
|
||||
return &diskStorage{
|
||||
file: file,
|
||||
filePath: filePath,
|
||||
size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newDiskStorageFromReader(reader io.Reader, maxBytes int64, cachePath string) (*diskStorage, error) {
|
||||
// 使用统一的缓存目录管理
|
||||
filePath, file, err := CreateDiskCacheFile(DiskCacheTypeBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 从 reader 读取并写入文件
|
||||
written, err := io.Copy(file, io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return nil, fmt.Errorf("failed to write to temp file: %w", err)
|
||||
}
|
||||
|
||||
if written > maxBytes {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return nil, ErrRequestBodyTooLarge
|
||||
}
|
||||
|
||||
// 重置文件指针
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return nil, fmt.Errorf("failed to seek temp file: %w", err)
|
||||
}
|
||||
|
||||
IncrementDiskFiles(written)
|
||||
|
||||
return &diskStorage{
|
||||
file: file,
|
||||
filePath: filePath,
|
||||
size: written,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *diskStorage) Read(p []byte) (n int, err error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if atomic.LoadInt32(&d.closed) == 1 {
|
||||
return 0, ErrStorageClosed
|
||||
}
|
||||
return d.file.Read(p)
|
||||
}
|
||||
|
||||
func (d *diskStorage) Seek(offset int64, whence int) (int64, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if atomic.LoadInt32(&d.closed) == 1 {
|
||||
return 0, ErrStorageClosed
|
||||
}
|
||||
return d.file.Seek(offset, whence)
|
||||
}
|
||||
|
||||
func (d *diskStorage) Close() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if atomic.CompareAndSwapInt32(&d.closed, 0, 1) {
|
||||
d.file.Close()
|
||||
os.Remove(d.filePath)
|
||||
DecrementDiskFiles(d.size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *diskStorage) Bytes() ([]byte, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if atomic.LoadInt32(&d.closed) == 1 {
|
||||
return nil, ErrStorageClosed
|
||||
}
|
||||
|
||||
// 保存当前位置
|
||||
currentPos, err := d.file.Seek(0, io.SeekCurrent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 移动到开头
|
||||
if _, err := d.file.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 读取全部内容
|
||||
data := make([]byte, d.size)
|
||||
_, err = io.ReadFull(d.file, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 恢复位置
|
||||
if _, err := d.file.Seek(currentPos, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (d *diskStorage) NewReader() (io.ReadCloser, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if atomic.LoadInt32(&d.closed) == 1 {
|
||||
return nil, ErrStorageClosed
|
||||
}
|
||||
// A separate file descriptor over the same cache file: an independent
|
||||
// cursor at zero copy cost. Closing the returned reader closes only that
|
||||
// descriptor; the storage keeps owning the primary descriptor and the
|
||||
// file's lifetime. Readers opened before Close stay usable even after the
|
||||
// file is unlinked, as the descriptor keeps the inode alive.
|
||||
file, err := os.Open(d.filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open body cache file for replay: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (d *diskStorage) Size() int64 {
|
||||
return d.size
|
||||
}
|
||||
|
||||
func (d *diskStorage) IsDisk() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateBodyStorage 根据数据大小创建合适的存储
|
||||
func CreateBodyStorage(data []byte) (BodyStorage, error) {
|
||||
size := int64(len(data))
|
||||
threshold := GetDiskCacheThresholdBytes()
|
||||
|
||||
// 检查是否应该使用磁盘缓存
|
||||
if IsDiskCacheEnabled() &&
|
||||
size >= threshold &&
|
||||
IsDiskCacheAvailable(size) {
|
||||
storage, err := newDiskStorage(data, GetDiskCachePath())
|
||||
if err != nil {
|
||||
// 如果磁盘存储失败,回退到内存存储
|
||||
SysError(fmt.Sprintf("failed to create disk storage, falling back to memory: %v", err))
|
||||
return newMemoryStorage(data), nil
|
||||
}
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
return newMemoryStorage(data), nil
|
||||
}
|
||||
|
||||
// CreateBodyStorageFromReader 从 Reader 创建存储(用于大请求的流式处理)
|
||||
func CreateBodyStorageFromReader(reader io.Reader, contentLength int64, maxBytes int64) (BodyStorage, error) {
|
||||
threshold := GetDiskCacheThresholdBytes()
|
||||
|
||||
// 如果启用了磁盘缓存且内容长度超过阈值,直接使用磁盘存储
|
||||
if IsDiskCacheEnabled() &&
|
||||
contentLength > 0 &&
|
||||
contentLength >= threshold &&
|
||||
IsDiskCacheAvailable(contentLength) {
|
||||
storage, err := newDiskStorageFromReader(reader, maxBytes, GetDiskCachePath())
|
||||
if err != nil {
|
||||
if IsRequestBodyTooLargeError(err) {
|
||||
return nil, err
|
||||
}
|
||||
// 磁盘存储失败,reader 已被消费,无法安全回退
|
||||
// 直接返回错误而非尝试回退(因为 reader 数据已丢失)
|
||||
return nil, fmt.Errorf("disk storage creation failed: %w", err)
|
||||
}
|
||||
IncrementDiskCacheHits()
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// 使用内存读取
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, ErrRequestBodyTooLarge
|
||||
}
|
||||
|
||||
storage, err := CreateBodyStorage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 如果最终使用内存存储,记录内存缓存命中
|
||||
if !storage.IsDisk() {
|
||||
IncrementMemoryCacheHits()
|
||||
} else {
|
||||
IncrementDiskCacheHits()
|
||||
}
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
type replayableBodyReader struct {
|
||||
storage BodyStorage
|
||||
}
|
||||
|
||||
func (r replayableBodyReader) Read(p []byte) (int, error) {
|
||||
return r.storage.Read(p)
|
||||
}
|
||||
|
||||
func (r replayableBodyReader) Size() int64 {
|
||||
return r.storage.Size()
|
||||
}
|
||||
|
||||
func (r replayableBodyReader) NewReader() (io.ReadCloser, error) {
|
||||
return r.storage.NewReader()
|
||||
}
|
||||
|
||||
// NewReplayableBodyReader exposes the replay capabilities of storage without
|
||||
// exposing io.Closer. This keeps ownership of the storage lifecycle with the
|
||||
// caller instead of allowing net/http to close it as the request body.
|
||||
func NewReplayableBodyReader(storage BodyStorage) ReplayableBody {
|
||||
return replayableBodyReader{storage: storage}
|
||||
}
|
||||
|
||||
// CleanupOldCacheFiles 清理旧的缓存文件(用于启动时清理残留)
|
||||
func CleanupOldCacheFiles() {
|
||||
// 使用统一的缓存管理
|
||||
CleanupOldDiskCacheFiles(5 * time.Minute)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewReplayableBodyReaderKeepsStorageLifecycleWithCaller(t *testing.T) {
|
||||
payload := []byte(`{"model":"test-model","input":"hello"}`)
|
||||
storage, err := CreateBodyStorage(payload)
|
||||
require.NoError(t, err)
|
||||
defer storage.Close()
|
||||
|
||||
body := NewReplayableBodyReader(storage)
|
||||
assert.EqualValues(t, len(payload), body.Size())
|
||||
_, exposesCloser := any(body).(io.Closer)
|
||||
assert.False(t, exposesCloser, "the request body must not expose the storage closer")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://example.com", body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, req.Body.Close())
|
||||
|
||||
replayBody, err := body.NewReader()
|
||||
require.NoError(t, err, "closing the HTTP request body must not close the storage")
|
||||
replay, err := io.ReadAll(replayBody)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, replayBody.Close())
|
||||
assert.Equal(t, payload, replay)
|
||||
|
||||
require.NoError(t, storage.Close())
|
||||
_, err = body.NewReader()
|
||||
require.ErrorIs(t, err, ErrStorageClosed)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
//"os"
|
||||
//"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var StartTime = time.Now().Unix() // unit: second
|
||||
var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
|
||||
var SystemName = "New API"
|
||||
var Footer = ""
|
||||
var Logo = ""
|
||||
var TopUpLink = ""
|
||||
|
||||
// var ChatLink = ""
|
||||
// var ChatLink2 = ""
|
||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||
// 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
|
||||
var DisplayInCurrencyEnabled = true
|
||||
var DisplayTokenStatEnabled = true
|
||||
var DrawingEnabled = true
|
||||
var TaskEnabled = true
|
||||
var DataExportEnabled = true
|
||||
var DataExportInterval = 5 // unit: minute
|
||||
var DataExportDefaultTime = "hour" // unit: minute
|
||||
var DefaultCollapseSidebar = false // default value of collapse sidebar
|
||||
|
||||
// Any options with "Secret", "Token" in its key won't be return by GetOptions
|
||||
|
||||
var SessionSecret = uuid.New().String()
|
||||
var CryptoSecret = uuid.New().String()
|
||||
var SessionCookieSecure = false
|
||||
var SessionCookieTrustedURLs []string
|
||||
|
||||
const (
|
||||
DefaultUserSessionActiveLimit = 50
|
||||
DefaultUserSessionIssuanceLimit = 100
|
||||
DefaultUserSessionIssuanceWindowSeconds = 24 * 60 * 60
|
||||
DefaultUserSessionRevokedRetentionDays = 7
|
||||
DefaultUserSessionHourlyAlertThreshold = 5000
|
||||
)
|
||||
|
||||
var (
|
||||
UserSessionActiveLimit = DefaultUserSessionActiveLimit
|
||||
UserSessionIssuanceLimit = DefaultUserSessionIssuanceLimit
|
||||
UserSessionIssuanceWindowSeconds = int64(DefaultUserSessionIssuanceWindowSeconds)
|
||||
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
|
||||
UserSessionHourlyAlertThreshold = DefaultUserSessionHourlyAlertThreshold
|
||||
)
|
||||
|
||||
var OptionMap map[string]string
|
||||
var OptionMapRWMutex sync.RWMutex
|
||||
|
||||
var ItemsPerPage = 10
|
||||
var MaxRecentItems = 1000
|
||||
|
||||
var PasswordLoginEnabled = true
|
||||
var PasswordLoginEncryptionEnabled = false
|
||||
var PasswordRegisterEnabled = true
|
||||
var EmailVerificationEnabled = false
|
||||
var GitHubOAuthEnabled = false
|
||||
var LinuxDOOAuthEnabled = false
|
||||
var WeChatAuthEnabled = false
|
||||
var TelegramOAuthEnabled = false
|
||||
var TurnstileCheckEnabled = false
|
||||
var RegisterEnabled = true
|
||||
|
||||
var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制
|
||||
var EmailAliasRestrictionEnabled = false // 是否启用邮箱别名限制
|
||||
var EmailDomainWhitelist = []string{
|
||||
"gmail.com",
|
||||
"163.com",
|
||||
"126.com",
|
||||
"qq.com",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"icloud.com",
|
||||
"yahoo.com",
|
||||
"foxmail.com",
|
||||
}
|
||||
var EmailLoginAuthServerList = []string{
|
||||
"smtp.sendcloud.net",
|
||||
"smtp.azurecomm.net",
|
||||
}
|
||||
|
||||
var DebugEnabled bool
|
||||
var MemoryCacheEnabled bool
|
||||
|
||||
var LogConsumeEnabled = true
|
||||
|
||||
var TLSInsecureSkipVerify bool
|
||||
var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
var SMTPServer = ""
|
||||
var SMTPPort = 587
|
||||
var SMTPSSLEnabled = false
|
||||
var SMTPStartTLSEnabled = false
|
||||
var SMTPInsecureSkipVerify = false
|
||||
var SMTPForceAuthLogin = false
|
||||
var SMTPAccount = ""
|
||||
var SMTPFrom = ""
|
||||
var SMTPToken = ""
|
||||
|
||||
var GitHubClientId = ""
|
||||
var GitHubClientSecret = ""
|
||||
var LinuxDOClientId = ""
|
||||
var LinuxDOClientSecret = ""
|
||||
var LinuxDOMinimumTrustLevel = 0
|
||||
|
||||
var WeChatServerAddress = ""
|
||||
var WeChatServerToken = ""
|
||||
var WeChatAccountQRCodeImageURL = ""
|
||||
|
||||
var TurnstileSiteKey = ""
|
||||
var TurnstileSecretKey = ""
|
||||
|
||||
var TelegramBotToken = ""
|
||||
var TelegramBotName = ""
|
||||
|
||||
var QuotaForNewUser = 0
|
||||
var QuotaForInviter = 0
|
||||
var QuotaForInvitee = 0
|
||||
var ChannelDisableThreshold = 5.0
|
||||
var AutomaticDisableChannelEnabled = false
|
||||
var AutomaticEnableChannelEnabled = false
|
||||
var QuotaRemindThreshold = 1000
|
||||
var PreConsumedQuota = 500
|
||||
|
||||
var RetryTimes = 0
|
||||
|
||||
//var RootUserEmail = ""
|
||||
|
||||
var IsMasterNode bool
|
||||
|
||||
const (
|
||||
NodeNameSourceManual = "manual"
|
||||
NodeNameSourceHostname = "hostname"
|
||||
)
|
||||
|
||||
// NodeName 节点名称,优先从 NODE_NAME 环境变量读取,未配置时回退主机名。
|
||||
// 用于审计日志和后台任务中标识节点身份;多实例部署时建议显式配置稳定 NODE_NAME。
|
||||
var NodeName = ""
|
||||
|
||||
// NodeNameSource records how NodeName was chosen so future instance-management
|
||||
// reporting can distinguish operator-configured names from automatic fallback.
|
||||
var NodeNameSource = NodeNameSourceHostname
|
||||
|
||||
var NodeNameManuallyConfigured bool
|
||||
|
||||
var requestInterval int
|
||||
var RequestInterval time.Duration
|
||||
|
||||
var SyncFrequency int // unit is second
|
||||
|
||||
var BatchUpdateEnabled = false
|
||||
var BatchUpdateInterval int
|
||||
|
||||
var RelayTimeout int // unit is second
|
||||
|
||||
var RelayIdleConnTimeout int // unit is second
|
||||
|
||||
// RelayResponseHeaderTimeout limits how long the relay transport waits for the
|
||||
// upstream response headers after the request has been fully written.
|
||||
// 0 disables it (previous behaviour: wait forever).
|
||||
//
|
||||
// Note this is NOT the same as RelayTimeout (http.Client.Timeout), which covers
|
||||
// the whole response read and therefore breaks legitimate long streaming calls.
|
||||
// ResponseHeaderTimeout only bounds the wait for the response headers; once the
|
||||
// headers arrive, streaming is unaffected.
|
||||
var RelayResponseHeaderTimeout int // unit is second
|
||||
var RelayMaxIdleConns int
|
||||
var RelayMaxIdleConnsPerHost int
|
||||
|
||||
var GeminiSafetySetting string
|
||||
|
||||
// https://docs.cohere.com/docs/safety-modes Type; NONE/CONTEXTUAL/STRICT
|
||||
var CohereSafetySetting string
|
||||
|
||||
const (
|
||||
RequestIdKey = "X-Oneapi-Request-Id"
|
||||
UpstreamRequestIdKey = "X-Upstream-Request-Id"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleGuestUser = 0
|
||||
RoleCommonUser = 1
|
||||
RoleAdminUser = 10
|
||||
RoleRootUser = 100
|
||||
)
|
||||
|
||||
func IsValidateRole(role int) bool {
|
||||
return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser
|
||||
}
|
||||
|
||||
var (
|
||||
FileUploadPermission = RoleGuestUser
|
||||
FileDownloadPermission = RoleGuestUser
|
||||
ImageUploadPermission = RoleGuestUser
|
||||
ImageDownloadPermission = RoleGuestUser
|
||||
)
|
||||
|
||||
// All duration's unit is seconds
|
||||
// Shouldn't larger then RateLimitKeyExpirationDuration
|
||||
var (
|
||||
GlobalApiRateLimitEnable bool
|
||||
GlobalApiRateLimitNum int
|
||||
GlobalApiRateLimitDuration int64
|
||||
|
||||
GlobalWebRateLimitEnable bool
|
||||
GlobalWebRateLimitNum int
|
||||
GlobalWebRateLimitDuration int64
|
||||
|
||||
CriticalRateLimitEnable bool
|
||||
CriticalRateLimitNum = 20
|
||||
CriticalRateLimitDuration int64 = 20 * 60
|
||||
|
||||
UploadRateLimitNum = 10
|
||||
UploadRateLimitDuration int64 = 60
|
||||
|
||||
DownloadRateLimitNum = 10
|
||||
DownloadRateLimitDuration int64 = 60
|
||||
|
||||
// Per-user search rate limit (applies after authentication, keyed by user ID)
|
||||
SearchRateLimitEnable = true
|
||||
SearchRateLimitNum = 10
|
||||
SearchRateLimitDuration int64 = 60
|
||||
)
|
||||
|
||||
var RateLimitKeyExpirationDuration = 20 * time.Minute
|
||||
|
||||
const (
|
||||
UserStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
UserStatusDisabled = 2 // also don't use 0
|
||||
)
|
||||
|
||||
const (
|
||||
TokenStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
TokenStatusDisabled = 2 // also don't use 0
|
||||
TokenStatusExpired = 3
|
||||
TokenStatusExhausted = 4
|
||||
)
|
||||
|
||||
const (
|
||||
RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
RedemptionCodeStatusDisabled = 2 // also don't use 0
|
||||
RedemptionCodeStatusUsed = 3 // also don't use 0
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelStatusUnknown = 0
|
||||
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
ChannelStatusManuallyDisabled = 2 // also don't use 0
|
||||
ChannelStatusAutoDisabled = 3
|
||||
)
|
||||
|
||||
const (
|
||||
TopUpStatusPending = "pending"
|
||||
TopUpStatusSuccess = "success"
|
||||
TopUpStatusFailed = "failed"
|
||||
TopUpStatusExpired = "expired"
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jinzhu/copier"
|
||||
)
|
||||
|
||||
func DeepCopy[T any](src *T) (*T, error) {
|
||||
if src == nil {
|
||||
return nil, fmt.Errorf("copy source cannot be nil")
|
||||
}
|
||||
var dst T
|
||||
err := copier.CopyWithOption(&dst, src, copier.Option{
|
||||
DeepCopy: true, IgnoreEmpty: true,
|
||||
Converters: []copier.TypeConverter{{
|
||||
SrcType: json.RawMessage{},
|
||||
DstType: json.RawMessage{},
|
||||
Fn: func(src any) (any, error) {
|
||||
// Copy raw JSON in bulk while retaining independent storage for
|
||||
// request mutation and retries, instead of reflecting over each byte.
|
||||
return json.RawMessage(bytes.Clone(src.(json.RawMessage))), nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dst, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func GenerateHMACWithKey(key []byte, data string) string {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(data))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func GenerateHMAC(data string) string {
|
||||
h := hmac.New(sha256.New, []byte(CryptoSecret))
|
||||
h.Write([]byte(data))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func Password2Hash(password string) (string, error) {
|
||||
passwordBytes := []byte(password)
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
|
||||
return string(hashedPassword), err
|
||||
}
|
||||
|
||||
func ValidatePasswordAndHash(password string, hash string) bool {
|
||||
if strings.HasPrefix(hash, "$argon2id$") {
|
||||
return validateArgon2AccountPassword(password, hash)
|
||||
}
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
||||
// Use of this source code is governed by a MIT style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type stringWriter interface {
|
||||
io.Writer
|
||||
writeString(string) (int, error)
|
||||
}
|
||||
|
||||
type stringWrapper struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (w stringWrapper) writeString(str string) (int, error) {
|
||||
return w.Writer.Write([]byte(str))
|
||||
}
|
||||
|
||||
func checkWriter(writer io.Writer) stringWriter {
|
||||
if w, ok := writer.(stringWriter); ok {
|
||||
return w
|
||||
} else {
|
||||
return stringWrapper{writer}
|
||||
}
|
||||
}
|
||||
|
||||
// Server-Sent Events
|
||||
// W3C Working Draft 29 October 2009
|
||||
// http://www.w3.org/TR/2009/WD-eventsource-20091029/
|
||||
|
||||
var writeContentType = []string{"text/event-stream"}
|
||||
var noCache = []string{"no-cache"}
|
||||
|
||||
var fieldReplacer = strings.NewReplacer(
|
||||
"\n", "\\n",
|
||||
"\r", "\\r")
|
||||
|
||||
var dataReplacer = strings.NewReplacer(
|
||||
"\n", "\n",
|
||||
"\r", "\\r")
|
||||
|
||||
// CustomEvent does not synchronize writes to the response writer. Streaming
|
||||
// callers must serialize event writes at the stream level.
|
||||
type CustomEvent struct {
|
||||
Event string
|
||||
Id string
|
||||
Retry uint
|
||||
Data any
|
||||
}
|
||||
|
||||
func encode(writer io.Writer, event CustomEvent) error {
|
||||
w := checkWriter(writer)
|
||||
return writeData(w, event.Data)
|
||||
}
|
||||
|
||||
func writeData(w stringWriter, data any) error {
|
||||
dataReplacer.WriteString(w, fmt.Sprint(data))
|
||||
if strings.HasPrefix(data.(string), "data") {
|
||||
w.writeString("\n\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r CustomEvent) Render(w http.ResponseWriter) error {
|
||||
r.WriteContentType(w)
|
||||
return encode(w, r)
|
||||
}
|
||||
|
||||
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
||||
header := w.Header()
|
||||
header["Content-Type"] = writeContentType
|
||||
|
||||
if _, exist := header["Cache-Control"]; !exist {
|
||||
header["Cache-Control"] = noCache
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package common
|
||||
|
||||
type DatabaseType string
|
||||
|
||||
const (
|
||||
DatabaseTypeMySQL DatabaseType = "mysql"
|
||||
DatabaseTypeSQLite DatabaseType = "sqlite"
|
||||
DatabaseTypePostgreSQL DatabaseType = "postgres"
|
||||
DatabaseTypeClickHouse DatabaseType = "clickhouse"
|
||||
)
|
||||
|
||||
var mainDatabaseType = DatabaseTypeSQLite
|
||||
var logDatabaseType = DatabaseTypeSQLite
|
||||
|
||||
func MainDatabaseType() DatabaseType {
|
||||
return mainDatabaseType
|
||||
}
|
||||
|
||||
func LogDatabaseType() DatabaseType {
|
||||
return logDatabaseType
|
||||
}
|
||||
|
||||
func SetMainDatabaseType(databaseType DatabaseType) {
|
||||
mainDatabaseType = databaseType
|
||||
}
|
||||
|
||||
func SetLogDatabaseType(databaseType DatabaseType) {
|
||||
logDatabaseType = databaseType
|
||||
}
|
||||
|
||||
func SetDatabaseTypes(mainType DatabaseType, logType DatabaseType) {
|
||||
mainDatabaseType = mainType
|
||||
logDatabaseType = logType
|
||||
}
|
||||
|
||||
func UsingMainDatabase(databaseType DatabaseType) bool {
|
||||
return mainDatabaseType == databaseType
|
||||
}
|
||||
|
||||
func UsingLogDatabase(databaseType DatabaseType) bool {
|
||||
return logDatabaseType == databaseType
|
||||
}
|
||||
|
||||
// SQLitePath is the DSN for the default SQLite database. It uses WAL journal
|
||||
// mode so readers are never blocked by the single writer, plus a 30s busy
|
||||
// timeout for writers to queue.
|
||||
//
|
||||
// Two details are non-obvious and both are required for concurrent correctness:
|
||||
//
|
||||
// 1. The busy timeout must be passed as a `_pragma=busy_timeout(30000)` DSN
|
||||
// parameter. The pure-Go driver (modernc.org/sqlite, used through
|
||||
// github.com/glebarez/sqlite) silently ignores the plain `_busy_timeout=`
|
||||
// form, so without this the effective timeout stays at SQLite's 5s default
|
||||
// and concurrent writes surface as "database is locked" (see #6805).
|
||||
//
|
||||
// 2. `_txlock=immediate` (BEGIN IMMEDIATE) must be enabled. Without it, a
|
||||
// transaction that first SELECTs (establishing a read snapshot) and then
|
||||
// writes can hit SQLITE_BUSY_SNAPSHOT when another connection commits in
|
||||
// between; the busy handler does not cover that case, so the write fails
|
||||
// instantly no matter the timeout. BEGIN IMMEDIATE takes the write lock up
|
||||
// front, so writers serialize through the busy timeout instead of dying on
|
||||
// a stale snapshot. Autocommit SELECTs stay concurrent because WAL keeps
|
||||
// readers unlocked.
|
||||
var SQLitePath = "one-api.db?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_txlock=immediate"
|
||||
@@ -0,0 +1,176 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DiskCacheType 磁盘缓存类型
|
||||
type DiskCacheType string
|
||||
|
||||
const (
|
||||
DiskCacheTypeBody DiskCacheType = "body" // 请求体缓存
|
||||
DiskCacheTypeFile DiskCacheType = "file" // 文件数据缓存
|
||||
)
|
||||
|
||||
// 统一的缓存目录名
|
||||
const diskCacheDir = "new-api-body-cache"
|
||||
|
||||
// GetDiskCacheDir 获取统一的磁盘缓存目录
|
||||
// 注意:每次调用都会重新计算,以响应配置变化
|
||||
func GetDiskCacheDir() string {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
return filepath.Join(cachePath, diskCacheDir)
|
||||
}
|
||||
|
||||
// EnsureDiskCacheDir 确保缓存目录存在
|
||||
func EnsureDiskCacheDir() error {
|
||||
dir := GetDiskCacheDir()
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
// CreateDiskCacheFile 创建磁盘缓存文件
|
||||
// cacheType: 缓存类型(body/file)
|
||||
// 返回文件路径和文件句柄
|
||||
func CreateDiskCacheFile(cacheType DiskCacheType) (string, *os.File, error) {
|
||||
if err := EnsureDiskCacheDir(); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
dir := GetDiskCacheDir()
|
||||
filename := fmt.Sprintf("%s-%s-%d.tmp", cacheType, uuid.New().String()[:8], time.Now().UnixNano())
|
||||
filePath := filepath.Join(dir, filename)
|
||||
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create cache file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, file, nil
|
||||
}
|
||||
|
||||
// WriteDiskCacheFile 写入数据到磁盘缓存文件
|
||||
// 返回文件路径
|
||||
func WriteDiskCacheFile(cacheType DiskCacheType, data []byte) (string, error) {
|
||||
filePath, file, err := CreateDiskCacheFile(cacheType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = file.Write(data)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
os.Remove(filePath)
|
||||
return "", fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
if err := file.Close(); err != nil {
|
||||
os.Remove(filePath)
|
||||
return "", fmt.Errorf("failed to close cache file: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// WriteDiskCacheFileString 写入字符串到磁盘缓存文件
|
||||
func WriteDiskCacheFileString(cacheType DiskCacheType, data string) (string, error) {
|
||||
return WriteDiskCacheFile(cacheType, []byte(data))
|
||||
}
|
||||
|
||||
// ReadDiskCacheFile 读取磁盘缓存文件
|
||||
func ReadDiskCacheFile(filePath string) ([]byte, error) {
|
||||
return os.ReadFile(filePath)
|
||||
}
|
||||
|
||||
// ReadDiskCacheFileString 读取磁盘缓存文件为字符串
|
||||
func ReadDiskCacheFileString(filePath string) (string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// RemoveDiskCacheFile 删除磁盘缓存文件
|
||||
func RemoveDiskCacheFile(filePath string) error {
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
// CleanupOldDiskCacheFiles 清理旧的缓存文件
|
||||
// maxAge: 文件最大存活时间
|
||||
// 注意:此函数只删除文件,不更新统计(因为无法知道每个文件的原始大小)
|
||||
func CleanupOldDiskCacheFiles(maxAge time.Duration) error {
|
||||
dir := GetDiskCacheDir()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // 目录不存在,无需清理
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(info.ModTime()) > maxAge {
|
||||
// 注意:后台清理任务删除文件时,由于无法得知原始 base64Size,
|
||||
// 只能按磁盘文件大小扣减。这在目前 base64 存储模式下是准确的。
|
||||
if err := os.Remove(filepath.Join(dir, entry.Name())); err == nil {
|
||||
DecrementDiskFiles(info.Size())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDiskCacheInfo 获取磁盘缓存目录信息
|
||||
func GetDiskCacheInfo() (fileCount int, totalSize int64, err error) {
|
||||
dir := GetDiskCacheDir()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fileCount++
|
||||
totalSize += info.Size()
|
||||
}
|
||||
return fileCount, totalSize, nil
|
||||
}
|
||||
|
||||
// ShouldUseDiskCache 判断是否应该使用磁盘缓存
|
||||
func ShouldUseDiskCache(dataSize int64) bool {
|
||||
if !IsDiskCacheEnabled() {
|
||||
return false
|
||||
}
|
||||
threshold := GetDiskCacheThresholdBytes()
|
||||
if dataSize < threshold {
|
||||
return false
|
||||
}
|
||||
return IsDiskCacheAvailable(dataSize)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// DiskCacheConfig 磁盘缓存配置(由 performance_setting 包更新)
|
||||
type DiskCacheConfig struct {
|
||||
// Enabled 是否启用磁盘缓存
|
||||
Enabled bool
|
||||
// ThresholdMB 触发磁盘缓存的请求体大小阈值(MB)
|
||||
ThresholdMB int
|
||||
// MaxSizeMB 磁盘缓存最大总大小(MB)
|
||||
MaxSizeMB int
|
||||
// Path 磁盘缓存目录
|
||||
Path string
|
||||
}
|
||||
|
||||
// 全局磁盘缓存配置
|
||||
var diskCacheConfig = DiskCacheConfig{
|
||||
Enabled: false,
|
||||
ThresholdMB: 10,
|
||||
MaxSizeMB: 1024,
|
||||
Path: "",
|
||||
}
|
||||
var diskCacheConfigMu sync.RWMutex
|
||||
|
||||
// GetDiskCacheConfig 获取磁盘缓存配置
|
||||
func GetDiskCacheConfig() DiskCacheConfig {
|
||||
diskCacheConfigMu.RLock()
|
||||
defer diskCacheConfigMu.RUnlock()
|
||||
return diskCacheConfig
|
||||
}
|
||||
|
||||
// SetDiskCacheConfig 设置磁盘缓存配置
|
||||
func SetDiskCacheConfig(config DiskCacheConfig) {
|
||||
diskCacheConfigMu.Lock()
|
||||
defer diskCacheConfigMu.Unlock()
|
||||
diskCacheConfig = config
|
||||
}
|
||||
|
||||
// IsDiskCacheEnabled 是否启用磁盘缓存
|
||||
func IsDiskCacheEnabled() bool {
|
||||
diskCacheConfigMu.RLock()
|
||||
defer diskCacheConfigMu.RUnlock()
|
||||
return diskCacheConfig.Enabled
|
||||
}
|
||||
|
||||
// GetDiskCacheThresholdBytes 获取磁盘缓存阈值(字节)
|
||||
func GetDiskCacheThresholdBytes() int64 {
|
||||
diskCacheConfigMu.RLock()
|
||||
defer diskCacheConfigMu.RUnlock()
|
||||
return int64(diskCacheConfig.ThresholdMB) << 20
|
||||
}
|
||||
|
||||
// GetDiskCacheMaxSizeBytes 获取磁盘缓存最大大小(字节)
|
||||
func GetDiskCacheMaxSizeBytes() int64 {
|
||||
diskCacheConfigMu.RLock()
|
||||
defer diskCacheConfigMu.RUnlock()
|
||||
return int64(diskCacheConfig.MaxSizeMB) << 20
|
||||
}
|
||||
|
||||
// GetDiskCachePath 获取磁盘缓存目录
|
||||
func GetDiskCachePath() string {
|
||||
diskCacheConfigMu.RLock()
|
||||
defer diskCacheConfigMu.RUnlock()
|
||||
return diskCacheConfig.Path
|
||||
}
|
||||
|
||||
// DiskCacheStats 磁盘缓存统计信息
|
||||
type DiskCacheStats struct {
|
||||
// 当前活跃的磁盘缓存文件数
|
||||
ActiveDiskFiles int64 `json:"active_disk_files"`
|
||||
// 当前磁盘缓存总大小(字节)
|
||||
CurrentDiskUsageBytes int64 `json:"current_disk_usage_bytes"`
|
||||
// 当前内存缓存数量
|
||||
ActiveMemoryBuffers int64 `json:"active_memory_buffers"`
|
||||
// 当前内存缓存总大小(字节)
|
||||
CurrentMemoryUsageBytes int64 `json:"current_memory_usage_bytes"`
|
||||
// 磁盘缓存命中次数
|
||||
DiskCacheHits int64 `json:"disk_cache_hits"`
|
||||
// 内存缓存命中次数
|
||||
MemoryCacheHits int64 `json:"memory_cache_hits"`
|
||||
// 磁盘缓存最大限制(字节)
|
||||
DiskCacheMaxBytes int64 `json:"disk_cache_max_bytes"`
|
||||
// 磁盘缓存阈值(字节)
|
||||
DiskCacheThresholdBytes int64 `json:"disk_cache_threshold_bytes"`
|
||||
}
|
||||
|
||||
var diskCacheStats DiskCacheStats
|
||||
|
||||
// GetDiskCacheStats 获取缓存统计信息
|
||||
func GetDiskCacheStats() DiskCacheStats {
|
||||
stats := DiskCacheStats{
|
||||
ActiveDiskFiles: atomic.LoadInt64(&diskCacheStats.ActiveDiskFiles),
|
||||
CurrentDiskUsageBytes: atomic.LoadInt64(&diskCacheStats.CurrentDiskUsageBytes),
|
||||
ActiveMemoryBuffers: atomic.LoadInt64(&diskCacheStats.ActiveMemoryBuffers),
|
||||
CurrentMemoryUsageBytes: atomic.LoadInt64(&diskCacheStats.CurrentMemoryUsageBytes),
|
||||
DiskCacheHits: atomic.LoadInt64(&diskCacheStats.DiskCacheHits),
|
||||
MemoryCacheHits: atomic.LoadInt64(&diskCacheStats.MemoryCacheHits),
|
||||
DiskCacheMaxBytes: GetDiskCacheMaxSizeBytes(),
|
||||
DiskCacheThresholdBytes: GetDiskCacheThresholdBytes(),
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// IncrementDiskFiles 增加磁盘文件计数
|
||||
func IncrementDiskFiles(size int64) {
|
||||
atomic.AddInt64(&diskCacheStats.ActiveDiskFiles, 1)
|
||||
atomic.AddInt64(&diskCacheStats.CurrentDiskUsageBytes, size)
|
||||
}
|
||||
|
||||
// DecrementDiskFiles 减少磁盘文件计数
|
||||
func DecrementDiskFiles(size int64) {
|
||||
if atomic.AddInt64(&diskCacheStats.ActiveDiskFiles, -1) < 0 {
|
||||
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, 0)
|
||||
}
|
||||
if atomic.AddInt64(&diskCacheStats.CurrentDiskUsageBytes, -size) < 0 {
|
||||
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// IncrementMemoryBuffers 增加内存缓存计数
|
||||
func IncrementMemoryBuffers(size int64) {
|
||||
atomic.AddInt64(&diskCacheStats.ActiveMemoryBuffers, 1)
|
||||
atomic.AddInt64(&diskCacheStats.CurrentMemoryUsageBytes, size)
|
||||
}
|
||||
|
||||
// DecrementMemoryBuffers 减少内存缓存计数
|
||||
func DecrementMemoryBuffers(size int64) {
|
||||
atomic.AddInt64(&diskCacheStats.ActiveMemoryBuffers, -1)
|
||||
atomic.AddInt64(&diskCacheStats.CurrentMemoryUsageBytes, -size)
|
||||
}
|
||||
|
||||
// IncrementDiskCacheHits 增加磁盘缓存命中次数
|
||||
func IncrementDiskCacheHits() {
|
||||
atomic.AddInt64(&diskCacheStats.DiskCacheHits, 1)
|
||||
}
|
||||
|
||||
// IncrementMemoryCacheHits 增加内存缓存命中次数
|
||||
func IncrementMemoryCacheHits() {
|
||||
atomic.AddInt64(&diskCacheStats.MemoryCacheHits, 1)
|
||||
}
|
||||
|
||||
// ResetDiskCacheStats 重置命中统计信息(不重置当前使用量)
|
||||
func ResetDiskCacheStats() {
|
||||
atomic.StoreInt64(&diskCacheStats.DiskCacheHits, 0)
|
||||
atomic.StoreInt64(&diskCacheStats.MemoryCacheHits, 0)
|
||||
}
|
||||
|
||||
// ResetDiskCacheUsage 重置磁盘缓存使用量统计(用于清理缓存后)
|
||||
func ResetDiskCacheUsage() {
|
||||
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, 0)
|
||||
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, 0)
|
||||
}
|
||||
|
||||
// SyncDiskCacheStats 从实际磁盘状态同步统计信息
|
||||
// 用于修正统计与实际不符的情况
|
||||
func SyncDiskCacheStats() {
|
||||
fileCount, totalSize, err := GetDiskCacheInfo()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, int64(fileCount))
|
||||
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, totalSize)
|
||||
}
|
||||
|
||||
// IsDiskCacheAvailable 检查是否可以创建新的磁盘缓存
|
||||
func IsDiskCacheAvailable(requestSize int64) bool {
|
||||
if !IsDiskCacheEnabled() {
|
||||
return false
|
||||
}
|
||||
maxBytes := GetDiskCacheMaxSizeBytes()
|
||||
currentUsage := atomic.LoadInt64(&diskCacheStats.CurrentDiskUsageBytes)
|
||||
return currentUsage+requestSize <= maxBytes
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type outlookAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func LoginAuth(username, password string) smtp.Auth {
|
||||
return &outlookAuth{username, password}
|
||||
}
|
||||
|
||||
func (a *outlookAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *outlookAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("unknown fromServer")
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func isOutlookServer(server string) bool {
|
||||
// 兼容多地区的outlook邮箱和ofb邮箱
|
||||
// 其实应该加一个Option来区分是否用LOGIN的方式登录
|
||||
// 先临时兼容一下
|
||||
return strings.Contains(server, "outlook") || strings.Contains(server, "onmicrosoft")
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func generateMessageID() (string, error) {
|
||||
split := strings.Split(SMTPFrom, "@")
|
||||
if len(split) < 2 {
|
||||
return "", fmt.Errorf("invalid SMTP account")
|
||||
}
|
||||
domain := strings.Split(SMTPFrom, "@")[1]
|
||||
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
|
||||
}
|
||||
|
||||
func shouldUseSMTPLoginAuth() bool {
|
||||
if SMTPForceAuthLogin {
|
||||
return true
|
||||
}
|
||||
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
|
||||
}
|
||||
|
||||
func getSMTPAuth() smtp.Auth {
|
||||
return AutoSMTPAuth(SMTPAccount, SMTPToken)
|
||||
}
|
||||
|
||||
func shouldAuthenticateSMTP() bool {
|
||||
return SMTPAccount != "" && SMTPToken != ""
|
||||
}
|
||||
|
||||
func smtpTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: SMTPServer,
|
||||
InsecureSkipVerify: SMTPInsecureSkipVerify, // #nosec G402 -- admin-controlled SMTP compatibility option.
|
||||
}
|
||||
}
|
||||
|
||||
func newSMTPClient(addr string) (*smtp.Client, error) {
|
||||
if SMTPSSLEnabled || (SMTPPort == 465 && !SMTPStartTLSEnabled) {
|
||||
conn, err := tls.Dial("tcp", addr, smtpTLSConfig())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, SMTPServer)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if SMTPStartTLSEnabled {
|
||||
startTLSSupported, _ := client.Extension("STARTTLS")
|
||||
if !startTLSSupported {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("SMTP server does not support STARTTLS")
|
||||
}
|
||||
if err := client.StartTLS(smtpTLSConfig()); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func SendEmail(subject string, receiver string, content string) error {
|
||||
if SMTPFrom == "" { // for compatibility
|
||||
SMTPFrom = SMTPAccount
|
||||
}
|
||||
id, err2 := generateMessageID()
|
||||
if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
if SMTPServer == "" && SMTPAccount == "" {
|
||||
return fmt.Errorf("SMTP 服务器未配置")
|
||||
}
|
||||
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
|
||||
mail := []byte(fmt.Sprintf("To: %s\r\n"+
|
||||
"From: %s <%s>\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"Date: %s\r\n"+
|
||||
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
|
||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
|
||||
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
|
||||
auth := getSMTPAuth()
|
||||
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
|
||||
to := strings.Split(receiver, ";")
|
||||
var err error
|
||||
client, err := newSMTPClient(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if shouldAuthenticateSMTP() {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = client.Mail(SMTPFrom); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, receiver := range to {
|
||||
if err = client.Rcpt(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(mail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = client.Quit()
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
ntlmssp "github.com/Azure/go-ntlmssp"
|
||||
)
|
||||
|
||||
type smtpAutoAuth struct {
|
||||
username string
|
||||
password string
|
||||
mech string
|
||||
}
|
||||
|
||||
func AutoSMTPAuth(username, password string) smtp.Auth {
|
||||
return &smtpAutoAuth{username: username, password: password}
|
||||
}
|
||||
|
||||
func (a *smtpAutoAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
useLoginAuth := SMTPForceAuthLogin
|
||||
if !useLoginAuth && shouldUseSMTPLoginAuth() {
|
||||
useLoginAuth = !(server != nil && len(server.Auth) == 1 && smtpServerSupportsAuth(server, "NTLM"))
|
||||
}
|
||||
if useLoginAuth {
|
||||
a.mech = "LOGIN"
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case smtpServerSupportsAuth(server, "PLAIN"):
|
||||
a.mech = "PLAIN"
|
||||
return smtp.PlainAuth("", a.username, a.password, SMTPServer).Start(server)
|
||||
case smtpServerSupportsAuth(server, "LOGIN"):
|
||||
a.mech = "LOGIN"
|
||||
return "LOGIN", []byte{}, nil
|
||||
case smtpServerSupportsAuth(server, "NTLM"):
|
||||
a.mech = "NTLM"
|
||||
negotiateMessage, err := ntlmssp.NewNegotiateMessage("", "")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "NTLM", negotiateMessage, nil
|
||||
default:
|
||||
a.mech = "PLAIN"
|
||||
return smtp.PlainAuth("", a.username, a.password, SMTPServer).Start(server)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *smtpAutoAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch a.mech {
|
||||
case "LOGIN":
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("unknown SMTP AUTH LOGIN challenge")
|
||||
}
|
||||
case "NTLM":
|
||||
return ntlmssp.NewAuthenticateMessage(fromServer, a.username, a.password, nil)
|
||||
default:
|
||||
return nil, errors.New("unexpected SMTP auth challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func smtpServerSupportsAuth(server *smtp.ServerInfo, mechanism string) bool {
|
||||
if server == nil {
|
||||
return false
|
||||
}
|
||||
for _, auth := range server.Auth {
|
||||
if strings.EqualFold(auth, mechanism) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
host string
|
||||
port int
|
||||
cert tls.Certificate
|
||||
advertiseSTARTTLS bool
|
||||
authMechanisms []string
|
||||
messages chan string
|
||||
authCommands chan string
|
||||
startTLSCommands chan string
|
||||
}
|
||||
|
||||
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
|
||||
return newFakeSMTPServerWithSTARTTLSAdvertisement(t, true)
|
||||
}
|
||||
|
||||
func newFakeSMTPServerWithSTARTTLSAdvertisement(t *testing.T, advertiseSTARTTLS bool) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
|
||||
cert, err := newTestTLSCertificate()
|
||||
require.NoError(t, err)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
host, portText, err := net.SplitHostPort(listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portText)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &fakeSMTPServer{
|
||||
listener: listener,
|
||||
host: host,
|
||||
port: port,
|
||||
cert: cert,
|
||||
advertiseSTARTTLS: advertiseSTARTTLS,
|
||||
authMechanisms: []string{"PLAIN", "LOGIN"},
|
||||
messages: make(chan string, 1),
|
||||
authCommands: make(chan string, 1),
|
||||
startTLSCommands: make(chan string, 1),
|
||||
}
|
||||
go server.serve()
|
||||
return server
|
||||
}
|
||||
|
||||
func newFakeImplicitTLSSMTPServer(t *testing.T) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
|
||||
cert, err := newTestTLSCertificate()
|
||||
require.NoError(t, err)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
host, portText, err := net.SplitHostPort(listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portText)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &fakeSMTPServer{
|
||||
listener: tls.NewListener(listener, &tls.Config{Certificates: []tls.Certificate{cert}}),
|
||||
host: host,
|
||||
port: port,
|
||||
cert: cert,
|
||||
advertiseSTARTTLS: false,
|
||||
authMechanisms: []string{"PLAIN", "LOGIN"},
|
||||
messages: make(chan string, 1),
|
||||
authCommands: make(chan string, 1),
|
||||
startTLSCommands: make(chan string, 1),
|
||||
}
|
||||
go server.serve()
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) close() {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) serve() {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
if err := writeSMTPLine(rw, "220 fake.smtp.local ESMTP"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
for {
|
||||
line, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
command := strings.TrimRight(line, "\r\n")
|
||||
upperCommand := strings.ToUpper(command)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(upperCommand, "EHLO"):
|
||||
if err := writeSMTPLine(rw, "250-fake.smtp.local"); err != nil {
|
||||
return
|
||||
}
|
||||
if !encrypted && s.advertiseSTARTTLS {
|
||||
if err := writeSMTPLine(rw, "250-STARTTLS"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(s.authMechanisms) > 0 {
|
||||
if err := writeSMTPLine(rw, "250 AUTH "+strings.Join(s.authMechanisms, " ")); err != nil {
|
||||
return
|
||||
}
|
||||
} else if err := writeSMTPLine(rw, "250 8BITMIME"); err != nil {
|
||||
return
|
||||
}
|
||||
case upperCommand == "STARTTLS":
|
||||
if encrypted || !s.advertiseSTARTTLS {
|
||||
if err := writeSMTPLine(rw, "502 5.5.1 STARTTLS not supported"); err != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.startTLSCommands <- command:
|
||||
default:
|
||||
}
|
||||
if err := writeSMTPLine(rw, "220 2.0.0 Ready to start TLS"); err != nil {
|
||||
return
|
||||
}
|
||||
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{s.cert}})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
conn = tlsConn
|
||||
rw = bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
encrypted = true
|
||||
case strings.HasPrefix(upperCommand, "AUTH"):
|
||||
select {
|
||||
case s.authCommands <- command:
|
||||
default:
|
||||
}
|
||||
if err := writeSMTPLine(rw, "235 2.7.0 Authentication successful"); err != nil {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upperCommand, "MAIL FROM:"):
|
||||
if err := writeSMTPLine(rw, "250 2.1.0 Sender OK"); err != nil {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upperCommand, "RCPT TO:"):
|
||||
if err := writeSMTPLine(rw, "250 2.1.5 Recipient OK"); err != nil {
|
||||
return
|
||||
}
|
||||
case upperCommand == "DATA":
|
||||
if err := writeSMTPLine(rw, "354 End data with <CR><LF>.<CR><LF>"); err != nil {
|
||||
return
|
||||
}
|
||||
var data strings.Builder
|
||||
for {
|
||||
dataLine, err := rw.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
data.WriteString(dataLine)
|
||||
}
|
||||
s.messages <- data.String()
|
||||
if err := writeSMTPLine(rw, "250 2.0.0 Queued"); err != nil {
|
||||
return
|
||||
}
|
||||
case upperCommand == "QUIT":
|
||||
_ = writeSMTPLine(rw, "221 2.0.0 Bye")
|
||||
return
|
||||
default:
|
||||
if err := writeSMTPLine(rw, "502 5.5.1 Command not implemented"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSMTPLine(rw *bufio.ReadWriter, line string) error {
|
||||
_, err := rw.WriteString(line + "\r\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rw.Flush()
|
||||
}
|
||||
|
||||
func newTestTLSCertificate() (tls.Certificate, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "aixinexchange01.aixin-chip.com",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"aixinexchange01", "aixinexchange01.aixin-chip.com"},
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
return tls.X509KeyPair(certPEM, keyPEM)
|
||||
}
|
||||
|
||||
func withSMTPSettings(t *testing.T) {
|
||||
t.Helper()
|
||||
originalSMTPServer := SMTPServer
|
||||
originalSMTPPort := SMTPPort
|
||||
originalSMTPSSLEnabled := SMTPSSLEnabled
|
||||
originalSMTPStartTLSEnabled := SMTPStartTLSEnabled
|
||||
originalSMTPInsecureSkipVerify := SMTPInsecureSkipVerify
|
||||
originalSMTPForceAuthLogin := SMTPForceAuthLogin
|
||||
originalSMTPAccount := SMTPAccount
|
||||
originalSMTPFrom := SMTPFrom
|
||||
originalSMTPToken := SMTPToken
|
||||
originalSystemName := SystemName
|
||||
|
||||
t.Cleanup(func() {
|
||||
SMTPServer = originalSMTPServer
|
||||
SMTPPort = originalSMTPPort
|
||||
SMTPSSLEnabled = originalSMTPSSLEnabled
|
||||
SMTPStartTLSEnabled = originalSMTPStartTLSEnabled
|
||||
SMTPInsecureSkipVerify = originalSMTPInsecureSkipVerify
|
||||
SMTPForceAuthLogin = originalSMTPForceAuthLogin
|
||||
SMTPAccount = originalSMTPAccount
|
||||
SMTPFrom = originalSMTPFrom
|
||||
SMTPToken = originalSMTPToken
|
||||
SystemName = originalSystemName
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendEmailUsesExplicitStartTLSWithInsecureCertificate(t *testing.T) {
|
||||
server := newFakeSMTPServer(t)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = true
|
||||
SMTPInsecureSkipVerify = true
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "sender@example.com"
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = "secret"
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case message := <-server.messages:
|
||||
require.Contains(t, message, "Subject: =?UTF-8?B?")
|
||||
require.Contains(t, message, "<p>123456</p>")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP DATA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailExplicitStartTLSRequiresServerSupport(t *testing.T) {
|
||||
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = true
|
||||
SMTPInsecureSkipVerify = true
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "sender@example.com"
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = "secret"
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "STARTTLS")
|
||||
}
|
||||
|
||||
func TestSendEmailDoesNotAutoUpgradeWhenStartTLSDisabled(t *testing.T) {
|
||||
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, true)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = false
|
||||
SMTPInsecureSkipVerify = false
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "sender@example.com"
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = "secret"
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case command := <-server.startTLSCommands:
|
||||
t.Fatalf("unexpected SMTP STARTTLS command: %s", command)
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case message := <-server.messages:
|
||||
require.Contains(t, message, "<p>123456</p>")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP DATA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPPlainAuthRejectsRemotePlaintextConnection(t *testing.T) {
|
||||
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = "smtp.example.com"
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = false
|
||||
SMTPInsecureSkipVerify = false
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "sender@example.com"
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = "secret"
|
||||
|
||||
conn, err := net.Dial("tcp", net.JoinHostPort(server.host, strconv.Itoa(server.port)))
|
||||
require.NoError(t, err)
|
||||
client, err := smtp.NewClient(conn, SMTPServer)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Auth(getSMTPAuth())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unencrypted connection")
|
||||
|
||||
select {
|
||||
case command := <-server.authCommands:
|
||||
t.Fatalf("unexpected SMTP auth command: %s", command)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSMTPClientHonorsExplicitStartTLSWhenPortIs465(t *testing.T) {
|
||||
server := newFakeSMTPServer(t)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = 465
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = true
|
||||
SMTPInsecureSkipVerify = true
|
||||
|
||||
client, err := newSMTPClient(fmt.Sprintf("%s:%d", server.host, server.port))
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
select {
|
||||
case command := <-server.startTLSCommands:
|
||||
require.Equal(t, "STARTTLS", command)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP STARTTLS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSMTPClientKeepsImplicitTLSForLegacyPort465(t *testing.T) {
|
||||
server := newFakeImplicitTLSSMTPServer(t)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = 465
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = false
|
||||
SMTPInsecureSkipVerify = true
|
||||
|
||||
client, err := newSMTPClient(fmt.Sprintf("%s:%d", server.host, server.port))
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
}
|
||||
|
||||
func TestSendEmailSkipsAuthWhenCredentialsAreEmpty(t *testing.T) {
|
||||
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = false
|
||||
SMTPInsecureSkipVerify = false
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = ""
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = ""
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case command := <-server.authCommands:
|
||||
t.Fatalf("unexpected SMTP auth command: %s", command)
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case message := <-server.messages:
|
||||
require.Contains(t, message, "<p>123456</p>")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP DATA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailSkipsAuthWhenCredentialsAreIncomplete(t *testing.T) {
|
||||
server := newFakeSMTPServerWithSTARTTLSAdvertisement(t, false)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = false
|
||||
SMTPInsecureSkipVerify = false
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "sender@example.com"
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = ""
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case command := <-server.authCommands:
|
||||
t.Fatalf("unexpected SMTP auth command: %s", command)
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case message := <-server.messages:
|
||||
require.Contains(t, message, "<p>123456</p>")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP DATA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailUsesNTLMWhenServerOnlySupportsNTLM(t *testing.T) {
|
||||
server := newFakeSMTPServer(t)
|
||||
server.authMechanisms = []string{"NTLM"}
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = true
|
||||
SMTPInsecureSkipVerify = true
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "no-reply"
|
||||
SMTPFrom = "no-reply@example.com"
|
||||
SMTPToken = "secret"
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case command := <-server.authCommands:
|
||||
require.True(t, strings.HasPrefix(command, "AUTH NTLM "), "unexpected auth command: %s", command)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP AUTH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailUsesNTLMForMicrosoftAccountWhenServerOnlySupportsNTLM(t *testing.T) {
|
||||
server := newFakeSMTPServer(t)
|
||||
server.authMechanisms = []string{"NTLM"}
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = true
|
||||
SMTPInsecureSkipVerify = true
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "no-reply@contoso.onmicrosoft.com"
|
||||
SMTPFrom = "no-reply@contoso.onmicrosoft.com"
|
||||
SMTPToken = "secret"
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case command := <-server.authCommands:
|
||||
require.True(t, strings.HasPrefix(command, "AUTH NTLM "), "unexpected auth command: %s", command)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for SMTP AUTH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailExplicitStartTLSRejectsUntrustedCertificateByDefault(t *testing.T) {
|
||||
server := newFakeSMTPServer(t)
|
||||
defer server.close()
|
||||
withSMTPSettings(t)
|
||||
|
||||
SMTPServer = server.host
|
||||
SMTPPort = server.port
|
||||
SMTPSSLEnabled = false
|
||||
SMTPStartTLSEnabled = true
|
||||
SMTPInsecureSkipVerify = false
|
||||
SMTPForceAuthLogin = false
|
||||
SMTPAccount = "sender@example.com"
|
||||
SMTPFrom = "sender@example.com"
|
||||
SMTPToken = "secret"
|
||||
SystemName = "New API"
|
||||
|
||||
err := SendEmail("Verification", "receiver@example.com", "<p>123456</p>")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, fmt.Sprint(err), "certificate")
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-contrib/static"
|
||||
)
|
||||
|
||||
// Credit: https://github.com/gin-contrib/static/issues/19
|
||||
|
||||
type embedFileSystem struct {
|
||||
http.FileSystem
|
||||
}
|
||||
|
||||
func (e *embedFileSystem) Exists(prefix string, path string) bool {
|
||||
_, err := e.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *embedFileSystem) Open(name string) (http.File, error) {
|
||||
if name == "/" {
|
||||
// This will make sure the index page goes to NoRouter handler,
|
||||
// which will use the replaced index bytes with analytic codes.
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return e.FileSystem.Open(name)
|
||||
}
|
||||
|
||||
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
|
||||
efs, err := fs.Sub(fsEmbed, targetPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &embedFileSystem{
|
||||
FileSystem: http.FS(efs),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package common
|
||||
|
||||
import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
// EndpointInfo 描述单个端点的默认请求信息
|
||||
// path: 上游路径
|
||||
// method: HTTP 请求方式,例如 POST/GET
|
||||
// 目前均为 POST,后续可扩展
|
||||
//
|
||||
// json 标签用于直接序列化到 API 输出
|
||||
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
||||
|
||||
type EndpointInfo struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
|
||||
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
||||
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIResponseCompact: {Path: "/v1/responses/compact", Method: "POST"},
|
||||
constant.EndpointTypeOpenAIAlphaSearch: {Path: "/v1/alpha/search", Method: "POST"},
|
||||
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"},
|
||||
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
|
||||
}
|
||||
|
||||
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||
info, ok := defaultEndpointInfoMap[et]
|
||||
return info, ok
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package common
|
||||
|
||||
import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
|
||||
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
|
||||
var endpointTypes []constant.EndpointType
|
||||
switch channelType {
|
||||
case constant.ChannelTypeJina:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank}
|
||||
//case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus:
|
||||
// endpointTypes = []constant.EndpointType{constant.EndpointTypeMidjourney}
|
||||
//case constant.ChannelTypeSunoAPI:
|
||||
// endpointTypes = []constant.EndpointType{constant.EndpointTypeSuno}
|
||||
//case constant.ChannelTypeKling:
|
||||
// endpointTypes = []constant.EndpointType{constant.EndpointTypeKling}
|
||||
//case constant.ChannelTypeJimeng:
|
||||
// endpointTypes = []constant.EndpointType{constant.EndpointTypeJimeng}
|
||||
case constant.ChannelTypeAws:
|
||||
fallthrough
|
||||
case constant.ChannelTypeAnthropic:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}
|
||||
case constant.ChannelTypeVertexAi:
|
||||
fallthrough
|
||||
case constant.ChannelTypeGemini:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
|
||||
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
||||
case constant.ChannelTypeXai:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse}
|
||||
case constant.ChannelTypeSora:
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
|
||||
case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI:
|
||||
endpointTypes = []constant.EndpointType{
|
||||
constant.EndpointTypeOpenAI,
|
||||
constant.EndpointTypeOpenAIResponse,
|
||||
constant.EndpointTypeOpenAIResponseCompact,
|
||||
constant.EndpointTypeAnthropic,
|
||||
constant.EndpointTypeGemini,
|
||||
constant.EndpointTypeOpenAIAlphaSearch,
|
||||
}
|
||||
case constant.ChannelTypeCodex:
|
||||
endpointTypes = []constant.EndpointType{
|
||||
constant.EndpointTypeOpenAIResponse,
|
||||
constant.EndpointTypeOpenAIResponseCompact,
|
||||
constant.EndpointTypeOpenAIAlphaSearch,
|
||||
}
|
||||
default:
|
||||
if IsOpenAIResponseOnlyModel(modelName) {
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
|
||||
} else {
|
||||
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
||||
}
|
||||
}
|
||||
if IsImageGenerationModel(modelName) {
|
||||
// add to first
|
||||
endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
|
||||
}
|
||||
return endpointTypes
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetEnvOrDefault(env string, defaultValue int) int {
|
||||
if env == "" || os.Getenv(env) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
num, err := strconv.Atoi(os.Getenv(env))
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
|
||||
return defaultValue
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func GetEnvOrDefaultString(env string, defaultValue string) string {
|
||||
if env == "" || os.Getenv(env) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return os.Getenv(env)
|
||||
}
|
||||
|
||||
func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
|
||||
if env == "" || os.Getenv(env) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
b, err := strconv.ParseBool(os.Getenv(env))
|
||||
if err != nil {
|
||||
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type digestAnchor struct{}
|
||||
|
||||
func modulePath() string {
|
||||
return reflect.TypeFor[digestAnchor]().PkgPath()
|
||||
}
|
||||
|
||||
var digestSeed = func() (s [sha256.Size]byte) {
|
||||
return sha256.Sum256([]byte(modulePath()))
|
||||
}()
|
||||
|
||||
// ETagFor returns a weak ETag derived from the namespace and content.
|
||||
func ETagFor(namespace, content string) string {
|
||||
buf := make([]byte, 0, sha256.Size+1+len(namespace)+1+len(content))
|
||||
buf = append(buf, digestSeed[:]...)
|
||||
buf = append(buf, 0)
|
||||
buf = append(buf, namespace...)
|
||||
buf = append(buf, 0)
|
||||
buf = append(buf, content...)
|
||||
digest := sha256.Sum256(buf)
|
||||
return `W/"` + hex.EncodeToString(digest[:]) + `"`
|
||||
}
|
||||
|
||||
// ETagMatches reports whether an If-None-Match header matches etag under weak
|
||||
// comparison (RFC 9110 §13.1.2): the W/ prefix is ignored on both sides, and
|
||||
// "*" matches everything.
|
||||
func ETagMatches(ifNoneMatch, etag string) bool {
|
||||
ifNoneMatch = strings.TrimSpace(ifNoneMatch)
|
||||
if ifNoneMatch == "" {
|
||||
return false
|
||||
}
|
||||
if ifNoneMatch == "*" {
|
||||
return true
|
||||
}
|
||||
etag = strings.TrimPrefix(etag, "W/")
|
||||
for candidate := range strings.SplitSeq(ifNoneMatch, ",") {
|
||||
if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const KeyRequestBody = "key_request_body"
|
||||
const KeyBodyStorage = "key_body_storage"
|
||||
|
||||
var ErrRequestBodyTooLarge = errors.New("request body too large")
|
||||
|
||||
func IsRequestBodyTooLargeError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, ErrRequestBodyTooLarge) {
|
||||
return true
|
||||
}
|
||||
var mbe *http.MaxBytesError
|
||||
return errors.As(err, &mbe)
|
||||
}
|
||||
|
||||
func GetRequestBody(c *gin.Context) (io.Seeker, error) {
|
||||
// 首先检查是否有 BodyStorage 缓存
|
||||
if storage, exists := c.Get(KeyBodyStorage); exists && storage != nil {
|
||||
if bs, ok := storage.(BodyStorage); ok {
|
||||
if _, err := bs.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, fmt.Errorf("failed to seek body storage: %w", err)
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 检查旧的缓存方式
|
||||
cached, exists := c.Get(KeyRequestBody)
|
||||
if exists && cached != nil {
|
||||
if b, ok := cached.([]byte); ok {
|
||||
bs, err := CreateBodyStorage(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Set(KeyBodyStorage, bs)
|
||||
return bs, nil
|
||||
}
|
||||
}
|
||||
|
||||
maxMB := constant.MaxRequestBodyMB
|
||||
if maxMB <= 0 {
|
||||
maxMB = 128 // 默认 128MB
|
||||
}
|
||||
maxBytes := int64(maxMB) << 20
|
||||
|
||||
contentLength := c.Request.ContentLength
|
||||
|
||||
// 使用新的存储系统
|
||||
storage, err := CreateBodyStorageFromReader(c.Request.Body, contentLength, maxBytes)
|
||||
_ = c.Request.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
if IsRequestBodyTooLargeError(err) {
|
||||
return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 缓存存储对象
|
||||
c.Set(KeyBodyStorage, storage)
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// GetBodyStorage 获取请求体存储对象(用于需要多次读取的场景)
|
||||
func GetBodyStorage(c *gin.Context) (BodyStorage, error) {
|
||||
seeker, err := GetRequestBody(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bs, ok := seeker.(BodyStorage)
|
||||
if !ok {
|
||||
return nil, errors.New("unexpected body storage type")
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// CleanupBodyStorage 清理请求体存储(应在请求结束时调用)
|
||||
func CleanupBodyStorage(c *gin.Context) {
|
||||
if storage, exists := c.Get(KeyBodyStorage); exists && storage != nil {
|
||||
if bs, ok := storage.(BodyStorage); ok {
|
||||
bs.Close()
|
||||
}
|
||||
c.Set(KeyBodyStorage, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
storage, err := GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
// disk-backed JSON: stream-decode directly from the file to avoid
|
||||
// materializing the entire payload back into a transient []byte
|
||||
// (diskStorage.Bytes() would ReadFull the whole file into the heap).
|
||||
if storage.IsDisk() && strings.HasPrefix(contentType, "application/json") {
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
if err := DecodeJson(storage, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
return nil
|
||||
}
|
||||
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
|
||||
err = parseFormData(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) {
|
||||
err = parseMultipartFormData(c, requestBody, v)
|
||||
} else {
|
||||
// skip for now
|
||||
// TODO: someday non json request have variant model, we will need to implementation this
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Reset request body
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetContextKey(c *gin.Context, key constant.ContextKey, value any) {
|
||||
c.Set(string(key), value)
|
||||
}
|
||||
|
||||
func GetContextKey(c *gin.Context, key constant.ContextKey) (any, bool) {
|
||||
return c.Get(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyString(c *gin.Context, key constant.ContextKey) string {
|
||||
return c.GetString(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int {
|
||||
return c.GetInt(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyBool(c *gin.Context, key constant.ContextKey) bool {
|
||||
return c.GetBool(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyStringSlice(c *gin.Context, key constant.ContextKey) []string {
|
||||
return c.GetStringSlice(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyStringMap(c *gin.Context, key constant.ContextKey) map[string]any {
|
||||
return c.GetStringMap(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyTime(c *gin.Context, key constant.ContextKey) time.Time {
|
||||
return c.GetTime(string(key))
|
||||
}
|
||||
|
||||
func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool) {
|
||||
if value, ok := c.Get(string(key)); ok {
|
||||
if v, ok := value.(T); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var t T
|
||||
return t, false
|
||||
}
|
||||
|
||||
func ApiError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
func ApiErrorMsg(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
func ApiSuccess(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// ApiErrorI18n returns a translated error message based on the user's language preference
|
||||
// key is the i18n message key, args is optional template data
|
||||
func ApiErrorI18n(c *gin.Context, key string, args ...map[string]any) {
|
||||
msg := TranslateMessage(c, key, args...)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// ApiSuccessI18n returns a translated success message based on the user's language preference
|
||||
func ApiSuccessI18n(c *gin.Context, key string, data any, args ...map[string]any) {
|
||||
msg := TranslateMessage(c, key, args...)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// TranslateMessage is a helper function that calls i18n.T
|
||||
// This function is defined here to avoid circular imports
|
||||
// The actual implementation will be set during init
|
||||
var TranslateMessage func(c *gin.Context, key string, args ...map[string]any) string
|
||||
|
||||
func init() {
|
||||
// Default implementation that returns the key as-is
|
||||
// This will be replaced by i18n.T during i18n initialization
|
||||
TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string {
|
||||
c.Header("X-Translate-id", "d5e7afdfc7f03414b941f9c1e7096be9966510e7")
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
|
||||
storage, err := GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use the original Content-Type saved on first call to avoid boundary
|
||||
// mismatch when callers overwrite c.Request.Header after multipart rebuild.
|
||||
var contentType string
|
||||
if saved, ok := c.Get("_original_multipart_ct"); ok {
|
||||
contentType = saved.(string)
|
||||
} else {
|
||||
contentType = c.Request.Header.Get("Content-Type")
|
||||
c.Set("_original_multipart_ct", contentType)
|
||||
}
|
||||
boundary, err := parseBoundary(contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
|
||||
form, err := reader.ReadForm(multipartMemoryLimit())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset request body
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return nil, seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func processFormMap(formMap map[string]any, v any) error {
|
||||
jsonData, err := Marshal(formMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = Unmarshal(jsonData, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFormData(data []byte, v any) error {
|
||||
values, err := url.ParseQuery(string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formMap := make(map[string]any)
|
||||
for key, vals := range values {
|
||||
if len(vals) == 1 {
|
||||
formMap[key] = vals[0]
|
||||
} else {
|
||||
formMap[key] = vals
|
||||
}
|
||||
}
|
||||
|
||||
return processFormMap(formMap, v)
|
||||
}
|
||||
|
||||
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
|
||||
var contentType string
|
||||
if saved, ok := c.Get("_original_multipart_ct"); ok {
|
||||
contentType = saved.(string)
|
||||
} else {
|
||||
contentType = c.Request.Header.Get("Content-Type")
|
||||
c.Set("_original_multipart_ct", contentType)
|
||||
}
|
||||
boundary, err := parseBoundary(contentType)
|
||||
if err != nil {
|
||||
if errors.Is(err, errBoundaryNotFound) {
|
||||
return Unmarshal(data, v) // Fallback to JSON
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(data), boundary)
|
||||
form, err := reader.ReadForm(multipartMemoryLimit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
formMap := make(map[string]any)
|
||||
for key, vals := range form.Value {
|
||||
if len(vals) == 1 {
|
||||
formMap[key] = vals[0]
|
||||
} else {
|
||||
formMap[key] = vals
|
||||
}
|
||||
}
|
||||
|
||||
return processFormMap(formMap, v)
|
||||
}
|
||||
|
||||
var errBoundaryNotFound = errors.New("multipart boundary not found")
|
||||
|
||||
// parseBoundary extracts the multipart boundary from the Content-Type header using mime.ParseMediaType
|
||||
func parseBoundary(contentType string) (string, error) {
|
||||
if contentType == "" {
|
||||
return "", errBoundaryNotFound
|
||||
}
|
||||
// Boundary-UUID / boundary-------xxxxxx
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
boundary, ok := params["boundary"]
|
||||
if !ok || boundary == "" {
|
||||
return "", errBoundaryNotFound
|
||||
}
|
||||
return boundary, nil
|
||||
}
|
||||
|
||||
// multipartMemoryLimit returns the configured multipart memory limit in bytes
|
||||
func multipartMemoryLimit() int64 {
|
||||
limitMB := constant.MaxFileDownloadMB
|
||||
if limitMB <= 0 {
|
||||
limitMB = 32
|
||||
}
|
||||
return int64(limitMB) << 20
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func SafeSendBool(ch chan bool, value bool) (closed bool) {
|
||||
defer func() {
|
||||
// Recover from panic if one occured. A panic would mean the channel was closed.
|
||||
if recover() != nil {
|
||||
closed = true
|
||||
}
|
||||
}()
|
||||
|
||||
// This will panic if the channel is closed.
|
||||
ch <- value
|
||||
|
||||
// If the code reaches here, then the channel was not closed.
|
||||
return false
|
||||
}
|
||||
|
||||
func SafeSendString(ch chan string, value string) (closed bool) {
|
||||
defer func() {
|
||||
// Recover from panic if one occured. A panic would mean the channel was closed.
|
||||
if recover() != nil {
|
||||
closed = true
|
||||
}
|
||||
}()
|
||||
|
||||
// This will panic if the channel is closed.
|
||||
ch <- value
|
||||
|
||||
// If the code reaches here, then the channel was not closed.
|
||||
return false
|
||||
}
|
||||
|
||||
// SafeSendStringTimeout send, return true, else return false
|
||||
func SafeSendStringTimeout(ch chan string, value string, timeout int) (closed bool) {
|
||||
defer func() {
|
||||
// Recover from panic if one occured. A panic would mean the channel was closed.
|
||||
if recover() != nil {
|
||||
closed = false
|
||||
}
|
||||
}()
|
||||
|
||||
// This will panic if the channel is closed.
|
||||
select {
|
||||
case ch <- value:
|
||||
return true
|
||||
case <-time.After(time.Duration(timeout) * time.Second):
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
var relayGoPool gopool.Pool
|
||||
|
||||
func init() {
|
||||
relayGoPool = gopool.NewPool("gopool.RelayPool", math.MaxInt32, gopool.NewConfig())
|
||||
relayGoPool.SetPanicHandler(func(ctx context.Context, i any) {
|
||||
if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok {
|
||||
SafeSendBool(stopChan, true)
|
||||
}
|
||||
SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i))
|
||||
})
|
||||
}
|
||||
|
||||
func RelayCtxGo(ctx context.Context, f func()) {
|
||||
relayGoPool.CtxGo(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func Sha256Raw(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func Sha1Raw(data []byte) []byte {
|
||||
h := sha1.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func Sha1(data []byte) string {
|
||||
return hex.EncodeToString(Sha1Raw(data))
|
||||
}
|
||||
|
||||
func HmacSha256Raw(message, key []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func HmacSha256(message, key string) string {
|
||||
return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key)))
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
var (
|
||||
Port = flag.Int("port", 3000, "the listening port")
|
||||
PrintVersion = flag.Bool("version", false, "print version and exit")
|
||||
PrintHelp = flag.Bool("help", false, "print help and exit")
|
||||
LogDir = flag.String("log-dir", "./logs", "specify the log directory")
|
||||
)
|
||||
|
||||
func printHelp() {
|
||||
fmt.Println("NewAPI(Based OneAPI) " + Version + " - The next-generation LLM gateway and AI asset management system supports multiple languages.")
|
||||
fmt.Println("Original Project: OneAPI by JustSong - https://github.com/songquanpeng/one-api")
|
||||
fmt.Println("Maintainer: QuantumNous - https://github.com/QuantumNous/new-api")
|
||||
fmt.Println("Usage: newapi [--port <port>] [--log-dir <log directory>] [--version] [--help]")
|
||||
}
|
||||
|
||||
func InitEnv() {
|
||||
flag.Parse()
|
||||
|
||||
envVersion := os.Getenv("VERSION")
|
||||
if envVersion != "" {
|
||||
Version = envVersion
|
||||
}
|
||||
|
||||
if *PrintVersion {
|
||||
fmt.Println(Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *PrintHelp {
|
||||
printHelp()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if os.Getenv("SESSION_SECRET") != "" {
|
||||
ss := os.Getenv("SESSION_SECRET")
|
||||
if ss == "random_string" {
|
||||
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
||||
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
||||
log.Fatal("Please set SESSION_SECRET to a random string.")
|
||||
} else {
|
||||
SessionSecret = ss
|
||||
}
|
||||
}
|
||||
if os.Getenv("CRYPTO_SECRET") != "" {
|
||||
CryptoSecret = os.Getenv("CRYPTO_SECRET")
|
||||
} else {
|
||||
CryptoSecret = SessionSecret
|
||||
}
|
||||
if err := InitSessionCookieSettings(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
initUserSessionSettings()
|
||||
if os.Getenv("SQLITE_PATH") != "" {
|
||||
SQLitePath = os.Getenv("SQLITE_PATH")
|
||||
}
|
||||
if *LogDir != "" {
|
||||
var err error
|
||||
*LogDir, err = filepath.Abs(*LogDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
|
||||
err = os.Mkdir(*LogDir, 0777)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize variables from constants.go that were using environment variables
|
||||
DebugEnabled = os.Getenv("DEBUG") == "true"
|
||||
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
|
||||
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
|
||||
PasswordLoginEncryptionEnabled = GetEnvOrDefaultBool("PASSWORD_LOGIN_ENCRYPTION_ENABLED", false)
|
||||
initNodeNameIdentity()
|
||||
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
|
||||
if TLSInsecureSkipVerify {
|
||||
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
|
||||
if tr.TLSClientConfig != nil {
|
||||
tr.TLSClientConfig.InsecureSkipVerify = true
|
||||
} else {
|
||||
tr.TLSClientConfig = InsecureTLSConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
SMTPStartTLSEnabled = GetEnvOrDefaultBool("SMTP_STARTTLS_ENABLE", GetEnvOrDefaultBool("SMTP_STARTTLS_ENABLED", false))
|
||||
SMTPInsecureSkipVerify = GetEnvOrDefaultBool("SMTP_INSECURE_SKIP_VERIFY", GetEnvOrDefaultBool("SMTP_TLS_INSECURE_SKIP_VERIFY", false))
|
||||
|
||||
// Parse requestInterval and set RequestInterval
|
||||
requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))
|
||||
RequestInterval = time.Duration(requestInterval) * time.Second
|
||||
|
||||
// Initialize variables with GetEnvOrDefault
|
||||
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
|
||||
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
|
||||
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
|
||||
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
|
||||
RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 1800)
|
||||
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
|
||||
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
|
||||
|
||||
// Initialize string variables with GetEnvOrDefaultString
|
||||
GeminiSafetySetting = GetEnvOrDefaultString("GEMINI_SAFETY_SETTING", "BLOCK_NONE")
|
||||
CohereSafetySetting = GetEnvOrDefaultString("COHERE_SAFETY_SETTING", "NONE")
|
||||
|
||||
// Initialize rate limit variables
|
||||
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 360)
|
||||
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
|
||||
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
|
||||
|
||||
SearchRateLimitEnable = GetEnvOrDefaultBool("SEARCH_RATE_LIMIT_ENABLE", true)
|
||||
SearchRateLimitNum = GetEnvOrDefault("SEARCH_RATE_LIMIT", 10)
|
||||
SearchRateLimitDuration = int64(GetEnvOrDefault("SEARCH_RATE_LIMIT_DURATION", 60))
|
||||
initConstantEnv()
|
||||
}
|
||||
|
||||
func initUserSessionSettings() {
|
||||
UserSessionActiveLimit = positiveUserSessionEnv("USER_SESSION_ACTIVE_LIMIT", DefaultUserSessionActiveLimit)
|
||||
UserSessionIssuanceLimit = positiveUserSessionEnv("USER_SESSION_ISSUANCE_LIMIT", DefaultUserSessionIssuanceLimit)
|
||||
UserSessionIssuanceWindowSeconds = int64(positiveUserSessionEnv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", DefaultUserSessionIssuanceWindowSeconds))
|
||||
UserSessionRevokedRetentionDays = positiveUserSessionEnv("USER_SESSION_REVOKED_RETENTION_DAYS", DefaultUserSessionRevokedRetentionDays)
|
||||
UserSessionHourlyAlertThreshold = positiveUserSessionEnv("USER_SESSION_HOURLY_ALERT_THRESHOLD", DefaultUserSessionHourlyAlertThreshold)
|
||||
|
||||
const secondsPerDay = 24 * 60 * 60
|
||||
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
|
||||
SysError(fmt.Sprintf(
|
||||
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
|
||||
DefaultUserSessionRevokedRetentionDays,
|
||||
))
|
||||
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
|
||||
}
|
||||
retentionSeconds := int64(UserSessionRevokedRetentionDays) * secondsPerDay
|
||||
if UserSessionIssuanceWindowSeconds > retentionSeconds {
|
||||
configuredWindow := UserSessionIssuanceWindowSeconds
|
||||
UserSessionIssuanceWindowSeconds = retentionSeconds
|
||||
SysError(fmt.Sprintf(
|
||||
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
|
||||
configuredWindow,
|
||||
retentionSeconds,
|
||||
UserSessionIssuanceWindowSeconds,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func positiveUserSessionEnv(name string, fallback int) int {
|
||||
value := GetEnvOrDefault(name, fallback)
|
||||
if value <= 0 {
|
||||
SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback))
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func initConstantEnv() {
|
||||
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
|
||||
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
||||
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
|
||||
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
|
||||
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
|
||||
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
|
||||
constant.AnonymousRequestBodyLimitKB = GetEnvOrDefault("ANONYMOUS_REQUEST_BODY_LIMIT_KB", 512)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
||||
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
||||
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
|
||||
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
|
||||
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
|
||||
constant.TaskPluginEnabled = GetEnvOrDefaultBool("TASK_PLUGIN_ENABLED", true)
|
||||
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
|
||||
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
|
||||
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
|
||||
// GenerateDefaultToken 是否生成初始令牌,默认关闭。
|
||||
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
|
||||
// 是否启用错误日志
|
||||
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
|
||||
// 任务轮询时查询的最大数量
|
||||
constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
|
||||
// 异步任务超时时间(分钟),超过此时间未完成的任务将被标记为失败并退款。0 表示禁用。
|
||||
constant.TaskTimeoutMinutes = GetEnvOrDefault("TASK_TIMEOUT_MINUTES", 1440)
|
||||
// Consecutive unrecognized/transient poll failures before the task is failed and refunded.
|
||||
constant.TaskPollMaxFailures = GetEnvOrDefault("TASK_POLL_MAX_FAILURES", 20)
|
||||
// 声明式任务协议桥只观察数据库;这些值控制一次客户端观察连接,
|
||||
// 不改变后台轮询或结算生命周期。
|
||||
constant.TaskPluginProtocolTimeoutSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TIMEOUT_SECONDS", 600)
|
||||
constant.TaskPluginProtocolTickMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_MILLISECONDS", 2000)
|
||||
constant.TaskPluginProtocolTickJitterMilliseconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_TICK_JITTER_MILLISECONDS", 500)
|
||||
constant.TaskPluginProtocolHeartbeatSeconds = GetEnvOrDefault("TASK_PLUGIN_PROTOCOL_HEARTBEAT_SECONDS", 15)
|
||||
|
||||
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
|
||||
if soraPatchStr != "" {
|
||||
var taskPricePatches []string
|
||||
soraPatches := strings.SplitSeq(soraPatchStr, ",")
|
||||
for patch := range soraPatches {
|
||||
trimmedPatch := strings.TrimSpace(patch)
|
||||
if trimmedPatch != "" {
|
||||
taskPricePatches = append(taskPricePatches, trimmedPatch)
|
||||
}
|
||||
}
|
||||
constant.TaskPricePatches = taskPricePatches
|
||||
}
|
||||
|
||||
// Initialize trusted redirect domains for URL validation
|
||||
trustedDomainsStr := GetEnvOrDefaultString("TRUSTED_REDIRECT_DOMAINS", "")
|
||||
var trustedDomains []string
|
||||
domains := strings.SplitSeq(trustedDomainsStr, ",")
|
||||
for domain := range domains {
|
||||
trimmedDomain := strings.TrimSpace(domain)
|
||||
if trimmedDomain != "" {
|
||||
// Normalize domain to lowercase
|
||||
trustedDomains = append(trustedDomains, strings.ToLower(trimmedDomain))
|
||||
}
|
||||
}
|
||||
constant.TrustedRedirectDomains = trustedDomains
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package common
|
||||
|
||||
import "net"
|
||||
|
||||
func IsIP(s string) bool {
|
||||
ip := net.ParseIP(s)
|
||||
return ip != nil
|
||||
}
|
||||
|
||||
func ParseIP(s string) net.IP {
|
||||
return net.ParseIP(s)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func IsIpInCIDRList(ip net.IP, cidrList []string) bool {
|
||||
for _, cidr := range cidrList {
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
// 尝试作为单个IP处理
|
||||
if whitelistIP := net.ParseIP(cidr); whitelistIP != nil {
|
||||
if ip.Equal(whitelistIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
// hostJSONCodec is the single place where the host chooses its JSON engine.
|
||||
// Swap the implementation here (for example to sonic.ConfigStd) and every
|
||||
// common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling,
|
||||
// follows. Injected from init() rather than main() so tests run on the same
|
||||
// engine as production: common is imported by virtually every root package
|
||||
// and test binary, while main() never executes under `go test`.
|
||||
type hostJSONCodec struct{}
|
||||
|
||||
func (hostJSONCodec) Marshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (hostJSONCodec) Unmarshal(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (hostJSONCodec) Decode(r io.Reader, v any) error {
|
||||
return json.NewDecoder(r).Decode(v)
|
||||
}
|
||||
|
||||
func (hostJSONCodec) Valid(data []byte) bool {
|
||||
return json.Valid(data)
|
||||
}
|
||||
|
||||
func init() {
|
||||
kitutil.SetCodec(hostJSONCodec{})
|
||||
}
|
||||
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
return kitutil.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func UnmarshalJsonStr(data string, v any) error {
|
||||
return kitutil.UnmarshalJsonStr(data, v)
|
||||
}
|
||||
|
||||
func DecodeJson(reader io.Reader, v any) error {
|
||||
return kitutil.DecodeJson(reader, v)
|
||||
}
|
||||
|
||||
// DecodeJsonWithValidation decodes JSON and applies Gin's configured binding-tag
|
||||
// validator, including binding:"required" and any registered custom validators.
|
||||
func DecodeJsonWithValidation(reader io.Reader, v any) error {
|
||||
if err := DecodeJson(reader, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if binding.Validator == nil {
|
||||
return nil
|
||||
}
|
||||
return binding.Validator.ValidateStruct(v)
|
||||
}
|
||||
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
return kitutil.Marshal(v)
|
||||
}
|
||||
|
||||
func IndentJson(data []byte) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
if err := json.Indent(&buffer, data, "", " "); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func GetJsonType(data json.RawMessage) string {
|
||||
return kitutil.GetJsonType(data)
|
||||
}
|
||||
|
||||
// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
|
||||
func JsonRawMessageToString(data json.RawMessage) string {
|
||||
return kitutil.JsonRawMessageToString(data)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestJsonRawMessageToString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data json.RawMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "object",
|
||||
data: json.RawMessage(`{"city":"Paris","days":0,"strict":false}`),
|
||||
want: `{"city":"Paris","days":0,"strict":false}`,
|
||||
},
|
||||
{
|
||||
name: "string",
|
||||
data: json.RawMessage(`"{\"city\":\"Paris\",\"days\":0,\"strict\":false}"`),
|
||||
want: `{"city":"Paris","days":0,"strict":false}`,
|
||||
},
|
||||
{
|
||||
name: "null",
|
||||
data: json.RawMessage(`null`),
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
data: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, JsonRawMessageToString(tt.data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJsonWithValidation(t *testing.T) {
|
||||
type request struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, body string
|
||||
validationError bool
|
||||
decodeError bool
|
||||
}{
|
||||
{name: "valid", body: `{"code":"123456"}`},
|
||||
{name: "missing required field", body: `{}`, validationError: true},
|
||||
{name: "empty required field", body: `{"code":""}`, validationError: true},
|
||||
{name: "malformed JSON", body: `{"code":`, decodeError: true},
|
||||
{name: "wrong field type", body: `{"code":123456}`, decodeError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var value request
|
||||
err := DecodeJsonWithValidation(strings.NewReader(test.body), &value)
|
||||
if test.validationError {
|
||||
var validationErrors validator.ValidationErrors
|
||||
require.ErrorAs(t, err, &validationErrors)
|
||||
require.Len(t, validationErrors, 1)
|
||||
assert.Equal(t, "Code", validationErrors[0].Field())
|
||||
assert.Equal(t, "required", validationErrors[0].Tag())
|
||||
return
|
||||
}
|
||||
if test.decodeError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "123456", value.Code)
|
||||
})
|
||||
}
|
||||
// Existing decoding callers opt into validation explicitly.
|
||||
var unvalidated request
|
||||
require.NoError(t, DecodeJson(strings.NewReader(`{}`), &unvalidated))
|
||||
}
|
||||
|
||||
// TestHostJSONCodecConformance runs through the codec injected by common's
|
||||
// init() and locks the encoding semantics the relay DTOs depend on. A future
|
||||
// engine swap in hostJSONCodec must keep every case here green.
|
||||
func TestHostJSONCodecConformance(t *testing.T) {
|
||||
type embedded struct {
|
||||
Content any `json:"content"`
|
||||
}
|
||||
type shadowed struct {
|
||||
embedded
|
||||
Content any `json:"content,omitempty"`
|
||||
}
|
||||
type anyFields struct {
|
||||
Nil any `json:"nil,omitempty"`
|
||||
Str any `json:"str,omitempty"`
|
||||
Int any `json:"int,omitempty"`
|
||||
Bool any `json:"bool,omitempty"`
|
||||
}
|
||||
type rawFields struct {
|
||||
Obj json.RawMessage `json:"obj"`
|
||||
Arr json.RawMessage `json:"arr"`
|
||||
Nested json.RawMessage `json:"nested"`
|
||||
Str json.RawMessage `json:"str"`
|
||||
}
|
||||
type numberField struct {
|
||||
N json.Number `json:"n"`
|
||||
}
|
||||
type pointerZeros struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
zero := 0
|
||||
off := false
|
||||
|
||||
t.Run("marshal", func(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "shallowest field shadows embedded content and nil is omitted",
|
||||
in: shadowed{embedded: embedded{Content: "inner"}},
|
||||
want: `{}`,
|
||||
},
|
||||
{
|
||||
name: "shallowest field keeps empty string content",
|
||||
in: shadowed{embedded: embedded{Content: "inner"}, Content: ""},
|
||||
want: `{"content":""}`,
|
||||
},
|
||||
{
|
||||
name: "omitempty on any drops nil but keeps zero values",
|
||||
in: anyFields{Str: "", Int: 0, Bool: false},
|
||||
want: `{"str":"","int":0,"bool":false}`,
|
||||
},
|
||||
{
|
||||
name: "map keys are sorted",
|
||||
in: map[string]any{"z": 1, "a": 2, "m": 3},
|
||||
want: `{"a":2,"m":3,"z":1}`,
|
||||
},
|
||||
{
|
||||
name: "html characters are escaped",
|
||||
in: map[string]string{"s": `<a href="x">&</a>`},
|
||||
want: `{"s":"\u003ca href=\"x\"\u003e\u0026\u003c/a\u003e"}`,
|
||||
},
|
||||
{
|
||||
name: "explicit pointer zeros are kept",
|
||||
in: pointerZeros{Count: &zero, Enabled: &off},
|
||||
want: `{"count":0,"enabled":false}`,
|
||||
},
|
||||
{
|
||||
name: "nil pointers are omitted",
|
||||
in: pointerZeros{},
|
||||
want: `{}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encoded, err := Marshal(tt.in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, string(encoded))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw message passthrough", func(t *testing.T) {
|
||||
input := `{"obj":{},"arr":[],"nested":{"k":[1,2]},"str":"x"}`
|
||||
var value rawFields
|
||||
require.NoError(t, UnmarshalJsonStr(input, &value))
|
||||
assert.Equal(t, `{}`, string(value.Obj))
|
||||
assert.Equal(t, `[]`, string(value.Arr))
|
||||
assert.Equal(t, `{"k":[1,2]}`, string(value.Nested))
|
||||
assert.Equal(t, `"x"`, string(value.Str))
|
||||
encoded, err := Marshal(value)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, input, string(encoded))
|
||||
})
|
||||
|
||||
t.Run("json.Number keeps large integers exact", func(t *testing.T) {
|
||||
input := `{"n":18446744073686646784}`
|
||||
var value numberField
|
||||
require.NoError(t, Unmarshal([]byte(input), &value))
|
||||
assert.Equal(t, json.Number("18446744073686646784"), value.N)
|
||||
encoded, err := Marshal(value)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, input, string(encoded))
|
||||
})
|
||||
|
||||
t.Run("explicit zeros survive unmarshal into pointers", func(t *testing.T) {
|
||||
var value pointerZeros
|
||||
require.NoError(t, Unmarshal([]byte(`{"count":0,"enabled":false}`), &value))
|
||||
require.NotNil(t, value.Count)
|
||||
require.NotNil(t, value.Enabled)
|
||||
assert.Equal(t, 0, *value.Count)
|
||||
assert.False(t, *value.Enabled)
|
||||
|
||||
var absent pointerZeros
|
||||
require.NoError(t, Unmarshal([]byte(`{}`), &absent))
|
||||
assert.Nil(t, absent.Count)
|
||||
assert.Nil(t, absent.Enabled)
|
||||
})
|
||||
|
||||
t.Run("relaykit DTO round trip", func(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"model":"kimi-k3",
|
||||
"messages":[
|
||||
{"role":"system","tools":[{"type":"function","function":{"name":"get_current_time","description":"Get the current time of a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]},
|
||||
{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_current_time","arguments":"{\"city\":\"Beijing\"}"}}]}
|
||||
]
|
||||
}`)
|
||||
var req dto.GeneralOpenAIRequest
|
||||
require.NoError(t, Unmarshal(raw, &req))
|
||||
encoded, err := Marshal(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
messages := gjson.GetBytes(encoded, "messages").Array()
|
||||
require.Len(t, messages, 2)
|
||||
|
||||
// Kimi K3 dynamic tool loading: tools survive and no content key is emitted.
|
||||
assert.Equal(t, "system", messages[0].Get("role").String())
|
||||
assert.JSONEq(t, gjson.GetBytes(raw, "messages.0.tools").Raw, messages[0].Get("tools").Raw)
|
||||
assert.False(t, messages[0].Get("content").Exists())
|
||||
|
||||
// Assistant tool-call replay still carries an explicit "content": null.
|
||||
assistantContent := messages[1].Get("content")
|
||||
assert.True(t, assistantContent.Exists())
|
||||
assert.Equal(t, gjson.Null, assistantContent.Type)
|
||||
assert.JSONEq(t, gjson.GetBytes(raw, "messages.1.tool_calls").Raw, messages[1].Get("tool_calls").Raw)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
//go:embed lua/rate_limit.lua
|
||||
var rateLimitScript string
|
||||
|
||||
type RedisLimiter struct {
|
||||
client *redis.Client
|
||||
limitScriptSHA string
|
||||
}
|
||||
|
||||
var (
|
||||
instance *RedisLimiter
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func New(ctx context.Context, r *redis.Client) *RedisLimiter {
|
||||
once.Do(func() {
|
||||
// 预加载脚本
|
||||
limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result()
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err))
|
||||
}
|
||||
instance = &RedisLimiter{
|
||||
client: r,
|
||||
limitScriptSHA: limitSHA,
|
||||
}
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func (rl *RedisLimiter) Allow(ctx context.Context, key string, opts ...Option) (bool, error) {
|
||||
// 默认配置
|
||||
config := &Config{
|
||||
Capacity: 10,
|
||||
Rate: 1,
|
||||
Requested: 1,
|
||||
}
|
||||
|
||||
// 应用选项模式
|
||||
for _, opt := range opts {
|
||||
opt(config)
|
||||
}
|
||||
|
||||
// 执行限流
|
||||
result, err := rl.client.EvalSha(
|
||||
ctx,
|
||||
rl.limitScriptSHA,
|
||||
[]string{key},
|
||||
config.Requested,
|
||||
config.Rate,
|
||||
config.Capacity,
|
||||
).Int()
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("rate limit failed: %w", err)
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
// Config 配置选项模式
|
||||
type Config struct {
|
||||
Capacity int64
|
||||
Rate int64
|
||||
Requested int64
|
||||
}
|
||||
|
||||
type Option func(*Config)
|
||||
|
||||
func WithCapacity(c int64) Option {
|
||||
return func(cfg *Config) { cfg.Capacity = c }
|
||||
}
|
||||
|
||||
func WithRate(r int64) Option {
|
||||
return func(cfg *Config) { cfg.Rate = r }
|
||||
}
|
||||
|
||||
func WithRequested(n int64) Option {
|
||||
return func(cfg *Config) { cfg.Requested = n }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 令牌桶限流器
|
||||
-- KEYS[1]: 限流器唯一标识
|
||||
-- ARGV[1]: 请求令牌数 (通常为1)
|
||||
-- ARGV[2]: 令牌生成速率 (每秒)
|
||||
-- ARGV[3]: 桶容量
|
||||
|
||||
local key = KEYS[1]
|
||||
local requested = tonumber(ARGV[1])
|
||||
local rate = tonumber(ARGV[2])
|
||||
local capacity = tonumber(ARGV[3])
|
||||
|
||||
-- 获取当前时间(Redis服务器时间)
|
||||
local now = redis.call('TIME')
|
||||
local nowInSeconds = tonumber(now[1])
|
||||
|
||||
-- 获取桶状态
|
||||
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
|
||||
local tokens = tonumber(bucket[1])
|
||||
local last_time = tonumber(bucket[2])
|
||||
|
||||
-- 初始化桶(首次请求或过期)
|
||||
if not tokens or not last_time then
|
||||
tokens = capacity
|
||||
last_time = nowInSeconds
|
||||
else
|
||||
-- 计算新增令牌
|
||||
local elapsed = nowInSeconds - last_time
|
||||
local add_tokens = elapsed * rate
|
||||
tokens = math.min(capacity, tokens + add_tokens)
|
||||
last_time = nowInSeconds
|
||||
end
|
||||
|
||||
-- 判断是否允许请求
|
||||
local allowed = false
|
||||
if tokens >= requested then
|
||||
tokens = tokens - requested
|
||||
allowed = true
|
||||
end
|
||||
|
||||
---- 更新桶状态并设置过期时间
|
||||
redis.call('HMSET', key, 'tokens', tokens, 'last_time', last_time)
|
||||
--redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) -- 适当延长过期时间
|
||||
|
||||
return allowed and 1 or 0
|
||||
@@ -0,0 +1,59 @@
|
||||
package common
|
||||
|
||||
import "strings"
|
||||
|
||||
var (
|
||||
// OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses.
|
||||
OpenAIResponseOnlyModels = []string{
|
||||
"o3-pro",
|
||||
"o3-deep-research",
|
||||
"o4-mini-deep-research",
|
||||
}
|
||||
ImageGenerationModels = []string{
|
||||
"dall-e-3",
|
||||
"dall-e-2",
|
||||
"gpt-image-1",
|
||||
"prefix:imagen-",
|
||||
"flux-",
|
||||
"flux.1-",
|
||||
}
|
||||
OpenAITextModels = []string{
|
||||
"gpt-",
|
||||
"o1",
|
||||
"o3",
|
||||
"o4",
|
||||
"chatgpt",
|
||||
}
|
||||
)
|
||||
|
||||
func IsOpenAIResponseOnlyModel(modelName string) bool {
|
||||
for _, m := range OpenAIResponseOnlyModels {
|
||||
if strings.Contains(modelName, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsImageGenerationModel(modelName string) bool {
|
||||
modelName = strings.ToLower(modelName)
|
||||
for _, m := range ImageGenerationModels {
|
||||
if strings.Contains(modelName, m) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsOpenAITextModel(modelName string) bool {
|
||||
modelName = strings.ToLower(modelName)
|
||||
for _, m := range OpenAITextModels {
|
||||
if strings.Contains(modelName, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import "os"
|
||||
|
||||
type NodeIdentity struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
ManuallyConfigured bool `json:"manually_configured"`
|
||||
ShouldConfigureManually bool `json:"should_configure_manually"`
|
||||
}
|
||||
|
||||
func initNodeNameIdentity() {
|
||||
if envNodeName := os.Getenv("NODE_NAME"); envNodeName != "" {
|
||||
NodeName = envNodeName
|
||||
NodeNameSource = NodeNameSourceManual
|
||||
NodeNameManuallyConfigured = true
|
||||
return
|
||||
}
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
NodeName = hostname
|
||||
NodeNameSource = NodeNameSourceHostname
|
||||
NodeNameManuallyConfigured = false
|
||||
}
|
||||
|
||||
func GetNodeIdentity() NodeIdentity {
|
||||
return NodeIdentity{
|
||||
Name: NodeName,
|
||||
Source: NodeNameSource,
|
||||
ManuallyConfigured: NodeNameManuallyConfigured,
|
||||
ShouldConfigureManually: !NodeNameManuallyConfigured,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PageInfo struct {
|
||||
Page int `json:"page"` // page num 页码
|
||||
PageSize int `json:"page_size"` // page size 页大小
|
||||
|
||||
Total int `json:"total"` // 总条数,后设置
|
||||
Items any `json:"items"` // 数据,后设置
|
||||
}
|
||||
|
||||
func (p *PageInfo) GetStartIdx() int {
|
||||
return (p.Page - 1) * p.PageSize
|
||||
}
|
||||
|
||||
func (p *PageInfo) GetEndIdx() int {
|
||||
return p.Page * p.PageSize
|
||||
}
|
||||
|
||||
func (p *PageInfo) GetPageSize() int {
|
||||
return p.PageSize
|
||||
}
|
||||
|
||||
func (p *PageInfo) GetPage() int {
|
||||
return p.Page
|
||||
}
|
||||
|
||||
func (p *PageInfo) SetTotal(total int) {
|
||||
p.Total = total
|
||||
}
|
||||
|
||||
func (p *PageInfo) SetItems(items any) {
|
||||
p.Items = items
|
||||
}
|
||||
|
||||
func GetPageQuery(c *gin.Context) *PageInfo {
|
||||
pageInfo := &PageInfo{}
|
||||
// 手动获取并处理每个参数
|
||||
if page, err := strconv.Atoi(c.Query("p")); err == nil {
|
||||
pageInfo.Page = page
|
||||
}
|
||||
if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
|
||||
pageInfo.PageSize = pageSize
|
||||
}
|
||||
if pageInfo.Page < 1 {
|
||||
// 兼容
|
||||
page, _ := strconv.Atoi(c.Query("p"))
|
||||
if page != 0 {
|
||||
pageInfo.Page = page
|
||||
} else {
|
||||
pageInfo.Page = 1
|
||||
}
|
||||
}
|
||||
|
||||
if pageInfo.PageSize == 0 {
|
||||
// 兼容
|
||||
pageSize, _ := strconv.Atoi(c.Query("ps"))
|
||||
if pageSize != 0 {
|
||||
pageInfo.PageSize = pageSize
|
||||
}
|
||||
if pageInfo.PageSize == 0 {
|
||||
pageSize, _ = strconv.Atoi(c.Query("size")) // token page
|
||||
if pageSize != 0 {
|
||||
pageInfo.PageSize = pageSize
|
||||
}
|
||||
}
|
||||
if pageInfo.PageSize == 0 {
|
||||
pageInfo.PageSize = ItemsPerPage
|
||||
}
|
||||
}
|
||||
|
||||
if pageInfo.PageSize > 100 {
|
||||
pageInfo.PageSize = 100
|
||||
}
|
||||
|
||||
return pageInfo
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const passwordEncryptionKeyBits = 2048
|
||||
|
||||
var ErrPasswordEncryptionInvalid = errors.New("password encryption payload is invalid")
|
||||
|
||||
var passwordEncryptionState struct {
|
||||
sync.RWMutex
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey string
|
||||
keyID string
|
||||
}
|
||||
|
||||
// GeneratePasswordEncryptionPrivateKey creates the server key used to decrypt
|
||||
// browser login passwords. The caller is responsible for persisting the PEM.
|
||||
func GeneratePasswordEncryptionPrivateKey() (string, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, passwordEncryptionKeyBits)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate password encryption key: %w", err)
|
||||
}
|
||||
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal password encryption key: %w", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: privateKeyDER,
|
||||
})), nil
|
||||
}
|
||||
|
||||
// LoadPasswordEncryptionPrivateKey validates a persisted key before replacing
|
||||
// the active in-memory key used by request handlers.
|
||||
func LoadPasswordEncryptionPrivateKey(privateKeyPEM string) error {
|
||||
block, rest := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil || block.Type != "PRIVATE KEY" || strings.TrimSpace(string(rest)) != "" {
|
||||
return errors.New("password encryption key is not valid PKCS#8 PEM")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse password encryption key: %w", err)
|
||||
}
|
||||
privateKey, ok := parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return errors.New("password encryption key is not RSA")
|
||||
}
|
||||
if privateKey.N == nil || privateKey.N.BitLen() < passwordEncryptionKeyBits {
|
||||
return fmt.Errorf("password encryption key must be at least %d bits", passwordEncryptionKeyBits)
|
||||
}
|
||||
if err := privateKey.Validate(); err != nil {
|
||||
return fmt.Errorf("validate password encryption key: %w", err)
|
||||
}
|
||||
privateKey.Precompute()
|
||||
|
||||
publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal password encryption public key: %w", err)
|
||||
}
|
||||
publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyDER,
|
||||
}))
|
||||
keyDigest := sha256.Sum256(publicKeyDER)
|
||||
keyID := hex.EncodeToString(keyDigest[:16])
|
||||
|
||||
passwordEncryptionState.Lock()
|
||||
defer passwordEncryptionState.Unlock()
|
||||
passwordEncryptionState.privateKey = privateKey
|
||||
passwordEncryptionState.publicKey = publicKeyPEM
|
||||
passwordEncryptionState.keyID = keyID
|
||||
return nil
|
||||
}
|
||||
|
||||
// PasswordEncryptionPublicKey returns the active key identifier and SPKI PEM
|
||||
// public key exposed to browser clients.
|
||||
func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) {
|
||||
passwordEncryptionState.RLock()
|
||||
defer passwordEncryptionState.RUnlock()
|
||||
return passwordEncryptionState.keyID, passwordEncryptionState.publicKey
|
||||
}
|
||||
|
||||
// DecryptPassword accepts legacy RSA-OAEP/SHA-256 ciphertext and v2 envelopes.
|
||||
// V2 wraps a fresh AES-256 key with RSA-OAEP and encrypts the password with GCM,
|
||||
// allowing long Unicode passwords to work with existing 2048-bit server keys.
|
||||
// Both formats share one public error for all malformed inputs.
|
||||
func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
|
||||
passwordEncryptionState.RLock()
|
||||
privateKey := passwordEncryptionState.privateKey
|
||||
activeKeyID := passwordEncryptionState.keyID
|
||||
passwordEncryptionState.RUnlock()
|
||||
if privateKey == nil || keyID == "" || keyID != activeKeyID {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
if strings.HasPrefix(ciphertextBase64, "v2.") {
|
||||
if len(ciphertextBase64) > 4096 {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
parts := strings.Split(ciphertextBase64, ".")
|
||||
if len(parts) != 4 {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
wrappedKey, err := base64.StdEncoding.Strict().DecodeString(parts[1])
|
||||
if err != nil || len(wrappedKey) != privateKey.Size() {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
nonce, err := base64.StdEncoding.Strict().DecodeString(parts[2])
|
||||
if err != nil || len(nonce) != 12 {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.Strict().DecodeString(parts[3])
|
||||
if err != nil || len(ciphertext) <= 16 || len(ciphertext) > MaxAccountPasswordLength*utf8.UTFMax+16 {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
key, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, wrappedKey, []byte("password-v2"))
|
||||
if err != nil || len(key) != 32 {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, []byte("password-v2:"+keyID))
|
||||
if err != nil {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||
if err != nil || len(ciphertext) != privateKey.Size() {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
|
||||
if err != nil || len(plaintext) == 0 {
|
||||
return "", ErrPasswordEncryptionInvalid
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// PerformanceMonitorConfig 性能监控配置
|
||||
type PerformanceMonitorConfig struct {
|
||||
Enabled bool
|
||||
CPUThreshold int
|
||||
MemoryThreshold int
|
||||
DiskThreshold int
|
||||
}
|
||||
|
||||
var performanceMonitorConfig atomic.Value
|
||||
|
||||
func init() {
|
||||
// 初始化默认配置
|
||||
performanceMonitorConfig.Store(PerformanceMonitorConfig{
|
||||
Enabled: true,
|
||||
CPUThreshold: 90,
|
||||
MemoryThreshold: 90,
|
||||
DiskThreshold: 90,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPerformanceMonitorConfig 获取性能监控配置
|
||||
func GetPerformanceMonitorConfig() PerformanceMonitorConfig {
|
||||
return performanceMonitorConfig.Load().(PerformanceMonitorConfig)
|
||||
}
|
||||
|
||||
// SetPerformanceMonitorConfig 设置性能监控配置
|
||||
func SetPerformanceMonitorConfig(config PerformanceMonitorConfig) {
|
||||
performanceMonitorConfig.Store(config)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/cpu"
|
||||
)
|
||||
|
||||
// Monitor 定时监控cpu使用率,超过阈值输出pprof文件
|
||||
func Monitor() {
|
||||
for {
|
||||
percent, err := cpu.Percent(time.Second, false)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if percent[0] > 80 {
|
||||
fmt.Println("cpu usage too high")
|
||||
// write pprof file
|
||||
if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
|
||||
err := os.Mkdir("./pprof", os.ModePerm)
|
||||
if err != nil {
|
||||
SysLog("创建pprof文件夹失败 " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
|
||||
if err != nil {
|
||||
SysLog("创建pprof文件失败 " + err.Error())
|
||||
continue
|
||||
}
|
||||
err = pprof.StartCPUProfile(f)
|
||||
if err != nil {
|
||||
SysLog("启动pprof失败 " + err.Error())
|
||||
continue
|
||||
}
|
||||
time.Sleep(10 * time.Second) // profile for 30 seconds
|
||||
pprof.StopCPUProfile()
|
||||
f.Close()
|
||||
}
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseProxyURLStrict validates and normalizes a proxy URL for persistence.
|
||||
func ParseProxyURLStrict(rawProxyURL string) (*url.URL, error) {
|
||||
parsedURL, _, err := parseProxyURL(rawProxyURL, false)
|
||||
return parsedURL, err
|
||||
}
|
||||
|
||||
// ParseProxyURLRuntime validates and normalizes a proxy URL for runtime use.
|
||||
// The boolean result reports whether a legacy path, query, or fragment was removed.
|
||||
func ParseProxyURLRuntime(rawProxyURL string) (*url.URL, bool, error) {
|
||||
return parseProxyURL(rawProxyURL, true)
|
||||
}
|
||||
|
||||
func parseProxyURL(rawProxyURL string, allowLegacySuffix bool) (*url.URL, bool, error) {
|
||||
trimmedProxyURL := strings.TrimSpace(rawProxyURL)
|
||||
if trimmedProxyURL == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(trimmedProxyURL)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("invalid proxy URL")
|
||||
}
|
||||
parsedURL.Scheme = strings.ToLower(parsedURL.Scheme)
|
||||
switch parsedURL.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return nil, false, fmt.Errorf("proxy URL must use http, https, socks5, or socks5h")
|
||||
}
|
||||
if parsedURL.Hostname() == "" {
|
||||
return nil, false, fmt.Errorf("proxy URL must include a host")
|
||||
}
|
||||
if portText := parsedURL.Port(); portText != "" {
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return nil, false, fmt.Errorf("proxy URL must include a valid port")
|
||||
}
|
||||
}
|
||||
|
||||
hasQuery := parsedURL.RawQuery != "" || parsedURL.ForceQuery
|
||||
hasFragment := strings.Contains(trimmedProxyURL, "#")
|
||||
escapedPath := parsedURL.EscapedPath()
|
||||
hasNonRootPath := escapedPath != "" && escapedPath != "/"
|
||||
legacySuffixStripped := hasQuery || hasFragment || hasNonRootPath
|
||||
if !allowLegacySuffix {
|
||||
switch {
|
||||
case hasQuery:
|
||||
return nil, false, fmt.Errorf("proxy URL must not include a query")
|
||||
case hasFragment:
|
||||
return nil, false, fmt.Errorf("proxy URL must not include a fragment")
|
||||
case hasNonRootPath:
|
||||
return nil, false, fmt.Errorf("proxy URL must not include a path")
|
||||
}
|
||||
}
|
||||
|
||||
parsedURL.Path = ""
|
||||
parsedURL.RawPath = ""
|
||||
parsedURL.RawQuery = ""
|
||||
parsedURL.ForceQuery = false
|
||||
parsedURL.Fragment = ""
|
||||
parsedURL.RawFragment = ""
|
||||
|
||||
if (parsedURL.Scheme == "socks5" || parsedURL.Scheme == "socks5h") && parsedURL.Port() == "" {
|
||||
parsedURL.Host = net.JoinHostPort(parsedURL.Hostname(), "1080")
|
||||
}
|
||||
|
||||
return parsedURL, legacySuffixStripped, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/grafana/pyroscope-go"
|
||||
)
|
||||
|
||||
func StartPyroScope() error {
|
||||
|
||||
pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "")
|
||||
if pyroscopeUrl == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api")
|
||||
pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "")
|
||||
pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "")
|
||||
pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api")
|
||||
|
||||
mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5)
|
||||
blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5)
|
||||
|
||||
runtime.SetMutexProfileFraction(mutexRate)
|
||||
runtime.SetBlockProfileRate(blockRate)
|
||||
|
||||
_, err := pyroscope.Start(pyroscope.Config{
|
||||
ApplicationName: pyroscopeAppName,
|
||||
|
||||
ServerAddress: pyroscopeUrl,
|
||||
BasicAuthUser: pyroscopeBasicAuthUser,
|
||||
BasicAuthPassword: pyroscopeBasicAuthPassword,
|
||||
|
||||
Logger: nil,
|
||||
|
||||
Tags: map[string]string{"hostname": pyroscopeHostname},
|
||||
|
||||
ProfileTypes: []pyroscope.ProfileType{
|
||||
pyroscope.ProfileCPU,
|
||||
pyroscope.ProfileAllocObjects,
|
||||
pyroscope.ProfileAllocSpace,
|
||||
pyroscope.ProfileInuseObjects,
|
||||
pyroscope.ProfileInuseSpace,
|
||||
|
||||
pyroscope.ProfileGoroutines,
|
||||
pyroscope.ProfileMutexCount,
|
||||
pyroscope.ProfileMutexDuration,
|
||||
pyroscope.ProfileBlockCount,
|
||||
pyroscope.ProfileBlockDuration,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
func GetTrustQuota() int {
|
||||
return int(10 * QuotaPerUnit)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// Quota conversions are centralized here so every billing path shares one
|
||||
// saturation + logging policy. Single-request charges stay bounded to int32;
|
||||
// top-ups and wallet-priced purchases use a JavaScript-safe 64-bit domain.
|
||||
const (
|
||||
MaxQuota = math.MaxInt32
|
||||
MinQuota = math.MinInt32
|
||||
MaxWalletQuota = 1<<53 - 1
|
||||
)
|
||||
|
||||
// ValidateWalletQuota enforces the upper bound shared by wallet mutations.
|
||||
// Negative balances remain valid because billing can temporarily overdraw a
|
||||
// wallet; callers that accept credits must apply their own positive check.
|
||||
func ValidateWalletQuota(quota int) error {
|
||||
if quota > MaxWalletQuota {
|
||||
return fmt.Errorf("wallet quota exceeds %d", MaxWalletQuota)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaClampKind identifies why a quota conversion had to be saturated.
|
||||
type QuotaClampKind string
|
||||
|
||||
// Clamp kinds reported by QuotaClamp.Kind.
|
||||
const (
|
||||
QuotaClampOverflow QuotaClampKind = "overflow"
|
||||
QuotaClampUnderflow QuotaClampKind = "underflow"
|
||||
QuotaClampNaN QuotaClampKind = "nan"
|
||||
)
|
||||
|
||||
// QuotaClamp describes a single saturation event: a quota conversion whose
|
||||
// input fell outside its supported range (or was NaN) and was
|
||||
// therefore clamped. It is surfaced to billing callers so the event can be
|
||||
// recorded on the related consume/task log for admin auditing.
|
||||
type QuotaClamp struct {
|
||||
Op string `json:"op"` // "QuotaFromFloat" | "QuotaRound" | "QuotaFromDecimal" | "WalletQuotaFromDecimal"
|
||||
Kind QuotaClampKind `json:"kind"` // "overflow" | "underflow" | "nan"
|
||||
Original float64 `json:"original"` // best-effort pre-clamp value (decimal -> float64 approx)
|
||||
Clamped int `json:"clamped"` // the saturated result actually used
|
||||
}
|
||||
|
||||
// Error lets the same typed value serve both as the settlement audit marker
|
||||
// and as the fail-fast error returned by strict pre-consume conversions.
|
||||
func (c *QuotaClamp) Error() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("quota conversion (%s) %s: original=%g, clamped=%d", c.Op, c.Kind, c.Original, c.Clamped)
|
||||
}
|
||||
|
||||
// AuditMap renders the clamp as the marker stored under a log's
|
||||
// admin_info.quota_saturation. Centralized here so every billing path (consume
|
||||
// logs, task billing logs, task compensation logs) records the same shape.
|
||||
func (c *QuotaClamp) AuditMap() map[string]any {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"op": c.Op,
|
||||
"kind": c.Kind,
|
||||
"original": c.Original,
|
||||
"clamped": c.Clamped,
|
||||
}
|
||||
}
|
||||
|
||||
// saturateQuota converts an already-rounded single-request quota to int.
|
||||
// Whenever clamping (what would otherwise be an integer wraparound) or a NaN
|
||||
// fallback is triggered it logs a warning, because in
|
||||
// normal operation a single request never approaches these bounds — hitting
|
||||
// them signals a bug or an abusive request. `op` names the caller. When a
|
||||
// clamp occurs it returns a non-nil *QuotaClamp so callers can additionally
|
||||
// record the event (e.g. on the consume log); the returned pointer is nil for
|
||||
// in-range values.
|
||||
func saturateQuota(value float64, op string) (int, *QuotaClamp) {
|
||||
return saturateQuotaBounded(value, op, MaxQuota, MinQuota)
|
||||
}
|
||||
|
||||
func saturateQuotaBounded(value float64, op string, maxQuota int, minQuota int) (int, *QuotaClamp) {
|
||||
var clamp *QuotaClamp
|
||||
switch {
|
||||
case math.IsNaN(value):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampNaN, Original: value, Clamped: 0}
|
||||
case value > float64(maxQuota):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampOverflow, Original: value, Clamped: maxQuota}
|
||||
case value < float64(minQuota):
|
||||
clamp = &QuotaClamp{Op: op, Kind: QuotaClampUnderflow, Original: value, Clamped: minQuota}
|
||||
default:
|
||||
return int(value), nil
|
||||
}
|
||||
SysError(clamp.Error())
|
||||
return clamp.Clamped, clamp
|
||||
}
|
||||
|
||||
func strictQuota(quota int, clamp *QuotaClamp) (int, error) {
|
||||
if clamp != nil {
|
||||
return 0, clamp
|
||||
}
|
||||
return quota, nil
|
||||
}
|
||||
|
||||
// QuotaFromFloat converts a computed quota value to int, truncating toward
|
||||
// zero, with saturation. Use for float products of prices, ratios, and
|
||||
// user-controlled multipliers (image n, video seconds, resolution ratios).
|
||||
func QuotaFromFloat(value float64) int {
|
||||
quota, _ := QuotaFromFloatChecked(value)
|
||||
return quota
|
||||
}
|
||||
|
||||
// QuotaFromFloatChecked is QuotaFromFloat but also returns a non-nil
|
||||
// *QuotaClamp when the value was clamped, so billing callers can audit it.
|
||||
func QuotaFromFloatChecked(value float64) (int, *QuotaClamp) {
|
||||
return saturateQuota(value, "QuotaFromFloat")
|
||||
}
|
||||
|
||||
// QuotaFromFloatStrict converts an in-range value and returns a typed
|
||||
// *QuotaClamp error instead of allowing a saturated result to reach billing.
|
||||
func QuotaFromFloatStrict(value float64) (int, error) {
|
||||
return strictQuota(QuotaFromFloatChecked(value))
|
||||
}
|
||||
|
||||
// QuotaRound converts a float64 quota value to int using half-away-from-zero
|
||||
// rounding, with saturation. Every tiered billing path (pre-consume,
|
||||
// settlement, breakdown validation, log fields) MUST use this to avoid +-1
|
||||
// discrepancies.
|
||||
func QuotaRound(value float64) int {
|
||||
quota, _ := QuotaRoundChecked(value)
|
||||
return quota
|
||||
}
|
||||
|
||||
// QuotaRoundChecked is QuotaRound but also returns a non-nil *QuotaClamp when
|
||||
// the value was clamped, so billing callers can audit it.
|
||||
func QuotaRoundChecked(value float64) (int, *QuotaClamp) {
|
||||
return saturateQuota(math.Round(value), "QuotaRound")
|
||||
}
|
||||
|
||||
// QuotaRoundStrict rounds an in-range value and returns a typed *QuotaClamp
|
||||
// error instead of allowing a saturated result to reach billing.
|
||||
func QuotaRoundStrict(value float64) (int, error) {
|
||||
return strictQuota(QuotaRoundChecked(value))
|
||||
}
|
||||
|
||||
// QuotaFromDecimal converts a computed quota decimal to int with saturation.
|
||||
// The decimal is rounded (half away from zero) before conversion.
|
||||
func QuotaFromDecimal(d decimal.Decimal) int {
|
||||
quota, _ := QuotaFromDecimalChecked(d)
|
||||
return quota
|
||||
}
|
||||
|
||||
// QuotaFromDecimalChecked is QuotaFromDecimal but also returns a non-nil
|
||||
// *QuotaClamp when the value was clamped, so billing callers can audit it.
|
||||
func QuotaFromDecimalChecked(d decimal.Decimal) (int, *QuotaClamp) {
|
||||
f, _ := d.Round(0).Float64()
|
||||
return saturateQuota(f, "QuotaFromDecimal")
|
||||
}
|
||||
|
||||
// QuotaFromDecimalStrict converts an in-range single-request quota and rejects
|
||||
// a value that would otherwise be saturated at the int32 boundary.
|
||||
func QuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
|
||||
return strictQuota(QuotaFromDecimalChecked(d))
|
||||
}
|
||||
|
||||
// WalletQuotaFromDecimalStrict converts wallet and top-up values within the
|
||||
// JavaScript-safe integer range, which is also exactly representable by float64.
|
||||
func WalletQuotaFromDecimalStrict(d decimal.Decimal) (int, error) {
|
||||
f, _ := d.Round(0).Float64()
|
||||
return strictQuota(saturateQuotaBounded(f, "WalletQuotaFromDecimal", MaxWalletQuota, -MaxWalletQuota))
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 2000 quota per call * n=18446744073686646784 overflows int64; the constant
|
||||
// below reproduces that oversized product for the saturation checks.
|
||||
const overflowingProduct = 2000 * 1.8446744073686647e19
|
||||
|
||||
// TestQuotaFromFloat guards the billing invariant that oversized quota
|
||||
// products (e.g. price multiplied by a huge user-supplied count) saturate
|
||||
// instead of wrapping into a negative charge (credit). QuotaFromFloat
|
||||
// truncates toward zero.
|
||||
func TestQuotaFromFloat(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.4))
|
||||
assert.Equal(t, 42, QuotaFromFloat(42.9))
|
||||
assert.Equal(t, -42, QuotaFromFloat(-42.9))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(float64(math.MaxInt32)+42))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaFromFloat(-overflowingProduct))
|
||||
assert.Equal(t, MaxQuota, QuotaFromFloat(math.Inf(1)))
|
||||
assert.Equal(t, MinQuota, QuotaFromFloat(math.Inf(-1)))
|
||||
assert.Equal(t, 0, QuotaFromFloat(math.NaN()))
|
||||
}
|
||||
|
||||
// TestQuotaRound checks half-away-from-zero rounding with the same
|
||||
// saturation policy.
|
||||
func TestQuotaRound(t *testing.T) {
|
||||
assert.Equal(t, 42, QuotaRound(41.5))
|
||||
assert.Equal(t, 43, QuotaRound(42.5))
|
||||
assert.Equal(t, -43, QuotaRound(-42.5))
|
||||
assert.Equal(t, MaxQuota, QuotaRound(float64(math.MaxInt32)+0.5))
|
||||
assert.Equal(t, MaxQuota, QuotaRound(overflowingProduct))
|
||||
assert.Equal(t, MinQuota, QuotaRound(-overflowingProduct))
|
||||
assert.Equal(t, 0, QuotaRound(math.NaN()))
|
||||
}
|
||||
|
||||
// TestQuotaFromDecimal checks the decimal entry point rounds and saturates
|
||||
// consistently with the float variants.
|
||||
func TestQuotaFromDecimal(t *testing.T) {
|
||||
assert.Equal(t, 43, QuotaFromDecimal(decimal.NewFromFloat(42.5)))
|
||||
assert.Equal(t, 42, QuotaFromDecimal(decimal.NewFromFloat(41.7)))
|
||||
assert.Equal(t, MaxQuota, QuotaFromDecimal(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))))
|
||||
assert.Equal(t, MinQuota, QuotaFromDecimal(decimal.NewFromInt(-2000).Mul(decimal.NewFromFloat(1.8446744073686647e19))))
|
||||
}
|
||||
|
||||
// TestQuotaFromFloatChecked verifies the clamp descriptor is nil in range and
|
||||
// carries the correct kind/clamped value on saturation, so billing callers can
|
||||
// audit the event.
|
||||
func TestQuotaFromFloatChecked(t *testing.T) {
|
||||
quota, clamp := QuotaFromFloatChecked(42.9)
|
||||
assert.Equal(t, 42, quota)
|
||||
assert.Nil(t, clamp)
|
||||
|
||||
quota, clamp = QuotaFromFloatChecked(overflowingProduct)
|
||||
assert.Equal(t, MaxQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, "QuotaFromFloat", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
assert.Equal(t, MaxQuota, clamp.Clamped)
|
||||
}
|
||||
|
||||
quota, clamp = QuotaFromFloatChecked(-overflowingProduct)
|
||||
assert.Equal(t, MinQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, QuotaClampUnderflow, clamp.Kind)
|
||||
assert.Equal(t, MinQuota, clamp.Clamped)
|
||||
}
|
||||
|
||||
quota, clamp = QuotaFromFloatChecked(math.NaN())
|
||||
assert.Equal(t, 0, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, QuotaClampNaN, clamp.Kind)
|
||||
assert.Equal(t, 0, clamp.Clamped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaFromFloatStrictReturnsTypedClampError(t *testing.T) {
|
||||
quota, err := QuotaFromFloatStrict(42.9)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 42, quota)
|
||||
|
||||
quota, err = QuotaFromFloatStrict(overflowingProduct)
|
||||
assert.Zero(t, quota)
|
||||
var clamp *QuotaClamp
|
||||
require.ErrorAs(t, err, &clamp)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
assert.Equal(t, MaxQuota, clamp.Clamped)
|
||||
assert.ErrorContains(t, err, "QuotaFromFloat")
|
||||
assert.ErrorContains(t, err, "overflow")
|
||||
assert.ErrorContains(t, err, "original=")
|
||||
assert.ErrorContains(t, err, fmt.Sprintf("clamped=%d", MaxQuota))
|
||||
}
|
||||
|
||||
// TestQuotaRoundChecked verifies the rounding entry point reports clamps the
|
||||
// same way.
|
||||
func TestQuotaRoundChecked(t *testing.T) {
|
||||
quota, clamp := QuotaRoundChecked(42.5)
|
||||
assert.Equal(t, 43, quota)
|
||||
assert.Nil(t, clamp)
|
||||
|
||||
quota, clamp = QuotaRoundChecked(overflowingProduct)
|
||||
assert.Equal(t, MaxQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, "QuotaRound", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaFromDecimalChecked verifies the decimal entry point reports clamps.
|
||||
func TestQuotaFromDecimalChecked(t *testing.T) {
|
||||
quota, clamp := QuotaFromDecimalChecked(decimal.NewFromFloat(41.7))
|
||||
assert.Equal(t, 42, quota)
|
||||
assert.Nil(t, clamp)
|
||||
|
||||
quota, clamp = QuotaFromDecimalChecked(decimal.NewFromInt(2000).Mul(decimal.NewFromFloat(1.8446744073686647e19)))
|
||||
assert.Equal(t, MaxQuota, quota)
|
||||
if assert.NotNil(t, clamp) {
|
||||
assert.Equal(t, "QuotaFromDecimal", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletQuotaFromDecimalStrict(t *testing.T) {
|
||||
quota, err := WalletQuotaFromDecimalStrict(decimal.NewFromInt(4_294_500_000))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4_294_500_000, quota)
|
||||
|
||||
quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, MaxWalletQuota, quota)
|
||||
|
||||
quota, err = WalletQuotaFromDecimalStrict(decimal.NewFromInt(MaxWalletQuota + 1))
|
||||
assert.Zero(t, quota)
|
||||
var clamp *QuotaClamp
|
||||
require.ErrorAs(t, err, &clamp)
|
||||
assert.Equal(t, "WalletQuotaFromDecimal", clamp.Op)
|
||||
assert.Equal(t, QuotaClampOverflow, clamp.Kind)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type InMemoryRateLimiter struct {
|
||||
store map[string]*[]int64
|
||||
mutex sync.Mutex
|
||||
expirationDuration time.Duration
|
||||
}
|
||||
|
||||
func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
|
||||
if l.store == nil {
|
||||
l.mutex.Lock()
|
||||
if l.store == nil {
|
||||
l.store = make(map[string]*[]int64)
|
||||
l.expirationDuration = expirationDuration
|
||||
if expirationDuration > 0 {
|
||||
go l.clearExpiredItems()
|
||||
}
|
||||
}
|
||||
l.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *InMemoryRateLimiter) clearExpiredItems() {
|
||||
for {
|
||||
time.Sleep(l.expirationDuration)
|
||||
l.mutex.Lock()
|
||||
now := time.Now().Unix()
|
||||
for key := range l.store {
|
||||
queue := l.store[key]
|
||||
size := len(*queue)
|
||||
if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
|
||||
delete(l.store, key)
|
||||
}
|
||||
}
|
||||
l.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Request parameter duration's unit is seconds
|
||||
func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
|
||||
l.mutex.Lock()
|
||||
defer l.mutex.Unlock()
|
||||
// [old <-- new]
|
||||
queue, ok := l.store[key]
|
||||
now := time.Now().Unix()
|
||||
if ok {
|
||||
if len(*queue) < maxRequestNum {
|
||||
*queue = append(*queue, now)
|
||||
return true
|
||||
} else {
|
||||
if now-(*queue)[0] >= duration {
|
||||
*queue = (*queue)[1:]
|
||||
*queue = append(*queue, now)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s := make([]int64, 0, maxRequestNum)
|
||||
l.store[key] = &s
|
||||
*(l.store[key]) = append(*(l.store[key]), now)
|
||||
}
|
||||
return true
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var RDB *redis.Client
|
||||
var RedisEnabled = true
|
||||
|
||||
func RedisKeyCacheSeconds() int {
|
||||
return SyncFrequency
|
||||
}
|
||||
|
||||
// InitRedisClient This function is called after init()
|
||||
func InitRedisClient() (err error) {
|
||||
if os.Getenv("REDIS_CONN_STRING") == "" {
|
||||
RedisEnabled = false
|
||||
SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("SYNC_FREQUENCY") == "" {
|
||||
SysLog("SYNC_FREQUENCY not set, use default value 60")
|
||||
SyncFrequency = 60
|
||||
}
|
||||
SysLog("Redis is enabled")
|
||||
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
||||
if err != nil {
|
||||
FatalLog("failed to parse Redis connection string: " + err.Error())
|
||||
}
|
||||
opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10)
|
||||
RDB = redis.NewClient(opt)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = RDB.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
FatalLog("Redis ping test failed: " + err.Error())
|
||||
}
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr))
|
||||
SysLog(fmt.Sprintf("Redis database: %d", opt.DB))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ParseRedisOption() *redis.Options {
|
||||
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
||||
if err != nil {
|
||||
FatalLog("failed to parse Redis connection string: " + err.Error())
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
func RedisSet(key string, value string, expiration time.Duration) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
func RedisGet(key string) (string, error) {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis GET: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
val, err := RDB.Get(ctx, key).Result()
|
||||
return val, err
|
||||
}
|
||||
|
||||
//func RedisExpire(key string, expiration time.Duration) error {
|
||||
// ctx := context.Background()
|
||||
// return RDB.Expire(ctx, key, expiration).Err()
|
||||
//}
|
||||
//
|
||||
//func RedisGetEx(key string, expiration time.Duration) (string, error) {
|
||||
// ctx := context.Background()
|
||||
// return RDB.GetSet(ctx, key, expiration).Result()
|
||||
//}
|
||||
|
||||
func RedisDel(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis DEL: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func RedisDelKey(key string) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
return RDB.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func RedisHSetObj(key string, obj any, expiration time.Duration) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
data := make(map[string]any)
|
||||
|
||||
// 使用反射遍历结构体字段
|
||||
v := reflect.ValueOf(obj).Elem()
|
||||
t := v.Type()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
value := v.Field(i)
|
||||
|
||||
// Skip DeletedAt field
|
||||
if field.Type.String() == "gorm.DeletedAt" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理指针类型
|
||||
if value.Kind() == reflect.Pointer {
|
||||
if value.IsNil() {
|
||||
data[field.Name] = ""
|
||||
continue
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
|
||||
// 处理布尔类型
|
||||
if value.Kind() == reflect.Bool {
|
||||
data[field.Name] = strconv.FormatBool(value.Bool())
|
||||
continue
|
||||
}
|
||||
|
||||
// 其他类型直接转换为字符串
|
||||
data[field.Name] = fmt.Sprintf("%v", value.Interface())
|
||||
}
|
||||
|
||||
txn := RDB.TxPipeline()
|
||||
txn.HSet(ctx, key, data)
|
||||
|
||||
// 只有在 expiration 大于 0 时才设置过期时间
|
||||
if expiration > 0 {
|
||||
txn.Expire(ctx, key, expiration)
|
||||
}
|
||||
|
||||
_, err := txn.Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RedisHGetObj(key string, obj any) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key))
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := RDB.HGetAll(ctx, key).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load hash from Redis: %w", err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return fmt.Errorf("key %s not found in Redis", key)
|
||||
}
|
||||
|
||||
// Handle both pointer and non-pointer values
|
||||
val := reflect.ValueOf(obj)
|
||||
if val.Kind() != reflect.Pointer {
|
||||
return fmt.Errorf("obj must be a pointer to a struct, got %T", obj)
|
||||
}
|
||||
|
||||
v := val.Elem()
|
||||
if v.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("obj must be a pointer to a struct, got pointer to %T", v.Interface())
|
||||
}
|
||||
|
||||
t := v.Type()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldName := field.Name
|
||||
if value, ok := result[fieldName]; ok {
|
||||
fieldValue := v.Field(i)
|
||||
|
||||
// Handle pointer types
|
||||
if fieldValue.Kind() == reflect.Pointer {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if fieldValue.IsNil() {
|
||||
fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
|
||||
}
|
||||
fieldValue = fieldValue.Elem()
|
||||
}
|
||||
|
||||
// Enhanced type handling for Token struct
|
||||
switch fieldValue.Kind() {
|
||||
case reflect.String:
|
||||
fieldValue.SetString(value)
|
||||
case reflect.Int, reflect.Int64:
|
||||
intValue, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse int field %s: %w", fieldName, err)
|
||||
}
|
||||
fieldValue.SetInt(intValue)
|
||||
case reflect.Bool:
|
||||
boolValue, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse bool field %s: %w", fieldName, err)
|
||||
}
|
||||
fieldValue.SetBool(boolValue)
|
||||
case reflect.Struct:
|
||||
// Special handling for gorm.DeletedAt
|
||||
if fieldValue.Type().String() == "gorm.DeletedAt" {
|
||||
if value != "" {
|
||||
timeValue, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse DeletedAt field %s: %w", fieldName, err)
|
||||
}
|
||||
fieldValue.Set(reflect.ValueOf(gorm.DeletedAt{Time: timeValue, Valid: true}))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported field type: %s for field %s", fieldValue.Kind(), fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RedisIncr Add this function to handle atomic increments
|
||||
func RedisIncr(key string, delta int64) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta))
|
||||
}
|
||||
// 检查键的剩余生存时间
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return fmt.Errorf("failed to get TTL: %w", err)
|
||||
}
|
||||
|
||||
// 只有在 key 存在且有 TTL 时才需要特殊处理
|
||||
if ttl > 0 {
|
||||
ctx := context.Background()
|
||||
// 开始一个Redis事务
|
||||
txn := RDB.TxPipeline()
|
||||
|
||||
// 减少余额
|
||||
decrCmd := txn.IncrBy(ctx, key, delta)
|
||||
if err := decrCmd.Err(); err != nil {
|
||||
return err // 如果减少失败,则直接返回错误
|
||||
}
|
||||
|
||||
// 重新设置过期时间,使用原来的过期时间
|
||||
txn.Expire(ctx, key, ttl)
|
||||
|
||||
// 执行事务
|
||||
_, err = txn.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RedisHIncrBy(key, field string, delta int64) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
|
||||
}
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return fmt.Errorf("failed to get TTL: %w", err)
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
ctx := context.Background()
|
||||
txn := RDB.TxPipeline()
|
||||
|
||||
incrCmd := txn.HIncrBy(ctx, key, field, delta)
|
||||
if err := incrCmd.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txn.Expire(ctx, key, ttl)
|
||||
|
||||
_, err = txn.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RedisHSetField(key, field string, value any) error {
|
||||
if DebugEnabled {
|
||||
SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
|
||||
}
|
||||
ttlCmd := RDB.TTL(context.Background(), key)
|
||||
ttl, err := ttlCmd.Result()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return fmt.Errorf("failed to get TTL: %w", err)
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
ctx := context.Background()
|
||||
txn := RDB.TxPipeline()
|
||||
|
||||
hsetCmd := txn.HSet(ctx, key, field, value)
|
||||
if err := hsetCmd.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
txn.Expire(ctx, key, ttl)
|
||||
|
||||
_, err = txn.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package common
|
||||
|
||||
import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
const defaultAnonymousRequestBodyLimitKB = 512
|
||||
|
||||
func GetAnonymousRequestBodyLimitBytes() int64 {
|
||||
limitKB := constant.AnonymousRequestBodyLimitKB
|
||||
if limitKB < 0 {
|
||||
limitKB = defaultAnonymousRequestBodyLimitKB
|
||||
}
|
||||
return int64(limitKB) << 10
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeOrigin validates and canonicalizes a browser origin. Only an exact
|
||||
// scheme/host/effective-port match is meaningful; paths and wildcards are not
|
||||
// accepted for authentication cookie endpoints.
|
||||
func NormalizeOrigin(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "null" || strings.ContainsAny(raw, "\r\n") {
|
||||
return "", fmt.Errorf("origin is empty or invalid")
|
||||
}
|
||||
parsedURL, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid origin: %w", err)
|
||||
}
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return "", fmt.Errorf("origin scheme must be http or https")
|
||||
}
|
||||
if parsedURL.Host == "" || parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" || (parsedURL.Path != "" && parsedURL.Path != "/") {
|
||||
return "", fmt.Errorf("origin must contain only scheme and host")
|
||||
}
|
||||
hostname := strings.ToLower(parsedURL.Hostname())
|
||||
if hostname == "" || strings.Contains(hostname, "*") {
|
||||
return "", fmt.Errorf("origin host is empty")
|
||||
}
|
||||
port := parsedURL.Port()
|
||||
normalizedHost := hostname
|
||||
if strings.Contains(hostname, ":") {
|
||||
normalizedHost = "[" + hostname + "]"
|
||||
}
|
||||
if port == "" || (parsedURL.Scheme == "http" && port == "80") || (parsedURL.Scheme == "https" && port == "443") {
|
||||
return parsedURL.Scheme + "://" + normalizedHost, nil
|
||||
}
|
||||
return parsedURL.Scheme + "://" + net.JoinHostPort(hostname, port), nil
|
||||
}
|
||||
|
||||
func InitSessionCookieSettings() error {
|
||||
secureRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_SECURE"))
|
||||
trustedURLsRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_TRUSTED_URL"))
|
||||
|
||||
SessionCookieSecure = false
|
||||
SessionCookieTrustedURLs = nil
|
||||
|
||||
if secureRaw == "" || strings.EqualFold(secureRaw, "false") {
|
||||
if trustedURLsRaw != "" {
|
||||
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL requires SESSION_COOKIE_SECURE=true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.EqualFold(secureRaw, "true") {
|
||||
return fmt.Errorf("SESSION_COOKIE_SECURE must be true or false")
|
||||
}
|
||||
|
||||
if trustedURLsRaw == "" {
|
||||
return fmt.Errorf("SESSION_COOKIE_SECURE=true requires SESSION_COOKIE_TRUSTED_URL")
|
||||
}
|
||||
|
||||
trustedURLs := strings.SplitSeq(trustedURLsRaw, ",")
|
||||
for trustedURL := range trustedURLs {
|
||||
trustedURL = strings.TrimSpace(trustedURL)
|
||||
if trustedURL == "" {
|
||||
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL contains an empty URL")
|
||||
}
|
||||
normalizedOrigin, err := NormalizeOrigin(trustedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SESSION_COOKIE_TRUSTED_URL: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(normalizedOrigin, "https://") {
|
||||
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL must contain only https URLs with hosts")
|
||||
}
|
||||
SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, normalizedOrigin)
|
||||
}
|
||||
|
||||
SessionCookieSecure = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"slices"
|
||||
"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{},
|
||||
}
|
||||
|
||||
// NewSSRFProtectionFromFetchSetting builds an SSRFProtection from persisted fetch_setting values.
|
||||
func NewSSRFProtectionFromFetchSetting(allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) (*SSRFProtection, error) {
|
||||
allowedPortInts, err := parsePortRanges(allowedPorts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request reject - invalid port configuration: %v", err)
|
||||
}
|
||||
|
||||
return &SSRFProtection{
|
||||
AllowPrivateIp: allowPrivateIp,
|
||||
DomainFilterMode: domainFilterMode,
|
||||
DomainList: domainList,
|
||||
IpFilterMode: ipFilterMode,
|
||||
IpList: ipList,
|
||||
AllowedPorts: allowedPortInts,
|
||||
ApplyIPFilterForDomain: applyIPFilterForDomain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// privateIPv4Nets IPv4 私有/保留/特殊用途网段
|
||||
// 参考 IANA IPv4 Special-Purpose Address Registry
|
||||
// https://www.iana.org/assignments/iana-ipv4-special-registry/
|
||||
var privateIPv4Nets = []net.IPNet{
|
||||
{IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 0.0.0.0/8 ("This network" / 未指定)
|
||||
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 (私有)
|
||||
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10 (运营商级 NAT / CGNAT)
|
||||
{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(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 (私有)
|
||||
{IP: net.IPv4(192, 0, 0, 0), Mask: net.CIDRMask(24, 32)}, // 192.0.0.0/24 (IETF 协议分配)
|
||||
{IP: net.IPv4(192, 0, 2, 0), Mask: net.CIDRMask(24, 32)}, // 192.0.2.0/24 (TEST-NET-1)
|
||||
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 (私有)
|
||||
{IP: net.IPv4(198, 18, 0, 0), Mask: net.CIDRMask(15, 32)}, // 198.18.0.0/15 (基准测试)
|
||||
{IP: net.IPv4(198, 51, 100, 0), Mask: net.CIDRMask(24, 32)}, // 198.51.100.0/24 (TEST-NET-2)
|
||||
{IP: net.IPv4(203, 0, 113, 0), Mask: net.CIDRMask(24, 32)}, // 203.0.113.0/24 (TEST-NET-3)
|
||||
{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 (保留)
|
||||
{IP: net.IPv4(255, 255, 255, 255), Mask: net.CIDRMask(32, 32)}, // 255.255.255.255/32 (受限广播)
|
||||
}
|
||||
|
||||
// privateIPv6Nets IPv6 私有/保留/特殊用途网段
|
||||
// 参考 IANA IPv6 Special-Purpose Address Registry
|
||||
// https://www.iana.org/assignments/iana-ipv6-special-registry/
|
||||
var privateIPv6Nets = func() []net.IPNet {
|
||||
cidrs := []string{
|
||||
"::/128", // 未指定地址
|
||||
"::1/128", // 回环
|
||||
"::ffff:0:0/96", // IPv4-mapped
|
||||
"64:ff9b::/96", // IPv4/IPv6 translation
|
||||
"100::/64", // Discard-Only
|
||||
"2001::/23", // IETF Protocol Assignments
|
||||
"2001:db8::/32", // 文档
|
||||
"fc00::/7", // Unique Local Address (ULA)
|
||||
"fe80::/10", // 链路本地
|
||||
"ff00::/8", // 组播
|
||||
}
|
||||
nets := make([]net.IPNet, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
if _, n, err := net.ParseCIDR(c); err == nil && n != nil {
|
||||
nets = append(nets, *n)
|
||||
}
|
||||
}
|
||||
return nets
|
||||
}()
|
||||
|
||||
// isPrivateIP 检查IP是否为私有/保留/特殊用途地址
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// 未指定地址 (0.0.0.0, ::)
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
// 回环、链路本地 (unicast/multicast)
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
// 接口本地组播 (IPv6 ff01::/16 等)
|
||||
if ip.IsInterfaceLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
for _, privateNet := range privateIPv4Nets {
|
||||
if privateNet.Contains(v4) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IPv6 检查
|
||||
for _, privateNet := range privateIPv6Nets {
|
||||
if privateNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 兜底: Go 标准库识别的其他私有地址
|
||||
if ip.IsPrivate() {
|
||||
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 // 如果没有配置端口限制,则允许所有端口
|
||||
}
|
||||
|
||||
return slices.Contains(p.AllowedPorts, port)
|
||||
}
|
||||
|
||||
// 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 after, ok := strings.CutPrefix(item, "*."); ok {
|
||||
suffix := after
|
||||
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
|
||||
}
|
||||
|
||||
return IsIpInCIDRList(ip, list)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (p *SSRFProtection) ipAccessError(host string, ip net.IP) error {
|
||||
if host != "" {
|
||||
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())
|
||||
}
|
||||
|
||||
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
||||
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())
|
||||
}
|
||||
|
||||
// ValidateNetworkTarget validates the host and port before dialing.
|
||||
func (p *SSRFProtection) ValidateNetworkTarget(host string, port int) error {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return fmt.Errorf("invalid host")
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("invalid port: %d", port)
|
||||
}
|
||||
if !p.isAllowedPort(port) {
|
||||
return fmt.Errorf("port %d is not allowed", port)
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if !p.IsIPAccessAllowed(ip) {
|
||||
return p.ipAccessError("", ip)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateResolvedIP validates a domain's resolved IP immediately before dialing it.
|
||||
func (p *SSRFProtection) ValidateResolvedIP(host string, ip net.IP) error {
|
||||
if !p.IsIPAccessAllowed(ip) {
|
||||
return p.ipAccessError(host, ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 err := p.ValidateNetworkTarget(host, port); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果 host 是 IP,或未启用对域名应用 IP 过滤,则到此通过。
|
||||
if net.ParseIP(host) != nil || !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 err := p.ValidateResolvedIP(host, ip); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
protection, err := NewSSRFProtectionFromFetchSetting(allowPrivateIp, domainFilterMode, ipFilterMode, domainList, ipList, allowedPorts, applyIPFilterForDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return protection.ValidateURL(urlStr)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSRFProtectionRejectsLiteralPrivateAndReservedIPs(t *testing.T) {
|
||||
protection := &SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
}
|
||||
|
||||
tests := []string{
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"169.254.169.254",
|
||||
"fc00::1",
|
||||
"::ffff:127.0.0.1",
|
||||
}
|
||||
for _, host := range tests {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
require.Error(t, protection.ValidateNetworkTarget(host, 80))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSRFProtectionAllowsPrivateIPWhenExplicitlyEnabled(t *testing.T) {
|
||||
protection := &SSRFProtection{
|
||||
AllowPrivateIp: true,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
}
|
||||
|
||||
require.NoError(t, protection.ValidateNetworkTarget("10.0.0.1", 80))
|
||||
}
|
||||
|
||||
func TestSSRFProtectionRejectsResolvedPrivateIP(t *testing.T) {
|
||||
protection := &SSRFProtection{
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
ApplyIPFilterForDomain: true,
|
||||
}
|
||||
|
||||
require.NoError(t, protection.ValidateNetworkTarget("example.com", 80))
|
||||
require.Error(t, protection.ValidateResolvedIP("example.com", net.ParseIP("169.254.169.254")))
|
||||
}
|
||||
|
||||
func TestNewSSRFProtectionFromFetchSettingParsesPortRanges(t *testing.T) {
|
||||
protection, err := NewSSRFProtectionFromFetchSetting(false, false, false, nil, nil, []string{"80", "8000-8001"}, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, protection.ValidateNetworkTarget("example.com", 8001))
|
||||
require.Error(t, protection.ValidateNetworkTarget("example.com", 9000))
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
const LocalLogContentLimit = 2048
|
||||
|
||||
// LocalLogPreview limits log-only content unless debug logging is enabled.
|
||||
func LocalLogPreview(content string) string {
|
||||
if DebugEnabled || len(content) <= LocalLogContentLimit {
|
||||
return content
|
||||
}
|
||||
return fmt.Sprintf("%s... [truncated, original_length=%d, limit=%d]", content[:LocalLogContentLimit], len(content), LocalLogContentLimit)
|
||||
}
|
||||
|
||||
func GetStringIfEmpty(str string, defaultValue string) string {
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func GetRandomString(length int) string {
|
||||
if length <= 0 {
|
||||
return ""
|
||||
}
|
||||
return lo.RandomString(length, lo.AlphanumericCharset)
|
||||
}
|
||||
|
||||
func MapToJsonStr(m map[string]any) string {
|
||||
bytes, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
func StrToMap(str string) (map[string]any, error) {
|
||||
m := make(map[string]any)
|
||||
err := Unmarshal([]byte(str), &m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func StrToJsonArray(str string) ([]any, error) {
|
||||
var js []any
|
||||
err := json.Unmarshal([]byte(str), &js)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return js, nil
|
||||
}
|
||||
|
||||
func IsJsonArray(str string) bool {
|
||||
var js []any
|
||||
return json.Unmarshal([]byte(str), &js) == nil
|
||||
}
|
||||
|
||||
func IsJsonObject(str string) bool {
|
||||
var js map[string]any
|
||||
return json.Unmarshal([]byte(str), &js) == nil
|
||||
}
|
||||
|
||||
func String2Int(str string) int {
|
||||
num, err := strconv.Atoi(str)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func StringsContains(strs []string, str string) bool {
|
||||
return slices.Contains(strs, str)
|
||||
}
|
||||
|
||||
// StringToByteSlice []byte only read, panic on append
|
||||
func StringToByteSlice(s string) []byte {
|
||||
tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))
|
||||
tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
|
||||
return *(*[]byte)(unsafe.Pointer(&tmp2))
|
||||
}
|
||||
|
||||
func EncodeBase64(str string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(str))
|
||||
}
|
||||
|
||||
func GetJsonString(data any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// NormalizeBillingPreference clamps the billing preference to valid values.
|
||||
func NormalizeBillingPreference(pref string) string {
|
||||
switch strings.TrimSpace(pref) {
|
||||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||||
return strings.TrimSpace(pref)
|
||||
default:
|
||||
return "subscription_first"
|
||||
}
|
||||
}
|
||||
|
||||
// MaskEmail masks a user email to prevent PII leakage in logs
|
||||
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
||||
func MaskEmail(email string) string {
|
||||
if email == "" {
|
||||
return "***masked***"
|
||||
}
|
||||
|
||||
// Find the @ symbol
|
||||
_, after, ok := strings.Cut(email, "@")
|
||||
if !ok {
|
||||
// No @ symbol found, return masked
|
||||
return "***masked***"
|
||||
}
|
||||
|
||||
// Return only the domain part with @ symbol
|
||||
return "***@" + after
|
||||
}
|
||||
|
||||
// MaskSensitiveInfo moved to the conversion kit (kitutil) because the types
|
||||
// package error formatting depends on it; host callers keep this name.
|
||||
func MaskSensitiveInfo(str string) string {
|
||||
return kitutil.MaskSensitiveInfo(str)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LogWriterMu protects concurrent access to gin.DefaultWriter/gin.DefaultErrorWriter
|
||||
// during log file rotation. Acquire RLock when reading/writing through the writers,
|
||||
// acquire Lock when swapping writers and closing old files.
|
||||
var LogWriterMu sync.RWMutex
|
||||
|
||||
func SysLog(s string) {
|
||||
t := time.Now()
|
||||
LogWriterMu.RLock()
|
||||
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
LogWriterMu.RUnlock()
|
||||
}
|
||||
|
||||
func SysError(s string) {
|
||||
t := time.Now()
|
||||
LogWriterMu.RLock()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
LogWriterMu.RUnlock()
|
||||
}
|
||||
|
||||
func FatalLog(v ...any) {
|
||||
t := time.Now()
|
||||
LogWriterMu.RLock()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
||||
LogWriterMu.RUnlock()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func LogStartupSuccess(startTime time.Time, port string) {
|
||||
duration := time.Since(startTime)
|
||||
durationMs := duration.Milliseconds()
|
||||
|
||||
// Get network IPs
|
||||
networkIps := GetNetworkIps()
|
||||
|
||||
LogWriterMu.RLock()
|
||||
defer LogWriterMu.RUnlock()
|
||||
|
||||
if SessionCookieSecure == false {
|
||||
// Warn when the local HTTP compatibility mode disables cookie transport
|
||||
// security and refresh/logout Origin validation.
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
|
||||
if !IsRunningInContainer() {
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
|
||||
}
|
||||
|
||||
for _, ip := range networkIps {
|
||||
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
|
||||
}
|
||||
|
||||
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/cpu"
|
||||
"github.com/shirou/gopsutil/mem"
|
||||
)
|
||||
|
||||
// DiskSpaceInfo 磁盘空间信息
|
||||
type DiskSpaceInfo struct {
|
||||
// 总空间(字节)
|
||||
Total uint64 `json:"total"`
|
||||
// 可用空间(字节)
|
||||
Free uint64 `json:"free"`
|
||||
// 已用空间(字节)
|
||||
Used uint64 `json:"used"`
|
||||
// 使用百分比
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
}
|
||||
|
||||
// SystemStatus 系统状态信息
|
||||
type SystemStatus struct {
|
||||
CPUUsage float64
|
||||
MemoryUsage float64
|
||||
DiskUsage float64
|
||||
}
|
||||
|
||||
var latestSystemStatus atomic.Value
|
||||
|
||||
func init() {
|
||||
latestSystemStatus.Store(SystemStatus{})
|
||||
}
|
||||
|
||||
// StartSystemMonitor 启动系统监控
|
||||
func StartSystemMonitor() {
|
||||
go func() {
|
||||
for {
|
||||
config := GetPerformanceMonitorConfig()
|
||||
if !config.Enabled {
|
||||
time.Sleep(30 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
updateSystemStatus()
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func updateSystemStatus() {
|
||||
var status SystemStatus
|
||||
|
||||
// CPU
|
||||
// 注意:cpu.Percent(0, false) 返回自上次调用以来的 CPU 使用率
|
||||
// 如果是第一次调用,可能会返回错误或不准确的值,但在循环中会逐渐正常
|
||||
percents, err := cpu.Percent(0, false)
|
||||
if err == nil && len(percents) > 0 {
|
||||
status.CPUUsage = percents[0]
|
||||
}
|
||||
|
||||
// Memory
|
||||
memInfo, err := mem.VirtualMemory()
|
||||
if err == nil {
|
||||
status.MemoryUsage = memInfo.UsedPercent
|
||||
}
|
||||
|
||||
// Disk
|
||||
diskInfo := GetDiskSpaceInfo()
|
||||
if diskInfo.Total > 0 {
|
||||
status.DiskUsage = diskInfo.UsedPercent
|
||||
}
|
||||
|
||||
latestSystemStatus.Store(status)
|
||||
}
|
||||
|
||||
// GetSystemStatus 获取当前系统状态
|
||||
func GetSystemStatus() SystemStatus {
|
||||
return latestSystemStatus.Load().(SystemStatus)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build !windows
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// GetDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Unix/Linux/macOS)
|
||||
func GetDiskSpaceInfo() DiskSpaceInfo {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
|
||||
info := DiskSpaceInfo{}
|
||||
|
||||
var stat unix.Statfs_t
|
||||
err := unix.Statfs(cachePath, &stat)
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
// 计算磁盘空间 (显式转换以兼容 FreeBSD,其字段类型为 int64)
|
||||
bsize := uint64(stat.Bsize)
|
||||
info.Total = uint64(stat.Blocks) * bsize
|
||||
info.Free = uint64(stat.Bavail) * bsize
|
||||
info.Used = info.Total - uint64(stat.Bfree)*bsize
|
||||
|
||||
if info.Total > 0 {
|
||||
info.UsedPercent = float64(info.Used) / float64(info.Total) * 100
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build windows
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// GetDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Windows)
|
||||
func GetDiskSpaceInfo() DiskSpaceInfo {
|
||||
cachePath := GetDiskCachePath()
|
||||
if cachePath == "" {
|
||||
cachePath = os.TempDir()
|
||||
}
|
||||
|
||||
info := DiskSpaceInfo{}
|
||||
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW")
|
||||
|
||||
var freeBytesAvailable, totalBytes, totalFreeBytes uint64
|
||||
|
||||
pathPtr, err := syscall.UTF16PtrFromString(cachePath)
|
||||
if err != nil {
|
||||
return info
|
||||
}
|
||||
|
||||
ret, _, _ := getDiskFreeSpaceEx.Call(
|
||||
uintptr(unsafe.Pointer(pathPtr)),
|
||||
uintptr(unsafe.Pointer(&freeBytesAvailable)),
|
||||
uintptr(unsafe.Pointer(&totalBytes)),
|
||||
uintptr(unsafe.Pointer(&totalFreeBytes)),
|
||||
)
|
||||
|
||||
if ret == 0 {
|
||||
return info
|
||||
}
|
||||
|
||||
info.Total = totalBytes
|
||||
info.Free = freeBytesAvailable
|
||||
info.Used = totalBytes - totalFreeBytes
|
||||
|
||||
if info.Total > 0 {
|
||||
info.UsedPercent = float64(info.Used) / float64(info.Total) * 100
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var topupGroupRatio = map[string]float64{
|
||||
"default": 1,
|
||||
"vip": 1,
|
||||
"svip": 1,
|
||||
}
|
||||
var topupGroupRatioMutex sync.RWMutex
|
||||
|
||||
func TopupGroupRatio2JSONString() string {
|
||||
topupGroupRatioMutex.RLock()
|
||||
defer topupGroupRatioMutex.RUnlock()
|
||||
jsonBytes, err := json.Marshal(topupGroupRatio)
|
||||
if err != nil {
|
||||
SysError("error marshalling topup group ratio: " + err.Error())
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func UpdateTopupGroupRatioByJSONString(jsonStr string) error {
|
||||
topupGroupRatioMutex.Lock()
|
||||
defer topupGroupRatioMutex.Unlock()
|
||||
topupGroupRatio = make(map[string]float64)
|
||||
return json.Unmarshal([]byte(jsonStr), &topupGroupRatio)
|
||||
}
|
||||
|
||||
func GetTopupGroupRatio(name string) float64 {
|
||||
topupGroupRatioMutex.RLock()
|
||||
defer topupGroupRatioMutex.RUnlock()
|
||||
ratio, ok := topupGroupRatio[name]
|
||||
if !ok {
|
||||
SysError("topup group ratio not found: " + name)
|
||||
return 1
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const (
|
||||
// 备用码配置
|
||||
BackupCodeLength = 8 // 备用码长度
|
||||
BackupCodeCount = 4 // 生成备用码数量
|
||||
|
||||
// 限制配置
|
||||
MaxFailAttempts = 5 // 最大失败尝试次数
|
||||
LockoutDuration = 300 // 锁定时间(秒)
|
||||
)
|
||||
|
||||
// GenerateTOTPSecret 生成TOTP密钥和配置
|
||||
func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
|
||||
issuer := Get2FAIssuer()
|
||||
return totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: accountName,
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateTOTPCode 验证TOTP验证码
|
||||
func ValidateTOTPCode(secret, code string) bool {
|
||||
// 清理验证码格式
|
||||
cleanCode := strings.ReplaceAll(code, " ", "")
|
||||
if len(cleanCode) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
return totp.Validate(cleanCode, secret)
|
||||
}
|
||||
|
||||
// GenerateBackupCodes 生成备用恢复码
|
||||
func GenerateBackupCodes() ([]string, error) {
|
||||
codes := make([]string, BackupCodeCount)
|
||||
|
||||
for i := range BackupCodeCount {
|
||||
code, err := generateRandomBackupCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes[i] = code
|
||||
}
|
||||
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// generateRandomBackupCode 生成单个备用码
|
||||
func generateRandomBackupCode() (string, error) {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
code := make([]byte, BackupCodeLength)
|
||||
|
||||
for i := range code {
|
||||
randomBytes := make([]byte, 1)
|
||||
_, err := rand.Read(randomBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
code[i] = charset[int(randomBytes[0])%len(charset)]
|
||||
}
|
||||
|
||||
// 格式化为 XXXX-XXXX 格式
|
||||
return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
|
||||
}
|
||||
|
||||
// ValidateBackupCode 验证备用码格式
|
||||
func ValidateBackupCode(code string) bool {
|
||||
// 移除所有分隔符并转为大写
|
||||
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
||||
if len(cleanCode) != BackupCodeLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查字符是否合法
|
||||
for _, char := range cleanCode {
|
||||
if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// NormalizeBackupCode 标准化备用码格式
|
||||
func NormalizeBackupCode(code string) string {
|
||||
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
||||
if len(cleanCode) == BackupCodeLength {
|
||||
return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// HashBackupCode 对备用码进行哈希
|
||||
func HashBackupCode(code string) (string, error) {
|
||||
normalizedCode := NormalizeBackupCode(code)
|
||||
return Password2Hash(normalizedCode)
|
||||
}
|
||||
|
||||
// Get2FAIssuer 获取2FA发行者名称
|
||||
func Get2FAIssuer() string {
|
||||
return SystemName
|
||||
}
|
||||
|
||||
// getEnvOrDefault 获取环境变量或默认值
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ValidateNumericCode 验证数字验证码格式
|
||||
func ValidateNumericCode(code string) (string, error) {
|
||||
// 移除空格
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
if len(code) != 6 {
|
||||
return "", fmt.Errorf("验证码必须是6位数字")
|
||||
}
|
||||
|
||||
// 检查是否为纯数字
|
||||
if _, err := strconv.Atoi(code); err != nil {
|
||||
return "", fmt.Errorf("验证码只能包含数字")
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// GenerateQRCodeData 生成二维码数据
|
||||
func GenerateQRCodeData(secret, username string) string {
|
||||
issuer := Get2FAIssuer()
|
||||
accountName := fmt.Sprintf("%s (%s)", username, issuer)
|
||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
|
||||
issuer, accountName, secret, issuer)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var defaultTrustedProxyCIDRs = []string{
|
||||
"127.0.0.0/8",
|
||||
"::1",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"fc00::/7",
|
||||
}
|
||||
|
||||
// ResolveTrustedProxies parses TRUSTED_PROXIES without applying it to an
|
||||
// engine. The returned slice can be reused by the outer and plugin engines.
|
||||
func ResolveTrustedProxies(raw string) (trustedProxies []string, usedDefaults bool, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return append([]string(nil), defaultTrustedProxyCIDRs...), true, nil
|
||||
}
|
||||
if strings.EqualFold(raw, "none") {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
trustedProxies = make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trustedProxy := strings.TrimSpace(part)
|
||||
if trustedProxy == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(trustedProxy, "none") {
|
||||
return nil, false, errors.New("TRUSTED_PROXIES=none must be used alone")
|
||||
}
|
||||
trustedProxies = append(trustedProxies, trustedProxy)
|
||||
}
|
||||
if len(trustedProxies) == 0 {
|
||||
return nil, false, errors.New("TRUSTED_PROXIES does not contain an IP address or CIDR")
|
||||
}
|
||||
return trustedProxies, false, nil
|
||||
}
|
||||
|
||||
func ConfigureTrustedProxies(engine *gin.Engine, trustedProxies []string) error {
|
||||
if err := engine.SetTrustedProxies(trustedProxies); err != nil {
|
||||
return fmt.Errorf("invalid TRUSTED_PROXIES: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
// ValidateRedirectURL validates that a redirect URL is safe to use.
|
||||
// It checks that:
|
||||
// - The URL is properly formatted
|
||||
// - The scheme is either http or https
|
||||
// - The domain is in the trusted domains list (exact match or subdomain)
|
||||
//
|
||||
// Returns nil if the URL is valid and trusted, otherwise returns an error
|
||||
// describing why the validation failed.
|
||||
func ValidateRedirectURL(rawURL string) error {
|
||||
// Parse the URL
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %s", err.Error())
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return fmt.Errorf("invalid URL scheme: only http and https are allowed")
|
||||
}
|
||||
|
||||
domain := strings.ToLower(parsedURL.Hostname())
|
||||
|
||||
for _, trustedDomain := range constant.TrustedRedirectDomains {
|
||||
if domain == trustedDomain || strings.HasSuffix(domain, "."+trustedDomain) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("domain %s is not in the trusted domains list", domain)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateRedirectURL(t *testing.T) {
|
||||
// Save original trusted domains and restore after test
|
||||
originalDomains := constant.TrustedRedirectDomains
|
||||
defer func() {
|
||||
constant.TrustedRedirectDomains = originalDomains
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
trustedDomains []string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "exact domain match with https",
|
||||
url: "https://example.com/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "exact domain match with http",
|
||||
url: "http://example.com/callback",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "subdomain match",
|
||||
url: "https://sub.example.com/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive domain",
|
||||
url: "https://EXAMPLE.COM/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases - untrusted domain
|
||||
{
|
||||
name: "untrusted domain",
|
||||
url: "https://evil.com/phishing",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "not in the trusted domains list",
|
||||
},
|
||||
{
|
||||
name: "suffix attack - fakeexample.com",
|
||||
url: "https://fakeexample.com/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "not in the trusted domains list",
|
||||
},
|
||||
{
|
||||
name: "empty trusted domains list",
|
||||
url: "https://example.com/success",
|
||||
trustedDomains: []string{},
|
||||
wantErr: true,
|
||||
errContains: "not in the trusted domains list",
|
||||
},
|
||||
|
||||
// Invalid cases - scheme
|
||||
{
|
||||
name: "javascript scheme",
|
||||
url: "javascript:alert('xss')",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "invalid URL scheme",
|
||||
},
|
||||
{
|
||||
name: "data scheme",
|
||||
url: "data:text/html,<script>alert('xss')</script>",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "invalid URL scheme",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty URL",
|
||||
url: "",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "invalid URL scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up trusted domains for this test case
|
||||
constant.TrustedRedirectDomains = tt.trustedDomains
|
||||
|
||||
err := ValidateRedirectURL(tt.url)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateRedirectURL(%q) expected error containing %q, got nil", tt.url, tt.errContains)
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("ValidateRedirectURL(%q) error = %q, want error containing %q", tt.url, err.Error(), tt.errContains)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ValidateRedirectURL(%q) unexpected error: %v", tt.url, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resetSessionCookieSettingsAfterTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
SessionCookieSecure = false
|
||||
SessionCookieTrustedURLs = nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsDefaultsToInsecure(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
|
||||
|
||||
require.NoError(t, InitSessionCookieSettings())
|
||||
assert.False(t, SessionCookieSecure)
|
||||
assert.Empty(t, SessionCookieTrustedURLs)
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsRequiresBothEnvVars(t *testing.T) {
|
||||
t.Run("secure without trusted url", func(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
|
||||
|
||||
require.Error(t, InitSessionCookieSettings())
|
||||
})
|
||||
|
||||
t.Run("trusted url without secure", func(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com")
|
||||
|
||||
require.Error(t, InitSessionCookieSettings())
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsRequiresHTTPSURL(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "http://example.com")
|
||||
|
||||
require.Error(t, InitSessionCookieSettings())
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsEnablesSecureCookie(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com")
|
||||
|
||||
require.NoError(t, InitSessionCookieSettings())
|
||||
assert.True(t, SessionCookieSecure)
|
||||
assert.Equal(t, []string{"https://example.com"}, SessionCookieTrustedURLs)
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsAllowsMultipleTrustedURLs(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com, https://admin.example.com")
|
||||
|
||||
require.NoError(t, InitSessionCookieSettings())
|
||||
assert.True(t, SessionCookieSecure)
|
||||
assert.Equal(t, []string{"https://example.com", "https://admin.example.com"}, SessionCookieTrustedURLs)
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsRejectsEmptyTrustedURLInList(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://example.com,")
|
||||
|
||||
require.Error(t, InitSessionCookieSettings())
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsNormalizesExactOrigins(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://EXAMPLE.com:443,https://admin.example.com:8443/")
|
||||
|
||||
require.NoError(t, InitSessionCookieSettings())
|
||||
assert.Equal(t, []string{"https://example.com", "https://admin.example.com:8443"}, SessionCookieTrustedURLs)
|
||||
}
|
||||
|
||||
func TestInitSessionCookieSettingsRejectsNonOriginURLs(t *testing.T) {
|
||||
for _, trustedURL := range []string{
|
||||
"https://*.example.com",
|
||||
"https://user@example.com",
|
||||
"https://example.com/admin",
|
||||
"https://example.com?next=admin",
|
||||
"https://example.com#admin",
|
||||
} {
|
||||
t.Run(trustedURL, func(t *testing.T) {
|
||||
resetSessionCookieSettingsAfterTest(t)
|
||||
t.Setenv("SESSION_COOKIE_SECURE", "true")
|
||||
t.Setenv("SESSION_COOKIE_TRUSTED_URL", trustedURL)
|
||||
require.Error(t, InitSessionCookieSettings())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestInitUserSessionSettingsUsesPositiveFallbacksAndClampsWindow(t *testing.T) {
|
||||
previousActiveLimit := UserSessionActiveLimit
|
||||
previousIssuanceLimit := UserSessionIssuanceLimit
|
||||
previousIssuanceWindow := UserSessionIssuanceWindowSeconds
|
||||
previousRevokedRetention := UserSessionRevokedRetentionDays
|
||||
previousAlertThreshold := UserSessionHourlyAlertThreshold
|
||||
t.Cleanup(func() {
|
||||
UserSessionActiveLimit = previousActiveLimit
|
||||
UserSessionIssuanceLimit = previousIssuanceLimit
|
||||
UserSessionIssuanceWindowSeconds = previousIssuanceWindow
|
||||
UserSessionRevokedRetentionDays = previousRevokedRetention
|
||||
UserSessionHourlyAlertThreshold = previousAlertThreshold
|
||||
})
|
||||
|
||||
t.Setenv("USER_SESSION_ACTIVE_LIMIT", "0")
|
||||
t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "-2")
|
||||
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "invalid")
|
||||
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "0")
|
||||
t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "-1")
|
||||
initUserSessionSettings()
|
||||
|
||||
assert.Equal(t, DefaultUserSessionActiveLimit, UserSessionActiveLimit)
|
||||
assert.Equal(t, DefaultUserSessionIssuanceLimit, UserSessionIssuanceLimit)
|
||||
assert.Equal(t, int64(DefaultUserSessionIssuanceWindowSeconds), UserSessionIssuanceWindowSeconds)
|
||||
assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays)
|
||||
assert.Equal(t, DefaultUserSessionHourlyAlertThreshold, UserSessionHourlyAlertThreshold)
|
||||
|
||||
t.Setenv("USER_SESSION_ACTIVE_LIMIT", "12")
|
||||
t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "34")
|
||||
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "172800")
|
||||
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "1")
|
||||
t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "56")
|
||||
initUserSessionSettings()
|
||||
|
||||
assert.Equal(t, 12, UserSessionActiveLimit)
|
||||
assert.Equal(t, 34, UserSessionIssuanceLimit)
|
||||
assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds)
|
||||
assert.Equal(t, 1, UserSessionRevokedRetentionDays)
|
||||
assert.Equal(t, 56, UserSessionHourlyAlertThreshold)
|
||||
|
||||
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "43200")
|
||||
initUserSessionSettings()
|
||||
assert.Equal(t, int64(12*60*60), UserSessionIssuanceWindowSeconds, "a window below retention remains unchanged")
|
||||
|
||||
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "86400")
|
||||
initUserSessionSettings()
|
||||
assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds, "a window equal to retention remains unchanged")
|
||||
|
||||
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "9223372036854775807")
|
||||
initUserSessionSettings()
|
||||
assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays)
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func OpenBrowser(url string) {
|
||||
var err error
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
err = exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
err = exec.Command("open", url).Start()
|
||||
}
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func GetIp() (ip string) {
|
||||
ips, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return ip
|
||||
}
|
||||
|
||||
for _, a := range ips {
|
||||
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
if ipNet.IP.To4() != nil {
|
||||
ip = ipNet.IP.String()
|
||||
if strings.HasPrefix(ip, "10") {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(ip, "172") {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(ip, "192.168") {
|
||||
return
|
||||
}
|
||||
ip = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func GetNetworkIps() []string {
|
||||
var networkIps []string
|
||||
ips, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return networkIps
|
||||
}
|
||||
|
||||
for _, a := range ips {
|
||||
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||
if ipNet.IP.To4() != nil {
|
||||
ip := ipNet.IP.String()
|
||||
// Include common private network ranges
|
||||
if strings.HasPrefix(ip, "10.") ||
|
||||
strings.HasPrefix(ip, "172.") ||
|
||||
strings.HasPrefix(ip, "192.168.") {
|
||||
networkIps = append(networkIps, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return networkIps
|
||||
}
|
||||
|
||||
// IsRunningInContainer detects if the application is running inside a container
|
||||
func IsRunningInContainer() bool {
|
||||
// Method 1: Check for .dockerenv file (Docker containers)
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Method 2: Check cgroup for container indicators
|
||||
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
||||
content := string(data)
|
||||
if strings.Contains(content, "docker") ||
|
||||
strings.Contains(content, "containerd") ||
|
||||
strings.Contains(content, "kubepods") ||
|
||||
strings.Contains(content, "/lxc/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Check environment variables commonly set by container runtimes
|
||||
containerEnvVars := []string{
|
||||
"KUBERNETES_SERVICE_HOST",
|
||||
"DOCKER_CONTAINER",
|
||||
"container",
|
||||
}
|
||||
|
||||
for _, envVar := range containerEnvVars {
|
||||
if os.Getenv(envVar) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Check if init process is not the traditional init
|
||||
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
||||
comm := strings.TrimSpace(string(data))
|
||||
// In containers, process 1 is often not "init" or "systemd"
|
||||
if comm != "init" && comm != "systemd" {
|
||||
// Additional check: if it's a common container entrypoint
|
||||
if strings.Contains(comm, "docker") ||
|
||||
strings.Contains(comm, "containerd") ||
|
||||
strings.Contains(comm, "runc") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var sizeKB = 1024
|
||||
var sizeMB = sizeKB * 1024
|
||||
var sizeGB = sizeMB * 1024
|
||||
|
||||
func Bytes2Size(num int64) string {
|
||||
numStr := ""
|
||||
unit := "B"
|
||||
if num/int64(sizeGB) > 1 {
|
||||
numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
|
||||
unit = "GB"
|
||||
} else if num/int64(sizeMB) > 1 {
|
||||
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
|
||||
unit = "MB"
|
||||
} else if num/int64(sizeKB) > 1 {
|
||||
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
|
||||
unit = "KB"
|
||||
} else {
|
||||
numStr = fmt.Sprintf("%d", num)
|
||||
}
|
||||
return numStr + " " + unit
|
||||
}
|
||||
|
||||
func Seconds2Time(num int) (time string) {
|
||||
if num/31104000 > 0 {
|
||||
time += strconv.Itoa(num/31104000) + " 年 "
|
||||
num %= 31104000
|
||||
}
|
||||
if num/2592000 > 0 {
|
||||
time += strconv.Itoa(num/2592000) + " 个月 "
|
||||
num %= 2592000
|
||||
}
|
||||
if num/86400 > 0 {
|
||||
time += strconv.Itoa(num/86400) + " 天 "
|
||||
num %= 86400
|
||||
}
|
||||
if num/3600 > 0 {
|
||||
time += strconv.Itoa(num/3600) + " 小时 "
|
||||
num %= 3600
|
||||
}
|
||||
if num/60 > 0 {
|
||||
time += strconv.Itoa(num/60) + " 分钟 "
|
||||
num %= 60
|
||||
}
|
||||
time += strconv.Itoa(num) + " 秒"
|
||||
return
|
||||
}
|
||||
|
||||
func Interface2String(inter any) string {
|
||||
switch inter.(type) {
|
||||
case string:
|
||||
return inter.(string)
|
||||
case int:
|
||||
return fmt.Sprintf("%d", inter.(int))
|
||||
case float64:
|
||||
return strconv.FormatFloat(inter.(float64), 'f', -1, 64)
|
||||
case bool:
|
||||
if inter.(bool) {
|
||||
return "true"
|
||||
} else {
|
||||
return "false"
|
||||
}
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", inter)
|
||||
}
|
||||
|
||||
func UnescapeHTML(x string) any {
|
||||
return template.HTML(x)
|
||||
}
|
||||
|
||||
func IntMax(a int, b int) int {
|
||||
if a >= b {
|
||||
return a
|
||||
} else {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
func GetUUID() string {
|
||||
code := uuid.New().String()
|
||||
code = strings.Replace(code, "-", "", -1)
|
||||
return code
|
||||
}
|
||||
|
||||
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
func GenerateRandomCharsKey(length int) (string, error) {
|
||||
b := make([]byte, length)
|
||||
maxI := big.NewInt(int64(len(keyChars)))
|
||||
|
||||
for i := range b {
|
||||
n, err := crand.Int(crand.Reader, maxI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[i] = keyChars[n.Int64()]
|
||||
}
|
||||
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func GenerateRandomKey(length int) (string, error) {
|
||||
bytes := make([]byte, length*3/4) // 对于48位的输出,这里应该是36
|
||||
if _, err := crand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateKey() (string, error) {
|
||||
//rand.Seed(time.Now().UnixNano())
|
||||
return GenerateRandomCharsKey(48)
|
||||
}
|
||||
|
||||
func GetRandomInt(max int) int {
|
||||
//rand.Seed(time.Now().UnixNano())
|
||||
return rand.Intn(max)
|
||||
}
|
||||
|
||||
func GetTimestamp() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
func GetTimeString() string {
|
||||
now := time.Now().UTC()
|
||||
return fmt.Sprintf("%s%09d", now.Format("20060102150405"), now.UnixNano()%1e9)
|
||||
}
|
||||
|
||||
var requestIdPrefix = func() string {
|
||||
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Path != "" {
|
||||
h := sha256.Sum256([]byte(bi.Main.Path))
|
||||
return hex.EncodeToString(h[:4])
|
||||
}
|
||||
return GetRandomString(8)
|
||||
}()
|
||||
|
||||
func NewRequestId() string {
|
||||
return GetTimeString() + requestIdPrefix + GetRandomString(8)
|
||||
}
|
||||
|
||||
func Max(a int, b int) int {
|
||||
if a >= b {
|
||||
return a
|
||||
} else {
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
func MessageWithRequestId(message string, id string) string {
|
||||
return fmt.Sprintf("%s (request id: %s)", message, id)
|
||||
}
|
||||
|
||||
func RandomSleep() {
|
||||
// Sleep for 0-3000 ms
|
||||
time.Sleep(time.Duration(rand.Intn(3000)) * time.Millisecond)
|
||||
}
|
||||
|
||||
func GetPointer[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func Any2Type[T any](data any) (T, error) {
|
||||
var zero T
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var res T
|
||||
err = json.Unmarshal(bytes, &res)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// SaveTmpFile saves data to a temporary file. The filename would be apppended with a random string.
|
||||
func SaveTmpFile(filename string, data io.Reader) (string, error) {
|
||||
f, err := os.CreateTemp(os.TempDir(), filename)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to create temporary file %s", filename)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(f, data)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to copy data to temporary file %s", filename)
|
||||
}
|
||||
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// BuildURL concatenates base and endpoint, returns the complete url string
|
||||
func BuildURL(base string, endpoint string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base + endpoint
|
||||
}
|
||||
end := endpoint
|
||||
if end == "" {
|
||||
end = "/"
|
||||
}
|
||||
ref, err := url.Parse(end)
|
||||
if err != nil {
|
||||
return base + endpoint
|
||||
}
|
||||
return u.ResolveReference(ref).String()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package common
|
||||
|
||||
import "github.com/go-playground/validator/v10"
|
||||
|
||||
var Validate *validator.Validate
|
||||
|
||||
func init() {
|
||||
Validate = validator.New()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type verificationValue struct {
|
||||
code string
|
||||
time time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
EmailVerificationPurpose = "v"
|
||||
PasswordResetPurpose = "r"
|
||||
)
|
||||
|
||||
var verificationMutex sync.Mutex
|
||||
var verificationMap map[string]verificationValue
|
||||
var verificationMapMaxSize = 10
|
||||
var VerificationValidMinutes = 10
|
||||
|
||||
func GenerateVerificationCode(length int) string {
|
||||
code := uuid.New().String()
|
||||
code = strings.Replace(code, "-", "", -1)
|
||||
if length == 0 {
|
||||
return code
|
||||
}
|
||||
return code[:length]
|
||||
}
|
||||
|
||||
func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
|
||||
verificationMutex.Lock()
|
||||
defer verificationMutex.Unlock()
|
||||
verificationMap[purpose+key] = verificationValue{
|
||||
code: code,
|
||||
time: time.Now(),
|
||||
}
|
||||
if len(verificationMap) > verificationMapMaxSize {
|
||||
removeExpiredPairs()
|
||||
}
|
||||
}
|
||||
|
||||
func VerifyCodeWithKey(key string, code string, purpose string) bool {
|
||||
verificationMutex.Lock()
|
||||
defer verificationMutex.Unlock()
|
||||
value, okay := verificationMap[purpose+key]
|
||||
now := time.Now()
|
||||
if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
|
||||
return false
|
||||
}
|
||||
return code == value.code
|
||||
}
|
||||
|
||||
func DeleteKey(key string, purpose string) {
|
||||
verificationMutex.Lock()
|
||||
defer verificationMutex.Unlock()
|
||||
delete(verificationMap, purpose+key)
|
||||
}
|
||||
|
||||
// no lock inside, so the caller must lock the verificationMap before calling!
|
||||
func removeExpiredPairs() {
|
||||
now := time.Now()
|
||||
for key := range verificationMap {
|
||||
if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
|
||||
delete(verificationMap, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
verificationMutex.Lock()
|
||||
defer verificationMutex.Unlock()
|
||||
verificationMap = make(map[string]verificationValue)
|
||||
}
|
||||
Reference in new issue
Block a user