feat(安全): 添加安全开关并完善测试流程
实现安全开关默认关闭与响应头透传逻辑 - URL 校验与响应头过滤支持开关并覆盖流式路径 - 非流式 Content-Type 透传/默认值按配置生效 - 接入 go test、golangci-lint 与前端 lint/typecheck - 补充相关测试与配置/文档说明
This commit is contained in:
12
Makefile
12
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build build-backend build-frontend
|
.PHONY: build build-backend build-frontend test test-backend test-frontend
|
||||||
|
|
||||||
# 一键编译前后端
|
# 一键编译前后端
|
||||||
build: build-backend build-frontend
|
build: build-backend build-frontend
|
||||||
@@ -10,3 +10,13 @@ build-backend:
|
|||||||
# 编译前端(需要已安装依赖)
|
# 编译前端(需要已安装依赖)
|
||||||
build-frontend:
|
build-frontend:
|
||||||
@npm --prefix frontend run build
|
@npm --prefix frontend run build
|
||||||
|
|
||||||
|
# 运行测试(后端 + 前端)
|
||||||
|
test: test-backend test-frontend
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
@$(MAKE) -C backend test
|
||||||
|
|
||||||
|
test-frontend:
|
||||||
|
@npm --prefix frontend run lint:check
|
||||||
|
@npm --prefix frontend run typecheck
|
||||||
|
|||||||
@@ -272,11 +272,19 @@ Additional security-related options are available in `config.yaml`:
|
|||||||
|
|
||||||
- `cors.allowed_origins` for CORS allowlist
|
- `cors.allowed_origins` for CORS allowlist
|
||||||
- `security.url_allowlist` for upstream/pricing/CRS host allowlists
|
- `security.url_allowlist` for upstream/pricing/CRS host allowlists
|
||||||
|
- `security.url_allowlist.enabled` to disable URL validation (use with caution)
|
||||||
|
- `security.response_headers.enabled` to disable response header filtering
|
||||||
- `security.csp` to control Content-Security-Policy headers
|
- `security.csp` to control Content-Security-Policy headers
|
||||||
- `billing.circuit_breaker` to fail closed on billing errors
|
- `billing.circuit_breaker` to fail closed on billing errors
|
||||||
- `server.trusted_proxies` to enable X-Forwarded-For parsing
|
- `server.trusted_proxies` to enable X-Forwarded-For parsing
|
||||||
- `turnstile.required` to require Turnstile in release mode
|
- `turnstile.required` to require Turnstile in release mode
|
||||||
|
|
||||||
|
If you disable URL validation or response header filtering, harden your network layer:
|
||||||
|
- Enforce an egress allowlist for upstream domains/IPs
|
||||||
|
- Block private/loopback/link-local ranges
|
||||||
|
- Enforce TLS-only outbound traffic
|
||||||
|
- Strip sensitive upstream response headers at the proxy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 6. Run the application
|
# 6. Run the application
|
||||||
./sub2api
|
./sub2api
|
||||||
|
|||||||
@@ -272,11 +272,19 @@ default:
|
|||||||
|
|
||||||
- `cors.allowed_origins` 配置 CORS 白名单
|
- `cors.allowed_origins` 配置 CORS 白名单
|
||||||
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
|
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
|
||||||
|
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
|
||||||
|
- `security.response_headers.enabled` 可关闭响应头过滤
|
||||||
- `security.csp` 配置 Content-Security-Policy
|
- `security.csp` 配置 Content-Security-Policy
|
||||||
- `billing.circuit_breaker` 计费异常时 fail-closed
|
- `billing.circuit_breaker` 计费异常时 fail-closed
|
||||||
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
|
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
|
||||||
- `turnstile.required` 在 release 模式强制启用 Turnstile
|
- `turnstile.required` 在 release 模式强制启用 Turnstile
|
||||||
|
|
||||||
|
如关闭 URL 校验或响应头过滤,请加强网络层防护:
|
||||||
|
- 出站访问白名单限制上游域名/IP
|
||||||
|
- 阻断私网/回环/链路本地地址
|
||||||
|
- 强制仅允许 TLS 出站
|
||||||
|
- 在反向代理层移除敏感响应头
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 6. 运行应用
|
# 6. 运行应用
|
||||||
./sub2api
|
./sub2api
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
.PHONY: build test-unit test-integration test-e2e
|
.PHONY: build test test-unit test-integration test-e2e
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o bin/server ./cmd/server
|
go build -o bin/server ./cmd/server
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
go test -tags=unit ./...
|
go test -tags=unit ./...
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ type SecurityConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type URLAllowlistConfig struct {
|
type URLAllowlistConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
UpstreamHosts []string `mapstructure:"upstream_hosts"`
|
UpstreamHosts []string `mapstructure:"upstream_hosts"`
|
||||||
PricingHosts []string `mapstructure:"pricing_hosts"`
|
PricingHosts []string `mapstructure:"pricing_hosts"`
|
||||||
CRSHosts []string `mapstructure:"crs_hosts"`
|
CRSHosts []string `mapstructure:"crs_hosts"`
|
||||||
@@ -133,6 +134,7 @@ type URLAllowlistConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResponseHeaderConfig struct {
|
type ResponseHeaderConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
AdditionalAllowed []string `mapstructure:"additional_allowed"`
|
AdditionalAllowed []string `mapstructure:"additional_allowed"`
|
||||||
ForceRemove []string `mapstructure:"force_remove"`
|
ForceRemove []string `mapstructure:"force_remove"`
|
||||||
}
|
}
|
||||||
@@ -381,6 +383,13 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("validate config error: %w", err)
|
return nil, fmt.Errorf("validate config error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !cfg.Security.URLAllowlist.Enabled {
|
||||||
|
log.Println("Warning: security.url_allowlist.enabled=false; URL validation is disabled.")
|
||||||
|
}
|
||||||
|
if !cfg.Security.ResponseHeaders.Enabled {
|
||||||
|
log.Println("Warning: security.response_headers.enabled=false; response header filtering is disabled.")
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Server.Mode != "release" && cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
|
if cfg.Server.Mode != "release" && cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
|
||||||
log.Println("Warning: JWT secret appears weak; use a 32+ character random secret in production.")
|
log.Println("Warning: JWT secret appears weak; use a 32+ character random secret in production.")
|
||||||
}
|
}
|
||||||
@@ -410,6 +419,7 @@ func setDefaults() {
|
|||||||
viper.SetDefault("cors.allow_credentials", true)
|
viper.SetDefault("cors.allow_credentials", true)
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
|
viper.SetDefault("security.url_allowlist.enabled", false)
|
||||||
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
|
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
|
||||||
"api.openai.com",
|
"api.openai.com",
|
||||||
"api.anthropic.com",
|
"api.anthropic.com",
|
||||||
@@ -425,6 +435,7 @@ func setDefaults() {
|
|||||||
})
|
})
|
||||||
viper.SetDefault("security.url_allowlist.crs_hosts", []string{})
|
viper.SetDefault("security.url_allowlist.crs_hosts", []string{})
|
||||||
viper.SetDefault("security.url_allowlist.allow_private_hosts", false)
|
viper.SetDefault("security.url_allowlist.allow_private_hosts", false)
|
||||||
|
viper.SetDefault("security.response_headers.enabled", false)
|
||||||
viper.SetDefault("security.response_headers.additional_allowed", []string{})
|
viper.SetDefault("security.response_headers.additional_allowed", []string{})
|
||||||
viper.SetDefault("security.response_headers.force_remove", []string{})
|
viper.SetDefault("security.response_headers.force_remove", []string{})
|
||||||
viper.SetDefault("security.csp.enabled", true)
|
viper.SetDefault("security.csp.enabled", true)
|
||||||
|
|||||||
@@ -68,3 +68,19 @@ func TestLoadSchedulingConfigFromEnv(t *testing.T) {
|
|||||||
t.Fatalf("StickySessionMaxWaiting = %d, want 5", cfg.Gateway.Scheduling.StickySessionMaxWaiting)
|
t.Fatalf("StickySessionMaxWaiting = %d, want 5", cfg.Gateway.Scheduling.StickySessionMaxWaiting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultSecurityToggles(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Security.URLAllowlist.Enabled {
|
||||||
|
t.Fatalf("URLAllowlist.Enabled = true, want false")
|
||||||
|
}
|
||||||
|
if cfg.Security.ResponseHeaders.Enabled {
|
||||||
|
t.Fatalf("ResponseHeaders.Enabled = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
|
|||||||
}
|
}
|
||||||
for k, vv := range res.Headers {
|
for k, vv := range res.Headers {
|
||||||
// Avoid overriding content-length and hop-by-hop headers.
|
// Avoid overriding content-length and hop-by-hop headers.
|
||||||
if strings.EqualFold(k, "Content-Length") || strings.EqualFold(k, "Transfer-Encoding") || strings.EqualFold(k, "Connection") || strings.EqualFold(k, "Www-Authenticate") {
|
if strings.EqualFold(k, "Content-Length") || strings.EqualFold(k, "Transfer-Encoding") || strings.EqualFold(k, "Connection") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, v := range vv {
|
for _, v := range vv {
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ func (s *httpUpstreamService) shouldValidateResolvedIP() bool {
|
|||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !s.cfg.Security.URLAllowlist.Enabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return !s.cfg.Security.URLAllowlist.AllowPrivateHosts
|
return !s.cfg.Security.URLAllowlist.AllowPrivateHosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ type pricingRemoteClient struct {
|
|||||||
|
|
||||||
func NewPricingRemoteClient(cfg *config.Config) service.PricingRemoteClient {
|
func NewPricingRemoteClient(cfg *config.Config) service.PricingRemoteClient {
|
||||||
allowPrivate := false
|
allowPrivate := false
|
||||||
|
validateResolvedIP := true
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
|
allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
|
||||||
|
validateResolvedIP = cfg.Security.URLAllowlist.Enabled
|
||||||
}
|
}
|
||||||
sharedClient, err := httpclient.GetClient(httpclient.Options{
|
sharedClient, err := httpclient.GetClient(httpclient.Options{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
ValidateResolvedIP: true,
|
ValidateResolvedIP: validateResolvedIP,
|
||||||
AllowPrivateHosts: allowPrivate,
|
AllowPrivateHosts: allowPrivate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import (
|
|||||||
func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
|
func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
|
||||||
insecure := false
|
insecure := false
|
||||||
allowPrivate := false
|
allowPrivate := false
|
||||||
|
validateResolvedIP := true
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
insecure = cfg.Security.ProxyProbe.InsecureSkipVerify
|
insecure = cfg.Security.ProxyProbe.InsecureSkipVerify
|
||||||
allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
|
allowPrivate = cfg.Security.URLAllowlist.AllowPrivateHosts
|
||||||
|
validateResolvedIP = cfg.Security.URLAllowlist.Enabled
|
||||||
}
|
}
|
||||||
if insecure {
|
if insecure {
|
||||||
log.Printf("[ProxyProbe] Warning: TLS verification is disabled for proxy probing.")
|
log.Printf("[ProxyProbe] Warning: TLS verification is disabled for proxy probing.")
|
||||||
@@ -28,6 +30,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
|
|||||||
ipInfoURL: defaultIPInfoURL,
|
ipInfoURL: defaultIPInfoURL,
|
||||||
insecureSkipVerify: insecure,
|
insecureSkipVerify: insecure,
|
||||||
allowPrivateHosts: allowPrivate,
|
allowPrivateHosts: allowPrivate,
|
||||||
|
validateResolvedIP: validateResolvedIP,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ type proxyProbeService struct {
|
|||||||
ipInfoURL string
|
ipInfoURL string
|
||||||
insecureSkipVerify bool
|
insecureSkipVerify bool
|
||||||
allowPrivateHosts bool
|
allowPrivateHosts bool
|
||||||
|
validateResolvedIP bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
|
func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*service.ProxyExitInfo, int64, error) {
|
||||||
@@ -45,7 +49,7 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s
|
|||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
InsecureSkipVerify: s.insecureSkipVerify,
|
InsecureSkipVerify: s.insecureSkipVerify,
|
||||||
ProxyStrict: true,
|
ProxyStrict: true,
|
||||||
ValidateResolvedIP: true,
|
ValidateResolvedIP: s.validateResolvedIP,
|
||||||
AllowPrivateHosts: s.allowPrivateHosts,
|
AllowPrivateHosts: s.allowPrivateHosts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error)
|
|||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
return "", errors.New("config is not available")
|
return "", errors.New("config is not available")
|
||||||
}
|
}
|
||||||
|
if !s.cfg.Security.URLAllowlist.Enabled {
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||||
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
||||||
RequireAllowlist: true,
|
RequireAllowlist: true,
|
||||||
|
|||||||
@@ -194,9 +194,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
if s.cfg == nil {
|
if s.cfg == nil {
|
||||||
return nil, errors.New("config is not available")
|
return nil, errors.New("config is not available")
|
||||||
}
|
}
|
||||||
baseURL, err := normalizeBaseURL(input.BaseURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
|
baseURL := strings.TrimSpace(input.BaseURL)
|
||||||
if err != nil {
|
if s.cfg.Security.URLAllowlist.Enabled {
|
||||||
return nil, err
|
normalized, err := normalizeBaseURL(baseURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
baseURL = normalized
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" {
|
if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" {
|
||||||
return nil, errors.New("username and password are required")
|
return nil, errors.New("username and password are required")
|
||||||
@@ -204,7 +208,7 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
|||||||
|
|
||||||
client, err := httpclient.GetClient(httpclient.Options{
|
client, err := httpclient.GetClient(httpclient.Options{
|
||||||
Timeout: 20 * time.Second,
|
Timeout: 20 * time.Second,
|
||||||
ValidateResolvedIP: true,
|
ValidateResolvedIP: s.cfg.Security.URLAllowlist.Enabled,
|
||||||
AllowPrivateHosts: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
AllowPrivateHosts: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1583,6 +1583,10 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
|
|||||||
// 更新5h窗口状态
|
// 更新5h窗口状态
|
||||||
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
||||||
|
|
||||||
|
if s.cfg != nil {
|
||||||
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
// 设置SSE响应头
|
// 设置SSE响应头
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
@@ -1837,8 +1841,15 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
|
|||||||
|
|
||||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||||
|
|
||||||
|
contentType := "application/json"
|
||||||
|
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
|
||||||
|
if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" {
|
||||||
|
contentType = upstreamType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 写入响应
|
// 写入响应
|
||||||
c.Data(resp.StatusCode, "application/json", body)
|
c.Data(resp.StatusCode, contentType, body)
|
||||||
|
|
||||||
return &response.Usage, nil
|
return &response.Usage, nil
|
||||||
}
|
}
|
||||||
@@ -2194,6 +2205,9 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||||
|
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||||
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
||||||
RequireAllowlist: true,
|
RequireAllowlist: true,
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ func (s *GeminiMessagesCompatService) GetAntigravityGatewayService() *Antigravit
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (string, error) {
|
func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||||
|
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||||
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
||||||
RequireAllowlist: true,
|
RequireAllowlist: true,
|
||||||
@@ -1720,6 +1723,10 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
|
|||||||
}
|
}
|
||||||
log.Printf("[GeminiAPI] ====================================================")
|
log.Printf("[GeminiAPI] ====================================================")
|
||||||
|
|
||||||
|
if s.cfg != nil {
|
||||||
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
c.Status(resp.StatusCode)
|
c.Status(resp.StatusCode)
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
|
|||||||
@@ -762,6 +762,10 @@ type openaiStreamingResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) {
|
func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) {
|
||||||
|
if s.cfg != nil {
|
||||||
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
// Set SSE response headers
|
// Set SSE response headers
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
@@ -1030,12 +1034,22 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
|
|||||||
|
|
||||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders)
|
||||||
|
|
||||||
c.Data(resp.StatusCode, "application/json", body)
|
contentType := "application/json"
|
||||||
|
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
|
||||||
|
if upstreamType := resp.Header.Get("Content-Type"); upstreamType != "" {
|
||||||
|
contentType = upstreamType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(resp.StatusCode, contentType, body)
|
||||||
|
|
||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||||
|
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||||
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
||||||
RequireAllowlist: true,
|
RequireAllowlist: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -88,3 +89,175 @@ func TestOpenAIStreamingTooLong(t *testing.T) {
|
|||||||
t.Fatalf("expected response_too_large SSE error, got %q", rec.Body.String())
|
t.Fatalf("expected response_too_large SSE error, got %q", rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAINonStreamingContentTypePassThrough(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
ResponseHeaders: config.ResponseHeaderConfig{Enabled: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
|
||||||
|
body := []byte(`{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/vnd.test+json"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.handleNonStreamingResponse(c.Request.Context(), resp, c, &Account{}, "model", "model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleNonStreamingResponse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Header().Get("Content-Type"), "application/vnd.test+json") {
|
||||||
|
t.Fatalf("expected Content-Type passthrough, got %q", rec.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAINonStreamingContentTypeDefault(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
ResponseHeaders: config.ResponseHeaderConfig{Enabled: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
|
||||||
|
body := []byte(`{"usage":{"input_tokens":1,"output_tokens":2,"input_tokens_details":{"cached_tokens":0}}}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
Header: http.Header{},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.handleNonStreamingResponse(c.Request.Context(), resp, c, &Account{}, "model", "model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleNonStreamingResponse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
|
||||||
|
t.Fatalf("expected default Content-Type, got %q", rec.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIStreamingHeadersOverride(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
ResponseHeaders: config.ResponseHeaderConfig{Enabled: false},
|
||||||
|
},
|
||||||
|
Gateway: config.GatewayConfig{
|
||||||
|
StreamDataIntervalTimeout: 0,
|
||||||
|
StreamKeepaliveInterval: 0,
|
||||||
|
MaxLineSize: defaultMaxLineSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
resp := &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: pr,
|
||||||
|
Header: http.Header{
|
||||||
|
"Cache-Control": []string{"upstream"},
|
||||||
|
"X-Test": []string{"value"},
|
||||||
|
"Content-Type": []string{"application/custom"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() { _ = pw.Close() }()
|
||||||
|
_, _ = pw.Write([]byte("data: {}\n\n"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
|
||||||
|
_ = pr.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleStreamingResponse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Header().Get("Cache-Control") != "no-cache" {
|
||||||
|
t.Fatalf("expected Cache-Control override, got %q", rec.Header().Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
if rec.Header().Get("Content-Type") != "text/event-stream" {
|
||||||
|
t.Fatalf("expected Content-Type override, got %q", rec.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if rec.Header().Get("X-Test") != "value" {
|
||||||
|
t.Fatalf("expected X-Test passthrough, got %q", rec.Header().Get("X-Test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIInvalidBaseURLWhenAllowlistDisabled(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cfg := &config.Config{
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Credentials: map[string]any{"base_url": "://invalid-url"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte("{}"), "token", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for invalid base_url when allowlist disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIValidateUpstreamBaseURLDisabledSkipsValidation(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg}
|
||||||
|
|
||||||
|
normalized, err := svc.validateUpstreamBaseURL("http://not-https.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error when allowlist disabled, got %v", err)
|
||||||
|
}
|
||||||
|
if normalized != "http://not-https.example.com" {
|
||||||
|
t.Fatalf("expected raw url passthrough, got %q", normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIValidateUpstreamBaseURLEnabledEnforcesAllowlist(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
URLAllowlist: config.URLAllowlistConfig{
|
||||||
|
Enabled: true,
|
||||||
|
UpstreamHosts: []string{"example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{cfg: cfg}
|
||||||
|
|
||||||
|
if _, err := svc.validateUpstreamBaseURL("https://example.com"); err != nil {
|
||||||
|
t.Fatalf("expected allowlisted host to pass, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.validateUpstreamBaseURL("https://evil.com"); err == nil {
|
||||||
|
t.Fatalf("expected non-allowlisted host to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -409,6 +409,9 @@ func (s *PricingService) fetchRemoteHash() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *PricingService) validatePricingURL(raw string) (string, error) {
|
func (s *PricingService) validatePricingURL(raw string) (string, error) {
|
||||||
|
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||||
AllowedHosts: s.cfg.Security.URLAllowlist.PricingHosts,
|
AllowedHosts: s.cfg.Security.URLAllowlist.PricingHosts,
|
||||||
RequireAllowlist: true,
|
RequireAllowlist: true,
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ var hopByHopHeaders = map[string]struct{}{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FilterHeaders(src http.Header, cfg config.ResponseHeaderConfig) http.Header {
|
func FilterHeaders(src http.Header, cfg config.ResponseHeaderConfig) http.Header {
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return passThroughHeaders(src)
|
||||||
|
}
|
||||||
allowed := make(map[string]struct{}, len(defaultAllowed)+len(cfg.AdditionalAllowed))
|
allowed := make(map[string]struct{}, len(defaultAllowed)+len(cfg.AdditionalAllowed))
|
||||||
for key := range defaultAllowed {
|
for key := range defaultAllowed {
|
||||||
allowed[key] = struct{}{}
|
allowed[key] = struct{}{}
|
||||||
@@ -91,3 +94,17 @@ func WriteFilteredHeaders(dst http.Header, src http.Header, cfg config.ResponseH
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func passThroughHeaders(src http.Header) http.Header {
|
||||||
|
filtered := make(http.Header, len(src))
|
||||||
|
for key, values := range src {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
if _, isHopByHop := hopByHopHeaders[lower]; isHopByHop {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
filtered.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package responseheaders
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterHeadersDisabledPassThrough(t *testing.T) {
|
||||||
|
src := http.Header{}
|
||||||
|
src.Add("Content-Type", "application/json")
|
||||||
|
src.Add("X-Test", "ok")
|
||||||
|
src.Add("X-Remove", "keep")
|
||||||
|
src.Add("Connection", "keep-alive")
|
||||||
|
src.Add("Content-Length", "123")
|
||||||
|
|
||||||
|
cfg := config.ResponseHeaderConfig{
|
||||||
|
Enabled: false,
|
||||||
|
ForceRemove: []string{"x-test"},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := FilterHeaders(src, cfg)
|
||||||
|
if filtered.Get("Content-Type") != "application/json" {
|
||||||
|
t.Fatalf("expected Content-Type passthrough, got %q", filtered.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if filtered.Get("X-Test") != "ok" {
|
||||||
|
t.Fatalf("expected X-Test passthrough, got %q", filtered.Get("X-Test"))
|
||||||
|
}
|
||||||
|
if filtered.Get("X-Remove") != "keep" {
|
||||||
|
t.Fatalf("expected X-Remove passthrough, got %q", filtered.Get("X-Remove"))
|
||||||
|
}
|
||||||
|
if filtered.Get("Connection") != "" {
|
||||||
|
t.Fatalf("expected Connection to be removed, got %q", filtered.Get("Connection"))
|
||||||
|
}
|
||||||
|
if filtered.Get("Content-Length") != "" {
|
||||||
|
t.Fatalf("expected Content-Length to be removed, got %q", filtered.Get("Content-Length"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterHeadersEnabledUsesAllowlist(t *testing.T) {
|
||||||
|
src := http.Header{}
|
||||||
|
src.Add("Content-Type", "application/json")
|
||||||
|
src.Add("X-Extra", "ok")
|
||||||
|
src.Add("X-Remove", "nope")
|
||||||
|
src.Add("X-Blocked", "nope")
|
||||||
|
|
||||||
|
cfg := config.ResponseHeaderConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AdditionalAllowed: []string{"x-extra"},
|
||||||
|
ForceRemove: []string{"x-remove"},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := FilterHeaders(src, cfg)
|
||||||
|
if filtered.Get("Content-Type") != "application/json" {
|
||||||
|
t.Fatalf("expected Content-Type allowed, got %q", filtered.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if filtered.Get("X-Extra") != "ok" {
|
||||||
|
t.Fatalf("expected X-Extra allowed, got %q", filtered.Get("X-Extra"))
|
||||||
|
}
|
||||||
|
if filtered.Get("X-Remove") != "" {
|
||||||
|
t.Fatalf("expected X-Remove removed, got %q", filtered.Get("X-Remove"))
|
||||||
|
}
|
||||||
|
if filtered.Get("X-Blocked") != "" {
|
||||||
|
t.Fatalf("expected X-Blocked removed, got %q", filtered.Get("X-Blocked"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ cors:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
security:
|
security:
|
||||||
url_allowlist:
|
url_allowlist:
|
||||||
|
# Enable URL allowlist validation (disable to skip all URL checks)
|
||||||
|
enabled: false
|
||||||
# Allowed upstream hosts for API proxying
|
# Allowed upstream hosts for API proxying
|
||||||
upstream_hosts:
|
upstream_hosts:
|
||||||
- "api.openai.com"
|
- "api.openai.com"
|
||||||
@@ -55,6 +57,8 @@ security:
|
|||||||
# Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks)
|
# Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks)
|
||||||
allow_private_hosts: false
|
allow_private_hosts: false
|
||||||
response_headers:
|
response_headers:
|
||||||
|
# Enable response header filtering (disable to pass through upstream headers)
|
||||||
|
enabled: false
|
||||||
# Extra allowed response headers from upstream
|
# Extra allowed response headers from upstream
|
||||||
additional_allowed: []
|
additional_allowed: []
|
||||||
# Force-remove response headers from upstream
|
# Force-remove response headers from upstream
|
||||||
|
|||||||
36
frontend/.eslintrc.cjs
Normal file
36
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
parser: "vue-eslint-parser",
|
||||||
|
parserOptions: {
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
extraFileExtensions: [".vue"],
|
||||||
|
},
|
||||||
|
plugins: ["vue", "@typescript-eslint"],
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"no-constant-condition": "off",
|
||||||
|
"no-mixed-spaces-and-tabs": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/ban-types": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-use-v-if-with-v-for": "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
1384
frontend/package-lock.json
generated
1384
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||||
|
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -30,6 +31,10 @@
|
|||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.25.0",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
if (response.user.run_mode) {
|
if (response.user.run_mode) {
|
||||||
runMode.value = response.user.run_mode
|
runMode.value = response.user.run_mode
|
||||||
}
|
}
|
||||||
const { run_mode, ...userData } = response.user
|
const { run_mode: _run_mode, ...userData } = response.user
|
||||||
user.value = userData
|
user.value = userData
|
||||||
|
|
||||||
// Persist to localStorage
|
// Persist to localStorage
|
||||||
@@ -141,7 +141,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
if (response.user.run_mode) {
|
if (response.user.run_mode) {
|
||||||
runMode.value = response.user.run_mode
|
runMode.value = response.user.run_mode
|
||||||
}
|
}
|
||||||
const { run_mode, ...userDataWithoutRunMode } = response.user
|
const { run_mode: _run_mode, ...userDataWithoutRunMode } = response.user
|
||||||
user.value = userDataWithoutRunMode
|
user.value = userDataWithoutRunMode
|
||||||
|
|
||||||
// Persist to localStorage
|
// Persist to localStorage
|
||||||
@@ -187,7 +187,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
if (response.data.run_mode) {
|
if (response.data.run_mode) {
|
||||||
runMode.value = response.data.run_mode
|
runMode.value = response.data.run_mode
|
||||||
}
|
}
|
||||||
const { run_mode, ...userData } = response.data
|
const { run_mode: _run_mode, ...userData } = response.data
|
||||||
user.value = userData
|
user.value = userData
|
||||||
|
|
||||||
// Update localStorage
|
// Update localStorage
|
||||||
|
|||||||
Reference in New Issue
Block a user