Merge branches 'main' and 'main' of github.com:danding5/new-api
# Conflicts: # common/api_type.go # constant/api_type.go # constant/channel.go # relay/relay_adaptor.go # web/src/constants/channel.constants.js
This commit is contained in:
@@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
||||
apiType = constant.APITypeCoze
|
||||
case constant.ChannelTypeJimeng:
|
||||
apiType = constant.APITypeJimeng
|
||||
case constant.ChannelTypeMoonshot:
|
||||
apiType = constant.APITypeMoonshot
|
||||
case constant.ChannelTypeSubmodel:
|
||||
apiType = constant.APITypeSubmodel
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ var GitHubClientId = ""
|
||||
var GitHubClientSecret = ""
|
||||
var LinuxDOClientId = ""
|
||||
var LinuxDOClientSecret = ""
|
||||
var LinuxDOMinimumTrustLevel = 0
|
||||
|
||||
var WeChatServerAddress = ""
|
||||
var WeChatServerToken = ""
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"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})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dst, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type stringWriter interface {
|
||||
@@ -52,6 +53,8 @@ type CustomEvent struct {
|
||||
Id string
|
||||
Retry uint
|
||||
Data interface{}
|
||||
|
||||
Mutex sync.Mutex
|
||||
}
|
||||
|
||||
func encode(writer io.Writer, event CustomEvent) error {
|
||||
@@ -73,6 +76,8 @@ func (r CustomEvent) Render(w http.ResponseWriter) error {
|
||||
}
|
||||
|
||||
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
||||
r.Mutex.Lock()
|
||||
defer r.Mutex.Unlock()
|
||||
header := w.Header()
|
||||
header["Content-Type"] = contentType
|
||||
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
||||
var UsingMySQL = false
|
||||
var UsingClickHouse = false
|
||||
|
||||
var SQLitePath = "one-api.db?_busy_timeout=5000"
|
||||
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package common
|
||||
|
||||
import "one-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.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
||||
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
||||
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
||||
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
||||
}
|
||||
|
||||
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
||||
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
||||
info, ok := defaultEndpointInfoMap[et]
|
||||
return info, ok
|
||||
}
|
||||
+5
-1
@@ -2,12 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/constant"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const KeyRequestBody = "key_request_body"
|
||||
@@ -31,6 +32,9 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
//if DebugEnabled {
|
||||
// println("UnmarshalBodyReusable request body:", string(requestBody))
|
||||
//}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, &v)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CloseResponseBodyGracefully(httpResponse *http.Response) {
|
||||
if httpResponse == nil || httpResponse.Body == nil {
|
||||
return
|
||||
}
|
||||
err := httpResponse.Body.Close()
|
||||
if err != nil {
|
||||
SysError("failed to close response body: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
|
||||
if c.Writer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
body := io.NopCloser(bytes.NewBuffer(data))
|
||||
|
||||
// We shouldn't set the header before we parse the response body, because the parse part may fail.
|
||||
// And then we will have to send an error response, but in this case, the header has already been set.
|
||||
// So the httpClient will be confused by the response.
|
||||
// For example, Postman will report error, and we cannot check the response at all.
|
||||
if src != nil {
|
||||
for k, v := range src.Header {
|
||||
// avoid setting Content-Length
|
||||
if k == "Content-Length" {
|
||||
continue
|
||||
}
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
}
|
||||
}
|
||||
|
||||
// set Content-Length header manually BEFORE calling WriteHeader
|
||||
c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
|
||||
// Write header with status code (this sends the headers)
|
||||
if src != nil {
|
||||
c.Writer.WriteHeader(src.StatusCode)
|
||||
} else {
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
_, err := io.Copy(c.Writer, body)
|
||||
if err != nil {
|
||||
LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -101,7 +101,7 @@ func InitEnv() {
|
||||
}
|
||||
|
||||
func initConstantEnv() {
|
||||
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120)
|
||||
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
|
||||
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
||||
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
|
||||
@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func GetJsonType(data json.RawMessage) string {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
firstChar := bytes.TrimSpace(data)[0]
|
||||
switch firstChar {
|
||||
case '{':
|
||||
return "object"
|
||||
case '[':
|
||||
return "array"
|
||||
case '"':
|
||||
return "string"
|
||||
case 't', 'f':
|
||||
return "boolean"
|
||||
case 'n':
|
||||
return "null"
|
||||
default:
|
||||
return "number"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
loggerINFO = "INFO"
|
||||
loggerWarn = "WARN"
|
||||
loggerError = "ERR"
|
||||
)
|
||||
|
||||
const maxLogCount = 1000000
|
||||
|
||||
var logCount int
|
||||
var setupLogLock sync.Mutex
|
||||
var setupLogWorking bool
|
||||
|
||||
func SetupLogger() {
|
||||
if *LogDir != "" {
|
||||
ok := setupLogLock.TryLock()
|
||||
if !ok {
|
||||
log.Println("setup log is already working")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
setupLogLock.Unlock()
|
||||
setupLogWorking = false
|
||||
}()
|
||||
logPath := filepath.Join(*LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
|
||||
fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("failed to open log file")
|
||||
}
|
||||
gin.DefaultWriter = io.MultiWriter(os.Stdout, fd)
|
||||
gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd)
|
||||
}
|
||||
}
|
||||
|
||||
func SysLog(s string) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
}
|
||||
|
||||
func SysError(s string) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
}
|
||||
|
||||
func LogInfo(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerINFO, msg)
|
||||
}
|
||||
|
||||
func LogWarn(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerWarn, msg)
|
||||
}
|
||||
|
||||
func LogError(ctx context.Context, msg string) {
|
||||
logHelper(ctx, loggerError, msg)
|
||||
}
|
||||
|
||||
func logHelper(ctx context.Context, level string, msg string) {
|
||||
writer := gin.DefaultErrorWriter
|
||||
if level == loggerINFO {
|
||||
writer = gin.DefaultWriter
|
||||
}
|
||||
id := ctx.Value(RequestIdKey)
|
||||
if id == nil {
|
||||
id = "SYSTEM"
|
||||
}
|
||||
now := time.Now()
|
||||
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)
|
||||
logCount++ // we don't need accurate count, so no lock here
|
||||
if logCount > maxLogCount && !setupLogWorking {
|
||||
logCount = 0
|
||||
setupLogWorking = true
|
||||
gopool.Go(func() {
|
||||
SetupLogger()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FatalLog(v ...any) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func LogQuota(quota int) string {
|
||||
if DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f 额度", float64(quota)/QuotaPerUnit)
|
||||
} else {
|
||||
return fmt.Sprintf("%d 点额度", quota)
|
||||
}
|
||||
}
|
||||
|
||||
func FormatQuota(quota int) string {
|
||||
if DisplayInCurrencyEnabled {
|
||||
return fmt.Sprintf("$%.6f", float64(quota)/QuotaPerUnit)
|
||||
} else {
|
||||
return fmt.Sprintf("%d", quota)
|
||||
}
|
||||
}
|
||||
|
||||
// LogJson 仅供测试使用 only for test
|
||||
func LogJson(ctx context.Context, msg string, obj any) {
|
||||
jsonStr, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
LogInfo(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
|
||||
}
|
||||
+1
-1
@@ -41,7 +41,7 @@ func (p *PageInfo) SetItems(items any) {
|
||||
func GetPageQuery(c *gin.Context) *PageInfo {
|
||||
pageInfo := &PageInfo{}
|
||||
// 手动获取并处理每个参数
|
||||
if page, err := strconv.Atoi(c.Query("page")); err == nil {
|
||||
if page, err := strconv.Atoi(c.Query("p")); err == nil {
|
||||
pageInfo.Page = page
|
||||
}
|
||||
if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
func GetTrustQuota() int {
|
||||
return int(10 * QuotaPerUnit)
|
||||
}
|
||||
+140
@@ -4,7 +4,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
@@ -95,3 +98,140 @@ func GetJsonString(data any) string {
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 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
|
||||
atIndex := strings.Index(email, "@")
|
||||
if atIndex == -1 {
|
||||
// No @ symbol found, return masked
|
||||
return "***masked***"
|
||||
}
|
||||
|
||||
// Return only the domain part with @ symbol
|
||||
return "***@" + email[atIndex+1:]
|
||||
}
|
||||
|
||||
// maskHostTail returns the tail parts of a domain/host that should be preserved.
|
||||
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
|
||||
func maskHostTail(parts []string) []string {
|
||||
if len(parts) < 2 {
|
||||
return parts
|
||||
}
|
||||
lastPart := parts[len(parts)-1]
|
||||
secondLastPart := parts[len(parts)-2]
|
||||
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
|
||||
// Likely country code TLD like co.uk, com.cn
|
||||
return []string{secondLastPart, lastPart}
|
||||
}
|
||||
return []string{lastPart}
|
||||
}
|
||||
|
||||
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
|
||||
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
|
||||
func maskHostForURL(host string) string {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 2 {
|
||||
return "***"
|
||||
}
|
||||
tail := maskHostTail(parts)
|
||||
return "***." + strings.Join(tail, ".")
|
||||
}
|
||||
|
||||
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
|
||||
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
|
||||
func maskHostForPlainDomain(domain string) string {
|
||||
parts := strings.Split(domain, ".")
|
||||
if len(parts) < 2 {
|
||||
return domain
|
||||
}
|
||||
tail := maskHostTail(parts)
|
||||
numStars := len(parts) - len(tail)
|
||||
if numStars < 1 {
|
||||
numStars = 1
|
||||
}
|
||||
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
|
||||
return stars + "." + strings.Join(tail, ".")
|
||||
}
|
||||
|
||||
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
|
||||
// Example:
|
||||
// http://example.com -> http://***.com
|
||||
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
|
||||
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
|
||||
// 192.168.1.1 -> ***.***.***.***
|
||||
// openai.com -> ***.com
|
||||
// www.openai.com -> ***.***.com
|
||||
// api.openai.com -> ***.***.com
|
||||
func MaskSensitiveInfo(str string) string {
|
||||
// Mask URLs
|
||||
urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
|
||||
str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
}
|
||||
|
||||
host := u.Host
|
||||
if host == "" {
|
||||
return urlStr
|
||||
}
|
||||
|
||||
// Mask host with unified logic
|
||||
maskedHost := maskHostForURL(host)
|
||||
|
||||
result := u.Scheme + "://" + maskedHost
|
||||
|
||||
// Mask path
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
maskedPathParts := make([]string, len(pathParts))
|
||||
for i := range pathParts {
|
||||
if pathParts[i] != "" {
|
||||
maskedPathParts[i] = "***"
|
||||
}
|
||||
}
|
||||
if len(maskedPathParts) > 0 {
|
||||
result += "/" + strings.Join(maskedPathParts, "/")
|
||||
}
|
||||
} else if u.Path == "/" {
|
||||
result += "/"
|
||||
}
|
||||
|
||||
// Mask query parameters
|
||||
if u.RawQuery != "" {
|
||||
values, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
// If can't parse query, just mask the whole query string
|
||||
result += "?***"
|
||||
} else {
|
||||
maskedParams := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
maskedParams = append(maskedParams, key+"=***")
|
||||
}
|
||||
if len(maskedParams) > 0 {
|
||||
result += "?" + strings.Join(maskedParams, "&")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Mask domain names without protocol (like openai.com, www.openai.com)
|
||||
domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
|
||||
str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string {
|
||||
return maskHostForPlainDomain(domain)
|
||||
})
|
||||
|
||||
// Mask IP addresses
|
||||
ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
||||
str = ipPattern.ReplaceAllString(str, "***.***.***.***")
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SysLog(s string) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
}
|
||||
|
||||
func SysError(s string) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
||||
}
|
||||
|
||||
func FatalLog(v ...any) {
|
||||
t := time.Now()
|
||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
||||
os.Exit(1)
|
||||
}
|
||||
+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 := 0; i < BackupCodeCount; i++ {
|
||||
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)
|
||||
}
|
||||
+33
-25
@@ -123,8 +123,16 @@ func Interface2String(inter interface{}) string {
|
||||
return fmt.Sprintf("%d", inter.(int))
|
||||
case float64:
|
||||
return fmt.Sprintf("%f", inter.(float64))
|
||||
case bool:
|
||||
if inter.(bool) {
|
||||
return "true"
|
||||
} else {
|
||||
return "false"
|
||||
}
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
return "Not Implemented"
|
||||
return fmt.Sprintf("%v", inter)
|
||||
}
|
||||
|
||||
func UnescapeHTML(x string) interface{} {
|
||||
@@ -257,32 +265,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration")
|
||||
}
|
||||
durationStr := string(bytes.TrimSpace(output))
|
||||
if durationStr == "N/A" {
|
||||
// Create a temporary output file name
|
||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create temporary file")
|
||||
}
|
||||
tmpName := tmpFp.Name()
|
||||
// Close immediately so ffmpeg can open the file on Windows.
|
||||
_ = tmpFp.Close()
|
||||
defer os.Remove(tmpName)
|
||||
durationStr := string(bytes.TrimSpace(output))
|
||||
if durationStr == "N/A" {
|
||||
// Create a temporary output file name
|
||||
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to create temporary file")
|
||||
}
|
||||
tmpName := tmpFp.Name()
|
||||
// Close immediately so ffmpeg can open the file on Windows.
|
||||
_ = tmpFp.Close()
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
||||
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
||||
if err := ffmpegCmd.Run(); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
||||
}
|
||||
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
||||
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
||||
if err := ffmpegCmd.Run(); err != nil {
|
||||
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
||||
}
|
||||
|
||||
// Recalculate the duration of the new file
|
||||
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
||||
}
|
||||
durationStr = string(bytes.TrimSpace(output))
|
||||
}
|
||||
// Recalculate the duration of the new file
|
||||
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
||||
output, err := c.Output()
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
||||
}
|
||||
durationStr = string(bytes.TrimSpace(output))
|
||||
}
|
||||
return strconv.ParseFloat(durationStr, 64)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user