luckfun233 commited on
Commit ·
33b8f8e
1
Parent(s): c37473d
feat: 增强管理员登录安全性,添加IP失败计数器以防止字典攻击,限制请求体大小,优化配置文件权限
Browse files- entrypoint.sh +1 -1
- internal/config/store.go +1 -1
- internal/config/store_env_writeback.go +12 -4
- internal/httpapi/admin/auth/handler_auth.go +7 -0
- internal/httpapi/admin/auth/login_rate_limit.go +109 -0
- internal/httpapi/admin/version/handler_version.go +13 -2
- internal/httpapi/claude/handler_messages.go +7 -2
- internal/httpapi/claude/handler_routes.go +4 -0
- internal/httpapi/claude/handler_tokens.go +7 -1
- internal/httpapi/gemini/handler_generate.go +7 -2
- internal/httpapi/gemini/handler_routes.go +4 -0
- internal/httpapi/ollama/handler_routes.go +5 -0
- internal/rawsample/rawsample.go +51 -0
- internal/webui/handler.go +2 -0
entrypoint.sh
CHANGED
|
@@ -14,7 +14,7 @@ fi
|
|
| 14 |
# was created by a previous container with different ownership (common on
|
| 15 |
# HuggingFace Spaces persistent storage after rebuild).
|
| 16 |
chown ds2api:ds2api /data/config.json 2>/dev/null || true
|
| 17 |
-
chmod
|
| 18 |
|
| 19 |
# Create chat history file if not exists or is empty
|
| 20 |
if [ ! -f /data/chat_history.json ] || [ ! -s /data/chat_history.json ]; then
|
|
|
|
| 14 |
# was created by a previous container with different ownership (common on
|
| 15 |
# HuggingFace Spaces persistent storage after rebuild).
|
| 16 |
chown ds2api:ds2api /data/config.json 2>/dev/null || true
|
| 17 |
+
chmod 600 /data/config.json 2>/dev/null || true
|
| 18 |
|
| 19 |
# Create chat history file if not exists or is empty
|
| 20 |
if [ ! -f /data/chat_history.json ] || [ ! -s /data/chat_history.json ]; then
|
internal/config/store.go
CHANGED
|
@@ -151,7 +151,7 @@ func loadConfigFromFile(path string) (Config, error) {
|
|
| 151 |
cfg.DropInvalidAccounts()
|
| 152 |
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
|
| 153 |
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
| 154 |
-
_ = overwriteFile(path, b,
|
| 155 |
}
|
| 156 |
}
|
| 157 |
// 加载成功后刷新可用备份(仅当当前文件合法时才覆盖备份,避免用损坏
|
|
|
|
| 151 |
cfg.DropInvalidAccounts()
|
| 152 |
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
|
| 153 |
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
| 154 |
+
_ = overwriteFile(path, b, configFilePerm())
|
| 155 |
}
|
| 156 |
}
|
| 157 |
// 加载成功后刷新可用备份(仅当当前文件合法时才覆盖备份,避免用损坏
|
internal/config/store_env_writeback.go
CHANGED
|
@@ -50,10 +50,16 @@ func overwriteFile(path string, b []byte, perm os.FileMode) error {
|
|
| 50 |
return nil
|
| 51 |
}
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
func writeConfigBytes(path string, b []byte) error {
|
| 54 |
dir := filepath.Dir(path)
|
| 55 |
if dir == "." || dir == "" {
|
| 56 |
-
return overwriteFile(path, b,
|
| 57 |
}
|
| 58 |
if err := os.MkdirAll(dir, 0o755); err != nil {
|
| 59 |
return fmt.Errorf("mkdir config dir: %w", err)
|
|
@@ -88,13 +94,15 @@ func writeConfigBytes(path string, b []byte) error {
|
|
| 88 |
// Try removing the old file and renaming again.
|
| 89 |
if removeErr := os.Remove(path); removeErr == nil {
|
| 90 |
if renameErr := os.Rename(tmpName, path); renameErr == nil {
|
|
|
|
| 91 |
return nil
|
| 92 |
}
|
| 93 |
}
|
| 94 |
// Rename still failing (e.g. cross-filesystem); fall back to direct write.
|
| 95 |
_ = os.Remove(tmpName)
|
| 96 |
-
return overwriteFile(path, b,
|
| 97 |
}
|
|
|
|
| 98 |
return nil
|
| 99 |
}
|
| 100 |
|
|
@@ -104,7 +112,7 @@ func backupConfigFile(path string, content []byte) error {
|
|
| 104 |
if len(content) == 0 {
|
| 105 |
return nil
|
| 106 |
}
|
| 107 |
-
return overwriteFile(path+".bak", content,
|
| 108 |
}
|
| 109 |
|
| 110 |
// resetConfigToEmpty 把 path 重置为 {},用于损坏文件无可恢复备份时的兜底。
|
|
@@ -123,7 +131,7 @@ func resetConfigToEmpty(path string) {
|
|
| 123 |
// config.json 被截断成半截 JSON)。
|
| 124 |
func recoverCorruptConfig(path string, corruptContent []byte) bool {
|
| 125 |
corruptPath := fmt.Sprintf("%s.corrupt.%d", path, time.Now().Unix())
|
| 126 |
-
if werr := overwriteFile(corruptPath, corruptContent,
|
| 127 |
Logger.Warn("[config] failed to preserve corrupt config for inspection", "path", corruptPath, "error", werr)
|
| 128 |
}
|
| 129 |
|
|
|
|
| 50 |
return nil
|
| 51 |
}
|
| 52 |
|
| 53 |
+
// configFilePerm 返回配置文件的权限位。
|
| 54 |
+
// 0600 (owner-only read/write) 防止同机其他用户读取明文凭据。
|
| 55 |
+
func configFilePerm() os.FileMode {
|
| 56 |
+
return 0o600
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
func writeConfigBytes(path string, b []byte) error {
|
| 60 |
dir := filepath.Dir(path)
|
| 61 |
if dir == "." || dir == "" {
|
| 62 |
+
return overwriteFile(path, b, configFilePerm())
|
| 63 |
}
|
| 64 |
if err := os.MkdirAll(dir, 0o755); err != nil {
|
| 65 |
return fmt.Errorf("mkdir config dir: %w", err)
|
|
|
|
| 94 |
// Try removing the old file and renaming again.
|
| 95 |
if removeErr := os.Remove(path); removeErr == nil {
|
| 96 |
if renameErr := os.Rename(tmpName, path); renameErr == nil {
|
| 97 |
+
_ = os.Chmod(path, configFilePerm())
|
| 98 |
return nil
|
| 99 |
}
|
| 100 |
}
|
| 101 |
// Rename still failing (e.g. cross-filesystem); fall back to direct write.
|
| 102 |
_ = os.Remove(tmpName)
|
| 103 |
+
return overwriteFile(path, b, configFilePerm())
|
| 104 |
}
|
| 105 |
+
_ = os.Chmod(path, configFilePerm())
|
| 106 |
return nil
|
| 107 |
}
|
| 108 |
|
|
|
|
| 112 |
if len(content) == 0 {
|
| 113 |
return nil
|
| 114 |
}
|
| 115 |
+
return overwriteFile(path+".bak", content, configFilePerm())
|
| 116 |
}
|
| 117 |
|
| 118 |
// resetConfigToEmpty 把 path 重置为 {},用于损坏文件无可恢复备份时的兜底。
|
|
|
|
| 131 |
// config.json 被截断成半截 JSON)。
|
| 132 |
func recoverCorruptConfig(path string, corruptContent []byte) bool {
|
| 133 |
corruptPath := fmt.Sprintf("%s.corrupt.%d", path, time.Now().Unix())
|
| 134 |
+
if werr := overwriteFile(corruptPath, corruptContent, configFilePerm()); werr != nil {
|
| 135 |
Logger.Warn("[config] failed to preserve corrupt config for inspection", "path", corruptPath, "error", werr)
|
| 136 |
}
|
| 137 |
|
internal/httpapi/admin/auth/handler_auth.go
CHANGED
|
@@ -21,14 +21,21 @@ func (h *Handler) requireAdmin(next http.Handler) http.Handler {
|
|
| 21 |
}
|
| 22 |
|
| 23 |
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
var req map[string]any
|
| 25 |
_ = json.NewDecoder(r.Body).Decode(&req)
|
| 26 |
adminKey, _ := req["admin_key"].(string)
|
| 27 |
expireHours := intFrom(req["expire_hours"])
|
| 28 |
if !authn.VerifyAdminCredential(adminKey, h.Store) {
|
|
|
|
| 29 |
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid admin key"})
|
| 30 |
return
|
| 31 |
}
|
|
|
|
| 32 |
token, err := authn.CreateJWTWithStore(expireHours, h.Store)
|
| 33 |
if err != nil {
|
| 34 |
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
| 24 |
+
ip := clientIP(r)
|
| 25 |
+
if !defaultLoginLimiter.allowLogin(ip) {
|
| 26 |
+
writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many failed login attempts. Please try again later."})
|
| 27 |
+
return
|
| 28 |
+
}
|
| 29 |
var req map[string]any
|
| 30 |
_ = json.NewDecoder(r.Body).Decode(&req)
|
| 31 |
adminKey, _ := req["admin_key"].(string)
|
| 32 |
expireHours := intFrom(req["expire_hours"])
|
| 33 |
if !authn.VerifyAdminCredential(adminKey, h.Store) {
|
| 34 |
+
defaultLoginLimiter.recordFailure(ip)
|
| 35 |
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid admin key"})
|
| 36 |
return
|
| 37 |
}
|
| 38 |
+
defaultLoginLimiter.recordSuccess(ip)
|
| 39 |
token, err := authn.CreateJWTWithStore(expireHours, h.Store)
|
| 40 |
if err != nil {
|
| 41 |
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
internal/httpapi/admin/auth/login_rate_limit.go
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package auth
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"sync"
|
| 6 |
+
"time"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
// loginRateLimiter 进程级 IP 失败计数器,防止 admin key 字典攻击。
|
| 10 |
+
// 同一 IP 在 window 内连续失败 maxFailures 次后,lockout 期间拒绝所有登录请求。
|
| 11 |
+
// 注意:HF Spaces 多副本部署时,每个副本独立计数,可被绕过。
|
| 12 |
+
type loginRateLimiter struct {
|
| 13 |
+
mu sync.Mutex
|
| 14 |
+
failures map[string]*loginFailState
|
| 15 |
+
maxFailures int
|
| 16 |
+
window time.Duration // 失败计数累计窗口
|
| 17 |
+
lockout time.Duration // 触发后封禁时长
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
type loginFailState struct {
|
| 21 |
+
count int
|
| 22 |
+
lastFailAt time.Time
|
| 23 |
+
lockoutUntil time.Time
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
var defaultLoginLimiter = newLoginRateLimiter(5, 5*time.Minute, 5*time.Minute)
|
| 27 |
+
|
| 28 |
+
func newLoginRateLimiter(maxFailures int, window, lockout time.Duration) *loginRateLimiter {
|
| 29 |
+
return &loginRateLimiter{
|
| 30 |
+
failures: map[string]*loginFailState{},
|
| 31 |
+
maxFailures: maxFailures,
|
| 32 |
+
window: window,
|
| 33 |
+
lockout: lockout,
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// allowLogin 检查 IP 是否被封禁。返回是否允许尝试登录。
|
| 38 |
+
func (l *loginRateLimiter) allowLogin(ip string) bool {
|
| 39 |
+
if ip == "" {
|
| 40 |
+
return true
|
| 41 |
+
}
|
| 42 |
+
l.mu.Lock()
|
| 43 |
+
defer l.mu.Unlock()
|
| 44 |
+
now := time.Now()
|
| 45 |
+
state, ok := l.failures[ip]
|
| 46 |
+
if !ok {
|
| 47 |
+
return true
|
| 48 |
+
}
|
| 49 |
+
if now.Before(state.lockoutUntil) {
|
| 50 |
+
return false
|
| 51 |
+
}
|
| 52 |
+
return true
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// recordFailure 记录一次登录失败。达到阈值后进入封禁。
|
| 56 |
+
func (l *loginRateLimiter) recordFailure(ip string) {
|
| 57 |
+
if ip == "" {
|
| 58 |
+
return
|
| 59 |
+
}
|
| 60 |
+
l.mu.Lock()
|
| 61 |
+
defer l.mu.Unlock()
|
| 62 |
+
now := time.Now()
|
| 63 |
+
state, ok := l.failures[ip]
|
| 64 |
+
if !ok || now.Sub(state.lastFailAt) > l.window {
|
| 65 |
+
state = &loginFailState{count: 0, lastFailAt: now}
|
| 66 |
+
l.failures[ip] = state
|
| 67 |
+
}
|
| 68 |
+
state.count++
|
| 69 |
+
state.lastFailAt = now
|
| 70 |
+
if state.count >= l.maxFailures {
|
| 71 |
+
state.lockoutUntil = now.Add(l.lockout)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// recordSuccess 登录成功后重置该 IP 的失败计数。
|
| 76 |
+
func (l *loginRateLimiter) recordSuccess(ip string) {
|
| 77 |
+
if ip == "" {
|
| 78 |
+
return
|
| 79 |
+
}
|
| 80 |
+
l.mu.Lock()
|
| 81 |
+
defer l.mu.Unlock()
|
| 82 |
+
delete(l.failures, ip)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// clientIP 从请求中提取客户端 IP。优先使用 chi 的 RealIP 中间件已设置的 RemoteAddr。
|
| 86 |
+
func clientIP(r *http.Request) string {
|
| 87 |
+
if r == nil {
|
| 88 |
+
return ""
|
| 89 |
+
}
|
| 90 |
+
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
| 91 |
+
return ip
|
| 92 |
+
}
|
| 93 |
+
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
| 94 |
+
// 取第一个 IP
|
| 95 |
+
for i := 0; i < len(ip); i++ {
|
| 96 |
+
if ip[i] == ',' {
|
| 97 |
+
return ip[:i]
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
return ip
|
| 101 |
+
}
|
| 102 |
+
host := r.RemoteAddr
|
| 103 |
+
for i := len(host) - 1; i >= 0; i-- {
|
| 104 |
+
if host[i] == ':' {
|
| 105 |
+
return host[:i]
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
return host
|
| 109 |
+
}
|
internal/httpapi/admin/version/handler_version.go
CHANGED
|
@@ -3,13 +3,24 @@ package version
|
|
| 3 |
import (
|
| 4 |
"encoding/json"
|
| 5 |
"net/http"
|
|
|
|
| 6 |
"strings"
|
| 7 |
"time"
|
| 8 |
|
| 9 |
"ds2api/internal/version"
|
| 10 |
)
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
type latestReleasePayload struct {
|
| 15 |
TagName string `json:"tag_name"`
|
|
@@ -27,7 +38,7 @@ func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
|
|
| 27 |
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
| 28 |
}
|
| 29 |
|
| 30 |
-
req, err := http.NewRequest(http.MethodGet,
|
| 31 |
if err != nil {
|
| 32 |
resp["check_error"] = err.Error()
|
| 33 |
writeJSON(w, http.StatusOK, resp)
|
|
|
|
| 3 |
import (
|
| 4 |
"encoding/json"
|
| 5 |
"net/http"
|
| 6 |
+
"os"
|
| 7 |
"strings"
|
| 8 |
"time"
|
| 9 |
|
| 10 |
"ds2api/internal/version"
|
| 11 |
)
|
| 12 |
|
| 13 |
+
// defaultReleaseRepo 是默认用于版本检查的 GitHub 仓库(owner/repo)。
|
| 14 |
+
// 可通过环境变量 DS2API_VERSION_CHECK_REPO 覆盖。
|
| 15 |
+
const defaultReleaseRepo = "luckfun233/ds2api"
|
| 16 |
+
|
| 17 |
+
func latestReleaseAPIURL() string {
|
| 18 |
+
repo := strings.TrimSpace(os.Getenv("DS2API_VERSION_CHECK_REPO"))
|
| 19 |
+
if repo == "" {
|
| 20 |
+
repo = defaultReleaseRepo
|
| 21 |
+
}
|
| 22 |
+
return "https://api.github.com/repos/" + repo + "/releases/latest"
|
| 23 |
+
}
|
| 24 |
|
| 25 |
type latestReleasePayload struct {
|
| 26 |
TagName string `json:"tag_name"`
|
|
|
|
| 38 |
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
| 39 |
}
|
| 40 |
|
| 41 |
+
req, err := http.NewRequest(http.MethodGet, latestReleaseAPIURL(), nil)
|
| 42 |
if err != nil {
|
| 43 |
resp["check_error"] = err.Error()
|
| 44 |
writeJSON(w, http.StatusOK, resp)
|
internal/httpapi/claude/handler_messages.go
CHANGED
|
@@ -28,6 +28,7 @@ import (
|
|
| 28 |
)
|
| 29 |
|
| 30 |
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
|
|
|
|
| 31 |
if strings.TrimSpace(r.Header.Get("anthropic-version")) == "" {
|
| 32 |
r.Header.Set("anthropic-version", "2023-06-01")
|
| 33 |
}
|
|
@@ -58,7 +59,9 @@ func isClaudeVercelProxyRequest(r *http.Request) bool {
|
|
| 58 |
func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) bool {
|
| 59 |
raw, err := io.ReadAll(r.Body)
|
| 60 |
if err != nil {
|
| 61 |
-
if
|
|
|
|
|
|
|
| 62 |
writeClaudeError(w, http.StatusBadRequest, "invalid json")
|
| 63 |
} else {
|
| 64 |
writeClaudeError(w, http.StatusBadRequest, "invalid body")
|
|
@@ -151,7 +154,9 @@ func (h *Handler) handleClaudeDirectStream(w http.ResponseWriter, r *http.Reques
|
|
| 151 |
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
|
| 152 |
raw, err := io.ReadAll(r.Body)
|
| 153 |
if err != nil {
|
| 154 |
-
if
|
|
|
|
|
|
|
| 155 |
writeClaudeError(w, http.StatusBadRequest, "invalid json")
|
| 156 |
} else {
|
| 157 |
writeClaudeError(w, http.StatusBadRequest, "invalid body")
|
|
|
|
| 28 |
)
|
| 29 |
|
| 30 |
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
|
| 31 |
+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
| 32 |
if strings.TrimSpace(r.Header.Get("anthropic-version")) == "" {
|
| 33 |
r.Header.Set("anthropic-version", "2023-06-01")
|
| 34 |
}
|
|
|
|
| 59 |
func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) bool {
|
| 60 |
raw, err := io.ReadAll(r.Body)
|
| 61 |
if err != nil {
|
| 62 |
+
if strings.Contains(strings.ToLower(err.Error()), "too large") {
|
| 63 |
+
writeClaudeError(w, http.StatusRequestEntityTooLarge, "request body too large")
|
| 64 |
+
} else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
|
| 65 |
writeClaudeError(w, http.StatusBadRequest, "invalid json")
|
| 66 |
} else {
|
| 67 |
writeClaudeError(w, http.StatusBadRequest, "invalid body")
|
|
|
|
| 154 |
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
|
| 155 |
raw, err := io.ReadAll(r.Body)
|
| 156 |
if err != nil {
|
| 157 |
+
if strings.Contains(strings.ToLower(err.Error()), "too large") {
|
| 158 |
+
writeClaudeError(w, http.StatusRequestEntityTooLarge, "request body too large")
|
| 159 |
+
} else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
|
| 160 |
writeClaudeError(w, http.StatusBadRequest, "invalid json")
|
| 161 |
} else {
|
| 162 |
writeClaudeError(w, http.StatusBadRequest, "invalid body")
|
internal/httpapi/claude/handler_routes.go
CHANGED
|
@@ -34,6 +34,10 @@ var (
|
|
| 34 |
claudeStreamMaxKeepaliveCnt = dsprotocol.MaxKeepaliveCount
|
| 35 |
)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
func RegisterRoutes(r chi.Router, h *Handler) {
|
| 38 |
r.Get("/anthropic/v1/models", h.ListModels)
|
| 39 |
r.Post("/anthropic/v1/messages", h.Messages)
|
|
|
|
| 34 |
claudeStreamMaxKeepaliveCnt = dsprotocol.MaxKeepaliveCount
|
| 35 |
)
|
| 36 |
|
| 37 |
+
// maxRequestBodySize limits total JSON request body size (100 MiB),
|
| 38 |
+
// consistent with OpenAI endpoints.
|
| 39 |
+
const maxRequestBodySize = 100 << 20
|
| 40 |
+
|
| 41 |
func RegisterRoutes(r chi.Router, h *Handler) {
|
| 42 |
r.Get("/anthropic/v1/models", h.ListModels)
|
| 43 |
r.Post("/anthropic/v1/messages", h.Messages)
|
internal/httpapi/claude/handler_tokens.go
CHANGED
|
@@ -3,9 +3,11 @@ package claude
|
|
| 3 |
import (
|
| 4 |
"encoding/json"
|
| 5 |
"net/http"
|
|
|
|
| 6 |
)
|
| 7 |
|
| 8 |
func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
|
|
|
|
| 9 |
a, err := h.Auth.Determine(r)
|
| 10 |
if err != nil {
|
| 11 |
writeClaudeError(w, http.StatusUnauthorized, err.Error())
|
|
@@ -15,7 +17,11 @@ func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
|
|
| 15 |
|
| 16 |
var req map[string]any
|
| 17 |
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return
|
| 20 |
}
|
| 21 |
model, _ := req["model"].(string)
|
|
|
|
| 3 |
import (
|
| 4 |
"encoding/json"
|
| 5 |
"net/http"
|
| 6 |
+
"strings"
|
| 7 |
)
|
| 8 |
|
| 9 |
func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
|
| 10 |
+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
| 11 |
a, err := h.Auth.Determine(r)
|
| 12 |
if err != nil {
|
| 13 |
writeClaudeError(w, http.StatusUnauthorized, err.Error())
|
|
|
|
| 17 |
|
| 18 |
var req map[string]any
|
| 19 |
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
| 20 |
+
if strings.Contains(strings.ToLower(err.Error()), "too large") {
|
| 21 |
+
writeClaudeError(w, http.StatusRequestEntityTooLarge, "request body too large")
|
| 22 |
+
} else {
|
| 23 |
+
writeClaudeError(w, http.StatusBadRequest, "invalid json")
|
| 24 |
+
}
|
| 25 |
return
|
| 26 |
}
|
| 27 |
model, _ := req["model"].(string)
|
internal/httpapi/gemini/handler_generate.go
CHANGED
|
@@ -28,6 +28,7 @@ import (
|
|
| 28 |
)
|
| 29 |
|
| 30 |
func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, stream bool) {
|
|
|
|
| 31 |
if isGeminiVercelProxyRequest(r) && h.proxyViaOpenAI(w, r, stream) {
|
| 32 |
return
|
| 33 |
}
|
|
@@ -55,7 +56,9 @@ func isGeminiVercelProxyRequest(r *http.Request) bool {
|
|
| 55 |
func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, stream bool) bool {
|
| 56 |
raw, err := io.ReadAll(r.Body)
|
| 57 |
if err != nil {
|
| 58 |
-
if
|
|
|
|
|
|
|
| 59 |
writeGeminiError(w, http.StatusBadRequest, "invalid json")
|
| 60 |
} else {
|
| 61 |
writeGeminiError(w, http.StatusBadRequest, "invalid body")
|
|
@@ -143,7 +146,9 @@ func (h *Handler) handleGeminiDirectStream(w http.ResponseWriter, r *http.Reques
|
|
| 143 |
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
|
| 144 |
raw, err := io.ReadAll(r.Body)
|
| 145 |
if err != nil {
|
| 146 |
-
if
|
|
|
|
|
|
|
| 147 |
writeGeminiError(w, http.StatusBadRequest, "invalid json")
|
| 148 |
} else {
|
| 149 |
writeGeminiError(w, http.StatusBadRequest, "invalid body")
|
|
|
|
| 28 |
)
|
| 29 |
|
| 30 |
func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, stream bool) {
|
| 31 |
+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
| 32 |
if isGeminiVercelProxyRequest(r) && h.proxyViaOpenAI(w, r, stream) {
|
| 33 |
return
|
| 34 |
}
|
|
|
|
| 56 |
func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, stream bool) bool {
|
| 57 |
raw, err := io.ReadAll(r.Body)
|
| 58 |
if err != nil {
|
| 59 |
+
if strings.Contains(strings.ToLower(err.Error()), "too large") {
|
| 60 |
+
writeGeminiError(w, http.StatusRequestEntityTooLarge, "request body too large")
|
| 61 |
+
} else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
|
| 62 |
writeGeminiError(w, http.StatusBadRequest, "invalid json")
|
| 63 |
} else {
|
| 64 |
writeGeminiError(w, http.StatusBadRequest, "invalid body")
|
|
|
|
| 146 |
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
|
| 147 |
raw, err := io.ReadAll(r.Body)
|
| 148 |
if err != nil {
|
| 149 |
+
if strings.Contains(strings.ToLower(err.Error()), "too large") {
|
| 150 |
+
writeGeminiError(w, http.StatusRequestEntityTooLarge, "request body too large")
|
| 151 |
+
} else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
|
| 152 |
writeGeminiError(w, http.StatusBadRequest, "invalid json")
|
| 153 |
} else {
|
| 154 |
writeGeminiError(w, http.StatusBadRequest, "invalid body")
|
internal/httpapi/gemini/handler_routes.go
CHANGED
|
@@ -12,6 +12,10 @@ import (
|
|
| 12 |
|
| 13 |
var writeJSON = util.WriteJSON
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
type Handler struct {
|
| 16 |
Store ConfigReader
|
| 17 |
Auth AuthResolver
|
|
|
|
| 12 |
|
| 13 |
var writeJSON = util.WriteJSON
|
| 14 |
|
| 15 |
+
// maxRequestBodySize limits total JSON request body size (100 MiB),
|
| 16 |
+
// consistent with OpenAI endpoints.
|
| 17 |
+
const maxRequestBodySize = 100 << 20
|
| 18 |
+
|
| 19 |
type Handler struct {
|
| 20 |
Store ConfigReader
|
| 21 |
Auth AuthResolver
|
internal/httpapi/ollama/handler_routes.go
CHANGED
|
@@ -11,6 +11,10 @@ import (
|
|
| 11 |
|
| 12 |
var WriteJSON = util.WriteJSON
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
type ConfigReader interface {
|
| 15 |
ModelAliases() map[string]string
|
| 16 |
}
|
|
@@ -38,6 +42,7 @@ func (h *Handler) ListOllamaModels(w http.ResponseWriter, r *http.Request) {
|
|
| 38 |
WriteJSON(w, http.StatusOK, config.OllamaModelsResponse())
|
| 39 |
}
|
| 40 |
func (h *Handler) GetOllamaModel(w http.ResponseWriter, r *http.Request) {
|
|
|
|
| 41 |
var payload OllamaModelRequest
|
| 42 |
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
| 43 |
http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
|
|
|
| 11 |
|
| 12 |
var WriteJSON = util.WriteJSON
|
| 13 |
|
| 14 |
+
// maxRequestBodySize limits total JSON request body size (100 MiB),
|
| 15 |
+
// consistent with OpenAI endpoints.
|
| 16 |
+
const maxRequestBodySize = 100 << 20
|
| 17 |
+
|
| 18 |
type ConfigReader interface {
|
| 19 |
ModelAliases() map[string]string
|
| 20 |
}
|
|
|
|
| 42 |
WriteJSON(w, http.StatusOK, config.OllamaModelsResponse())
|
| 43 |
}
|
| 44 |
func (h *Handler) GetOllamaModel(w http.ResponseWriter, r *http.Request) {
|
| 45 |
+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
| 46 |
var payload OllamaModelRequest
|
| 47 |
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
| 48 |
http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
internal/rawsample/rawsample.go
CHANGED
|
@@ -7,12 +7,15 @@ import (
|
|
| 7 |
"os"
|
| 8 |
"path/filepath"
|
| 9 |
"regexp"
|
|
|
|
| 10 |
"strings"
|
| 11 |
"time"
|
| 12 |
|
| 13 |
"github.com/google/uuid"
|
| 14 |
)
|
| 15 |
|
|
|
|
|
|
|
| 16 |
var referenceMarkerRe = regexp.MustCompile(`(?i)\[reference:\s*\d+\]`)
|
| 17 |
|
| 18 |
type CaptureRound struct {
|
|
@@ -49,6 +52,9 @@ type PersistOptions struct {
|
|
| 49 |
Request any
|
| 50 |
Capture CaptureSummary
|
| 51 |
UpstreamBody []byte
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
type SavedSample struct {
|
|
@@ -125,6 +131,12 @@ func Persist(opts PersistOptions) (SavedSample, error) {
|
|
| 125 |
return SavedSample{}, fmt.Errorf("promote sample dir: %w", err)
|
| 126 |
}
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
return SavedSample{
|
| 129 |
SampleID: sampleID,
|
| 130 |
Dir: finalDir,
|
|
@@ -197,3 +209,42 @@ func analyzeBytes(raw []byte) (containsReferenceMarkers bool, referenceMarkerCou
|
|
| 197 |
containsFinishedToken = finishedTokenCount > 0
|
| 198 |
return
|
| 199 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"os"
|
| 8 |
"path/filepath"
|
| 9 |
"regexp"
|
| 10 |
+
"sort"
|
| 11 |
"strings"
|
| 12 |
"time"
|
| 13 |
|
| 14 |
"github.com/google/uuid"
|
| 15 |
)
|
| 16 |
|
| 17 |
+
const DefaultMaxSamples = 50
|
| 18 |
+
|
| 19 |
var referenceMarkerRe = regexp.MustCompile(`(?i)\[reference:\s*\d+\]`)
|
| 20 |
|
| 21 |
type CaptureRound struct {
|
|
|
|
| 52 |
Request any
|
| 53 |
Capture CaptureSummary
|
| 54 |
UpstreamBody []byte
|
| 55 |
+
// MaxSamples limits how many sample directories are retained in RootDir.
|
| 56 |
+
// If <= 0, DefaultMaxSamples is used. Excess samples (oldest first) are pruned after persist.
|
| 57 |
+
MaxSamples int
|
| 58 |
}
|
| 59 |
|
| 60 |
type SavedSample struct {
|
|
|
|
| 131 |
return SavedSample{}, fmt.Errorf("promote sample dir: %w", err)
|
| 132 |
}
|
| 133 |
|
| 134 |
+
maxKeep := opts.MaxSamples
|
| 135 |
+
if maxKeep <= 0 {
|
| 136 |
+
maxKeep = DefaultMaxSamples
|
| 137 |
+
}
|
| 138 |
+
pruneExcessSamples(root, maxKeep)
|
| 139 |
+
|
| 140 |
return SavedSample{
|
| 141 |
SampleID: sampleID,
|
| 142 |
Dir: finalDir,
|
|
|
|
| 209 |
containsFinishedToken = finishedTokenCount > 0
|
| 210 |
return
|
| 211 |
}
|
| 212 |
+
|
| 213 |
+
// pruneExcessSamples removes the oldest sample directories beyond maxKeep.
|
| 214 |
+
// Errors during individual removals are ignored (best-effort cleanup).
|
| 215 |
+
func pruneExcessSamples(rootDir string, maxKeep int) {
|
| 216 |
+
if maxKeep <= 0 {
|
| 217 |
+
return
|
| 218 |
+
}
|
| 219 |
+
entries, err := os.ReadDir(rootDir)
|
| 220 |
+
if err != nil {
|
| 221 |
+
return
|
| 222 |
+
}
|
| 223 |
+
type sampleInfo struct {
|
| 224 |
+
name string
|
| 225 |
+
mtime time.Time
|
| 226 |
+
}
|
| 227 |
+
var samples []sampleInfo
|
| 228 |
+
for _, entry := range entries {
|
| 229 |
+
if !entry.IsDir() {
|
| 230 |
+
continue
|
| 231 |
+
}
|
| 232 |
+
info, err := entry.Info()
|
| 233 |
+
if err != nil {
|
| 234 |
+
continue
|
| 235 |
+
}
|
| 236 |
+
samples = append(samples, sampleInfo{
|
| 237 |
+
name: entry.Name(),
|
| 238 |
+
mtime: info.ModTime(),
|
| 239 |
+
})
|
| 240 |
+
}
|
| 241 |
+
if len(samples) <= maxKeep {
|
| 242 |
+
return
|
| 243 |
+
}
|
| 244 |
+
sort.Slice(samples, func(i, j int) bool {
|
| 245 |
+
return samples[i].mtime.After(samples[j].mtime)
|
| 246 |
+
})
|
| 247 |
+
for _, s := range samples[maxKeep:] {
|
| 248 |
+
_ = os.RemoveAll(filepath.Join(rootDir, s.name))
|
| 249 |
+
}
|
| 250 |
+
}
|
internal/webui/handler.go
CHANGED
|
@@ -95,6 +95,8 @@ func setStaticContentType(w http.ResponseWriter, fullPath string) {
|
|
| 95 |
}
|
| 96 |
|
| 97 |
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
|
|
|
|
|
|
| 98 |
root := filepath.Clean(staticDir)
|
| 99 |
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
| 100 |
path = strings.TrimPrefix(path, "/")
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
| 98 |
+
// 防止浏览器对响应内容做 MIME sniffing,避免 XSS 通过类型混淆绕过。
|
| 99 |
+
w.Header().Set("X-Content-Type-Options", "nosniff")
|
| 100 |
root := filepath.Clean(staticDir)
|
| 101 |
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
| 102 |
path = strings.TrimPrefix(path, "/")
|