6 Commits

Author SHA1 Message Date
kone 381ad28fe0 chore: prepare v0.1.147 release
Release Image / image (push) Failing after 2m32s
2026-06-09 01:14:02 +08:00
kone d702f74582 fix: add binary release assets to CI and update download allowlist
- Build linux_amd64 binary in CI and upload to Gitea release assets
- Add checksums.txt for integrity verification
- Update allowed download hosts to Gitea domain/IP
2026-06-09 01:13:48 +08:00
kone 0984773711 fix(gemini): skip token cache when expires_at is within refresh window
When a Gemini OAuth account receives a 401, ratelimit_service sets
expires_at=now() to force a refresh. Previously GetAccessToken would
return the stale cached token before checking expires_at, causing
repeated 401s until the cache TTL expired.

Fix: check needsRefresh before attempting cache lookup.
2026-06-09 01:00:11 +08:00
kone 4eb9877082 fix: use Gitea API for version check instead of GitHub 2026-06-06 04:29:48 +08:00
kone 88ccd0ecbb feat: add registration abuse prevention
- Silently block verification code for IPs with 2+ registered accounts
- Silently block Gmail alias emails (containing + or . in local part)
- Add CountByRegistrationIP to UserRepository interface
- Pass client IP to SendVerifyCodeAsync for abuse detection

Both checks return fake success to prevent enumeration attacks.
2026-06-06 04:07:07 +08:00
kone ba5a09862f fix: remove hardcoded default update proxy URL
The default socks5 proxy (172.16.32.16:3389) was unreachable for most
deployments, causing version check to timeout after 30 seconds.

Setting the default to empty string allows direct connection to the
Gitea API, which is the expected behavior for most users.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-06 03:43:13 +08:00
11 changed files with 122 additions and 22 deletions
+30 -2
View File
@@ -81,6 +81,18 @@ jobs:
docker push "$IMAGE_NAME:$VERSION" docker push "$IMAGE_NAME:$VERSION"
docker push "$IMAGE_NAME:latest" docker push "$IMAGE_NAME:latest"
- name: Build binary
run: |
set -eu
cd backend
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags "-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.BuildDate=${BUILD_DATE}" \
-o /tmp/sub2api \
./cmd/server
cd /tmp
tar -czf "sub2api_linux_amd64.tar.gz" sub2api
sha256sum "sub2api_linux_amd64.tar.gz" > checksums.txt
- name: Create Gitea release - name: Create Gitea release
env: env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
@@ -88,9 +100,25 @@ jobs:
set -eu set -eu
BODY="Docker image: ${IMAGE_NAME}:${VERSION}" BODY="Docker image: ${IMAGE_NAME}:${VERSION}"
PAYLOAD=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Sub2API %s","body":"%s","draft":false,"prerelease":false}' "$TAG" "$(git rev-parse HEAD)" "$VERSION" "$BODY") PAYLOAD=$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Sub2API %s","body":"%s","draft":false,"prerelease":false}' "$TAG" "$(git rev-parse HEAD)" "$VERSION" "$BODY")
curl -fsS \ RELEASE_ID=$(curl -fsS \
-X POST \ -X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \ -H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$PAYLOAD" \ -d "$PAYLOAD" \
"$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases" || true "$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
# Upload binary archive
curl -fsS \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @/tmp/sub2api_linux_amd64.tar.gz \
"$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases/${RELEASE_ID}/assets?name=sub2api_linux_amd64.tar.gz"
# Upload checksums
curl -fsS \
-X POST \
-H "Authorization: token ${RELEASE_TOKEN}" \
-H "Content-Type: text/plain" \
--data-binary @/tmp/checksums.txt \
"$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases/${RELEASE_ID}/assets?name=checksums.txt"
+1 -1
View File
@@ -1 +1 @@
0.1.140 0.1.147
+5 -5
View File
@@ -148,9 +148,9 @@ type GeminiTierQuotaConfig struct {
} }
type UpdateConfig struct { type UpdateConfig struct {
// GitHubRepo 用于在线更新的 GitHub 仓库,格式 owner/repo // GitHubRepo 是历史配置字段名,用于在线更新的代码仓库,格式 owner/repo
GitHubRepo string `mapstructure:"github_repo"` GitHubRepo string `mapstructure:"github_repo"`
// ProxyURL 用于访问 GitHub 的代理地址 // ProxyURL 用于访问代码仓库的代理地址
// 支持 http/https/socks5/socks5h 协议 // 支持 http/https/socks5/socks5h 协议
// 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080" // 例如: "http://127.0.0.1:7890", "socks5://127.0.0.1:1080"
ProxyURL string `mapstructure:"proxy_url"` ProxyURL string `mapstructure:"proxy_url"`
@@ -564,7 +564,7 @@ type CSPConfig struct {
type ProxyFallbackConfig struct { type ProxyFallbackConfig struct {
// AllowDirectOnError 当辅助服务的代理初始化失败时是否允许回退直连。 // AllowDirectOnError 当辅助服务的代理初始化失败时是否允许回退直连。
// 仅影响以下非 AI 账号连接的辅助服务: // 仅影响以下非 AI 账号连接的辅助服务:
// - GitHub Release 更新检查 // - Gitea Release 更新检查
// - 定价数据拉取 // - 定价数据拉取
// 不影响 AI 账号网关连接(Claude/OpenAI/Gemini/Antigravity), // 不影响 AI 账号网关连接(Claude/OpenAI/Gemini/Antigravity),
// 这些关键路径的代理失败始终返回错误,不会回退直连。 // 这些关键路径的代理失败始终返回错误,不会回退直连。
@@ -1614,8 +1614,8 @@ func setDefaults() {
viper.SetDefault("pricing.hash_check_interval_minutes", 10) viper.SetDefault("pricing.hash_check_interval_minutes", 10)
// Update // Update
viper.SetDefault("update.github_repo", "man209111-cpu/sub2api") viper.SetDefault("update.github_repo", "kgod/sub2api")
viper.SetDefault("update.proxy_url", "socks5://admin%40sub2api.local:m729066849@172.16.32.16:3389") viper.SetDefault("update.proxy_url", "")
// Timezone (default to Asia/Shanghai for Chinese users) // Timezone (default to Asia/Shanghai for Chinese users)
viper.SetDefault("timezone", "Asia/Shanghai") viper.SetDefault("timezone", "Asia/Shanghai")
+4 -2
View File
@@ -200,13 +200,15 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
return return
} }
clientIP := ip.GetClientIP(c)
// Turnstile 验证 // Turnstile 验证
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, clientIP); err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
} }
result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email) result, err := h.authService.SendVerifyCodeAsync(c.Request.Context(), req.Email, clientIP)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
@@ -76,13 +76,14 @@ func (c *githubReleaseClientError) FetchChecksumFile(ctx context.Context, url st
} }
func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) { func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) // 使用 Gitea API(兼容 GitHub Release API 格式)
url := fmt.Sprintf("http://git.jianshixingqiu.com/api/v1/repos/%s/releases/latest", repo)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Sub2API-Updater") req.Header.Set("User-Agent", "Sub2API-Updater")
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -92,7 +93,7 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode) return nil, fmt.Errorf("Gitea API returned %d", resp.StatusCode)
} }
var release service.GitHubRelease var release service.GitHubRelease
+23
View File
@@ -1113,3 +1113,26 @@ func (r *userRepository) DisableTotp(ctx context.Context, userID int64) error {
} }
return nil return nil
} }
// CountByRegistrationIP 统计指定 IP 注册的用户数量
func (r *userRepository) CountByRegistrationIP(ctx context.Context, ip string) (int, error) {
if strings.TrimSpace(ip) == "" {
return 0, nil
}
rows, err := r.sql.QueryContext(ctx,
`SELECT COUNT(*) FROM users WHERE register_ip_address = $1 AND deleted_at IS NULL`,
ip,
)
if err != nil {
return 0, err
}
defer rows.Close()
var count int
if rows.Next() {
if err := rows.Scan(&count); err != nil {
return 0, err
}
}
return count, nil
}
+41 -2
View File
@@ -311,8 +311,9 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error {
} }
// SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时 // SendVerifyCodeAsync 异步发送邮箱验证码并返回倒计时
func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) { // clientIP 用于检查同一 IP 注册账号数量限制
logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s", email) func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, clientIP string) (*SendVerifyCodeResult, error) {
logger.LegacyPrintf("service.auth", "[Auth] SendVerifyCodeAsync called for email: %s, ip: %s", email, clientIP)
// 检查是否开放注册(默认关闭) // 检查是否开放注册(默认关闭)
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
@@ -338,6 +339,28 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
return nil, ErrEmailExists return nil, ErrEmailExists
} }
// 检查 Gmail 别名邮箱(含 + 或本地部分含 . 的),静默假装发送成功
if isGmailAliasEmail(email) {
logger.LegacyPrintf("service.auth", "[Auth] Gmail alias email detected: %s, returning fake success", email)
return &SendVerifyCodeResult{
Countdown: 60,
}, nil
}
// 检查同一 IP 注册账号数量(>=2 则静默假装发送成功,不实际发送)
if clientIP != "" {
ipRegCount, err := s.userRepo.CountByRegistrationIP(ctx, clientIP)
if err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to count users by registration IP: %v", err)
// 查询失败不阻塞,继续正常流程
} else if ipRegCount >= 2 {
logger.LegacyPrintf("service.auth", "[Auth] IP %s already registered %d accounts, returning fake success", clientIP, ipRegCount)
return &SendVerifyCodeResult{
Countdown: 60,
}, nil
}
}
// 检查邮件队列服务是否配置 // 检查邮件队列服务是否配置
if s.emailQueueService == nil { if s.emailQueueService == nil {
logger.LegacyPrintf("service.auth", "%s", "[Auth] Email queue service not configured") logger.LegacyPrintf("service.auth", "%s", "[Auth] Email queue service not configured")
@@ -1092,6 +1115,22 @@ func isReservedEmail(email string) bool {
strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain)
} }
// isGmailAliasEmail 检测 Gmail 别名邮箱
// Gmail 支持两种别名方式:
// 1. 加号别名:user+anything@gmail.com -> user@gmail.com
// 2. 点号忽略:u.s.e.r@gmail.com -> user@gmail.com
// 为防止滥用注册,检测到这类邮箱时返回 true
func isGmailAliasEmail(email string) bool {
normalized := strings.ToLower(strings.TrimSpace(email))
if !strings.HasSuffix(normalized, "@gmail.com") {
return false
}
// 提取本地部分(@前面的部分)
localPart := strings.TrimSuffix(normalized, "@gmail.com")
// 检查是否包含 + 或 .
return strings.Contains(localPart, "+") || strings.Contains(localPart, ".")
}
// GenerateToken 生成JWT access token // GenerateToken 生成JWT access token
// 使用新的access_token_expire_minutes配置项(如果配置了),否则回退到expire_hour // 使用新的access_token_expire_minutes配置项(如果配置了),否则回退到expire_hour
func (s *AuthService) GenerateToken(user *User) (string, error) { func (s *AuthService) GenerateToken(user *User) (string, error) {
@@ -231,6 +231,10 @@ func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID
panic("unexpected DisableTotp call") panic("unexpected DisableTotp call")
} }
func (r *contentModerationTestUserRepo) CountByRegistrationIP(ctx context.Context, ip string) (int, error) {
return 0, nil
}
type contentModerationTestAuthCacheInvalidator struct { type contentModerationTestAuthCacheInvalidator struct {
userIDs []int64 userIDs []int64
} }
@@ -62,16 +62,16 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
cacheKey := GeminiTokenCacheKey(account) cacheKey := GeminiTokenCacheKey(account)
// 1) Try cache first. // 1) Try cache first — skip if token is already expired or within refresh skew.
if p.tokenCache != nil { expiresAt := account.GetCredentialAsTime("expires_at")
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= geminiTokenRefreshSkew
if !needsRefresh && p.tokenCache != nil {
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" { if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
return token, nil return token, nil
} }
} }
// 2) Refresh if needed (pre-expiry skew). // 2) Refresh if needed (pre-expiry skew).
expiresAt := account.GetCredentialAsTime("expires_at")
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= geminiTokenRefreshSkew
if needsRefresh && p.refreshAPI != nil && p.executor != nil { if needsRefresh && p.refreshAPI != nil && p.executor != nil {
result, err := p.refreshAPI.RefreshIfNeeded(ctx, account, p.executor, geminiTokenRefreshSkew) result, err := p.refreshAPI.RefreshIfNeeded(ctx, account, p.executor, geminiTokenRefreshSkew)
+3 -3
View File
@@ -22,11 +22,11 @@ import (
const ( const (
updateCacheKey = "update_check_cache" updateCacheKey = "update_check_cache"
updateCacheTTL = 1200 // 20 minutes updateCacheTTL = 1200 // 20 minutes
defaultGitHubRepo = "man209111-cpu/sub2api" defaultGitHubRepo = "kgod/sub2api"
// Security: allowed download domains for updates // Security: allowed download domains for updates
allowedDownloadHost = "github.com" allowedDownloadHost = "git.jianshixingqiu.com"
allowedAssetHost = "objects.githubusercontent.com" allowedAssetHost = "8.138.12.104"
// Security: max download size (500MB) // Security: max download size (500MB)
maxDownloadSize = 500 * 1024 * 1024 maxDownloadSize = 500 * 1024 * 1024
+3
View File
@@ -111,6 +111,9 @@ type UserRepository interface {
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
EnableTotp(ctx context.Context, userID int64) error EnableTotp(ctx context.Context, userID int64) error
DisableTotp(ctx context.Context, userID int64) error DisableTotp(ctx context.Context, userID int64) error
// CountByRegistrationIP 统计指定 IP 注册的用户数量
CountByRegistrationIP(ctx context.Context, ip string) (int, error)
} }
type UserAuthIdentityRecord struct { type UserAuthIdentityRecord struct {