Update SproutGate

This commit is contained in:
2026-05-13 12:19:36 +08:00
parent f7db8aa053
commit a37b92e144
51 changed files with 12034 additions and 254 deletions

View File

@@ -10,6 +10,15 @@ import (
"sproutgate-backend/internal/models"
)
// ListUsers
// @Summary 用户列表
// @Tags admin
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/users [get]
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.store.ListUsers()
if err != nil {
@@ -23,8 +32,20 @@ func (h *Handler) ListUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
}
// CreateUser
// @Summary 创建用户
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body CreateUserRequest true "用户"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/users [post]
func (h *Handler) CreateUser(c *gin.Context) {
var req createUserRequest
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -66,13 +87,27 @@ func (h *Handler) CreateUser(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"user": record.OwnerPublic()})
}
// UpdateUser
// @Summary 更新用户
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param account path string true "账号"
// @Param body body UpdateUserRequest true "可部分更新"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/users/{account} [put]
func (h *Handler) UpdateUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
return
}
var req updateUserRequest
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -158,6 +193,17 @@ func (h *Handler) UpdateUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()})
}
// DeleteUser
// @Summary 删除用户
// @Tags admin
// @Produce json
// @Security AdminToken
// @Param account path string true "账号"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/users/{account} [delete]
func (h *Handler) DeleteUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {

View File

@@ -15,17 +15,36 @@ import (
"sproutgate-backend/internal/models"
)
// Login
// @Summary 账号密码登录
// @Description 可选 Turnstile开启时 body 需带 turnstileToken。
// @Tags auth
// @Accept json
// @Produce json
// @Param body body LoginRequest true "登录请求"
// @Success 200 {object} map[string]interface{} "token、expiresAt、user"
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/login [post]
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
h.store.MaybeSyncRuntimeConfigFromDB()
req.Account = strings.TrimSpace(req.Account)
if req.Account == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
return
}
if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled {
if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, req.TurnstileToken, c.ClientIP()); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
user, found, err := h.store.GetUser(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
@@ -61,8 +80,20 @@ func (h *Handler) Login(c *gin.Context) {
})
}
// Verify
// @Summary 校验 JWT
// @Description 校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。
// @Tags auth
// @Accept json
// @Produce json
// @Param body body VerifyRequest true "token"
// @Success 200 {object} map[string]interface{} "valid、user"
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/verify [post]
func (h *Handler) Verify(c *gin.Context) {
var req verifyRequest
var req VerifyRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
@@ -94,6 +125,21 @@ func (h *Handler) Verify(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
}
// Me
// @Summary 当前用户(含签到摘要)
// @Description 需要 Authorization: Bearer。可选头X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Visit-Ip header string false "访客 IP"
// @Param X-Visit-Location header string false "访客地区展示文案"
// @Param X-Auth-Client header string false "接入应用 ID"
// @Param X-Auth-Client-Name header string false "接入应用名称"
// @Success 200 {object} map[string]interface{} "user、checkIn"
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/me [get]
func (h *Handler) Me(c *gin.Context) {
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {

View File

@@ -14,12 +14,23 @@ import (
"sproutgate-backend/internal/storage"
)
// Register
// @Summary 自助注册(发验证邮件)
// @Tags auth
// @Accept json
// @Produce json
// @Param body body RegisterRequest true "注册请求"
// @Success 200 {object} map[string]interface{} "sent、expiresAt"
// @Failure 400 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/register [post]
func (h *Handler) Register(c *gin.Context) {
var req registerRequest
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
h.store.MaybeSyncRuntimeConfigFromDB()
req.Account = storage.NormalizeSelfServiceAccount(req.Account)
req.Email = strings.TrimSpace(req.Email)
inviteTrim := strings.TrimSpace(req.InviteCode)
@@ -31,6 +42,12 @@ func (h *Handler) Register(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled {
if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, req.TurnstileToken, c.ClientIP()); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
requireInv := h.store.RegistrationRequireInvite()
if requireInv && inviteTrim == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
@@ -88,8 +105,18 @@ func (h *Handler) Register(c *gin.Context) {
})
}
// VerifyEmail
// @Summary 邮箱验证并完成注册
// @Tags auth
// @Accept json
// @Produce json
// @Param body body VerifyEmailRequest true "账号与验证码"
// @Success 201 {object} map[string]interface{} "created、user"
// @Failure 400 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/verify-email [post]
func (h *Handler) VerifyEmail(c *gin.Context) {
var req verifyEmailRequest
var req VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -153,8 +180,18 @@ func (h *Handler) VerifyEmail(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.OwnerPublic()})
}
// ForgotPassword
// @Summary 忘记密码(发重置邮件)
// @Tags auth
// @Accept json
// @Produce json
// @Param body body ForgotPasswordRequest true "账号与邮箱"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/forgot-password [post]
func (h *Handler) ForgotPassword(c *gin.Context) {
var req forgotPasswordRequest
var req ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -206,8 +243,18 @@ func (h *Handler) ForgotPassword(c *gin.Context) {
})
}
// ResetPassword
// @Summary 重置密码
// @Tags auth
// @Accept json
// @Produce json
// @Param body body ResetPasswordRequest true "账号、验证码、新密码"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/reset-password [post]
func (h *Handler) ResetPassword(c *gin.Context) {
var req resetPasswordRequest
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return

View File

@@ -12,6 +12,16 @@ import (
"sproutgate-backend/internal/storage"
)
// CheckIn
// @Summary 每日签到
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{} "checkedIn、user、checkIn 等"
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/check-in [post]
func (h *Handler) CheckIn(c *gin.Context) {
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {
@@ -71,13 +81,33 @@ func (h *Handler) CheckIn(c *gin.Context) {
})
}
// GetCheckInConfig
// @Summary 签到奖励配置(管理端也可用)
// @Tags admin
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /api/admin/check-in/config [get]
func (h *Handler) GetCheckInConfig(c *gin.Context) {
cfg := h.store.CheckInConfig()
c.JSON(http.StatusOK, gin.H{"rewardCoins": cfg.RewardCoins})
}
// UpdateCheckInConfig
// @Summary 更新签到奖励
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body UpdateCheckInConfigRequest true "rewardCoins"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/check-in/config [put]
func (h *Handler) UpdateCheckInConfig(c *gin.Context) {
var req updateCheckInConfigRequest
var req UpdateCheckInConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return

View File

@@ -0,0 +1,961 @@
package handlers
import (
"context"
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
"golang.org/x/oauth2/google"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/models"
)
const oauthStateTTL = 20 * time.Minute
func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
t := strings.TrimSpace(tenant)
if t == "" {
t = "common"
}
base := "https://login.microsoftonline.com/" + t
return oauth2.Endpoint{
AuthURL: base + "/oauth2/v2.0/authorize",
TokenURL: base + "/oauth2/v2.0/token",
}
}
func isOAuthProvider(p string) bool {
switch strings.ToLower(strings.TrimSpace(p)) {
case "github", "gitea", "google", "microsoft", "linuxdo":
return true
default:
return false
}
}
type oauthStatePayload struct {
Exp int64 `json:"exp"`
Nonce string `json:"n"`
Ret string `json:"r"`
Mode string `json:"m"` // login | bind
Acc string `json:"a"`
Prov string `json:"p"`
}
func publicAPIBase(c *gin.Context) string {
if b := os.Getenv("PUBLIC_API_BASE"); strings.TrimSpace(b) != "" {
return strings.TrimRight(strings.TrimSpace(b), "/")
}
host := c.Request.Host
if host == "" {
return ""
}
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
scheme = "https"
}
return scheme + "://" + host
}
func (h *Handler) signOAuthState(p oauthStatePayload) (string, error) {
if p.Exp == 0 {
p.Exp = time.Now().Add(oauthStateTTL).Unix()
}
raw, err := json.Marshal(p)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, h.store.JWTSecret())
_, _ = mac.Write(raw)
sig := mac.Sum(nil)
return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
func (h *Handler) parseOAuthState(s string) (oauthStatePayload, error) {
parts := strings.Split(s, ".")
if len(parts) != 2 {
return oauthStatePayload{}, errors.New("invalid state")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return oauthStatePayload{}, err
}
wantSig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return oauthStatePayload{}, err
}
mac := hmac.New(sha256.New, h.store.JWTSecret())
_, _ = mac.Write(raw)
if !hmac.Equal(mac.Sum(nil), wantSig) {
return oauthStatePayload{}, errors.New("bad signature")
}
var p oauthStatePayload
if err := json.Unmarshal(raw, &p); err != nil {
return oauthStatePayload{}, err
}
if time.Now().Unix() > p.Exp {
return oauthStatePayload{}, errors.New("state expired")
}
return p, nil
}
func randomNonce() (string, error) {
b := make([]byte, 16)
if _, err := cryptorand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (h *Handler) oAuth2Config(c *gin.Context, provider string) (oauth2.Config, error) {
cfg := h.store.GetOAuthConfig()
redirect := publicAPIBase(c)
if redirect == "" {
return oauth2.Config{}, errors.New("cannot resolve public API base (set PUBLIC_API_BASE)")
}
var oc oauth2.Config
switch provider {
case "github":
if !cfg.GitHubEnabled {
return oc, errors.New("github oauth disabled")
}
secret := strings.TrimSpace(os.Getenv("GITHUB_CLIENT_SECRET"))
if secret == "" {
secret = cfg.GitHubClientSecret
}
if strings.TrimSpace(cfg.GitHubClientID) == "" || secret == "" {
return oc, errors.New("github oauth not configured")
}
oc = oauth2.Config{
ClientID: strings.TrimSpace(cfg.GitHubClientID),
ClientSecret: secret,
RedirectURL: redirect + "/api/auth/oauth/github/callback",
Scopes: []string{"read:user", "user:email"},
Endpoint: github.Endpoint,
}
return oc, nil
case "gitea":
if !cfg.GiteaEnabled {
return oc, errors.New("gitea oauth disabled")
}
secret := strings.TrimSpace(os.Getenv("GITEA_CLIENT_SECRET"))
if secret == "" {
secret = cfg.GiteaClientSecret
}
base := strings.TrimRight(cfg.GiteaBaseURL, "/")
if base == "" {
base = "https://git.shumengya.top"
}
if strings.TrimSpace(cfg.GiteaClientID) == "" || secret == "" {
return oc, errors.New("gitea oauth not configured")
}
oc = oauth2.Config{
ClientID: strings.TrimSpace(cfg.GiteaClientID),
ClientSecret: secret,
RedirectURL: redirect + "/api/auth/oauth/gitea/callback",
Scopes: []string{"read:user"},
Endpoint: oauth2.Endpoint{
AuthURL: base + "/login/oauth/authorize",
TokenURL: base + "/login/oauth/access_token",
},
}
return oc, nil
case "google":
if !cfg.GoogleEnabled {
return oc, errors.New("google oauth disabled")
}
secret := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_SECRET"))
if secret == "" {
secret = cfg.GoogleClientSecret
}
if strings.TrimSpace(cfg.GoogleClientID) == "" || secret == "" {
return oc, errors.New("google oauth not configured")
}
oc = oauth2.Config{
ClientID: strings.TrimSpace(cfg.GoogleClientID),
ClientSecret: secret,
RedirectURL: redirect + "/api/auth/oauth/google/callback",
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
return oc, nil
case "microsoft":
if !cfg.MicrosoftEnabled {
return oc, errors.New("microsoft oauth disabled")
}
secret := strings.TrimSpace(os.Getenv("MICROSOFT_CLIENT_SECRET"))
if secret == "" {
secret = cfg.MicrosoftClientSecret
}
tenant := strings.TrimSpace(cfg.MicrosoftTenant)
if tenant == "" {
tenant = "common"
}
if strings.TrimSpace(cfg.MicrosoftClientID) == "" || secret == "" {
return oc, errors.New("microsoft oauth not configured")
}
oc = oauth2.Config{
ClientID: strings.TrimSpace(cfg.MicrosoftClientID),
ClientSecret: secret,
RedirectURL: redirect + "/api/auth/oauth/microsoft/callback",
Scopes: []string{"openid", "profile", "email", "https://graph.microsoft.com/User.Read"},
Endpoint: microsoftV2Endpoint(tenant),
}
return oc, nil
case "linuxdo":
if !cfg.LinuxdoEnabled {
return oc, errors.New("linuxdo oauth disabled")
}
secret := strings.TrimSpace(os.Getenv("LINUXDO_CLIENT_SECRET"))
if secret == "" {
secret = cfg.LinuxdoClientSecret
}
base := strings.TrimRight(cfg.LinuxdoConnectBaseURL, "/")
if base == "" {
base = "https://connect.linux.do"
}
if strings.TrimSpace(cfg.LinuxdoClientID) == "" || secret == "" {
return oc, errors.New("linuxdo oauth not configured")
}
oc = oauth2.Config{
ClientID: strings.TrimSpace(cfg.LinuxdoClientID),
ClientSecret: secret,
RedirectURL: redirect + "/api/auth/oauth/linuxdo/callback",
Scopes: []string{"read:user"},
Endpoint: oauth2.Endpoint{
AuthURL: base + "/oauth2/authorize",
TokenURL: base + "/oauth2/token",
},
}
return oc, nil
default:
return oc, errors.New("unknown provider")
}
}
// OAuthStart
// @Summary OAuth 登录起点(重定向 IdP
// @Tags oauth
// @Produce json
// @Param provider path string true "github|gitea|google|microsoft|linuxdo"
// @Param return_to query string true "登录完成回跳 URL须符合后台允许前缀"
// @Param turnstile_token query string false "Turnstile 开启时必填"
// @Success 302 "重定向至 IdP"
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Failure 503 {object} map[string]interface{}
// @Router /api/auth/oauth/{provider}/start [get]
func (h *Handler) OAuthStart(c *gin.Context) {
provider := strings.ToLower(strings.TrimSpace(c.Param("provider")))
if !isOAuthProvider(provider) {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"})
return
}
h.store.MaybeSyncRuntimeConfigFromDB()
ret := strings.TrimSpace(c.Query("return_to"))
cfg := h.store.GetOAuthConfig()
if !cfg.IsReturnURLAllowed(ret) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid return_to (not allowed for OAuth redirect)"})
return
}
if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled {
tok := strings.TrimSpace(c.Query("turnstile_token"))
if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, tok, c.ClientIP()); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
nonce, err := randomNonce()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start oauth"})
return
}
st, err := h.signOAuthState(oauthStatePayload{
Exp: time.Now().Add(oauthStateTTL).Unix(),
Nonce: nonce,
Ret: ret,
Mode: "login",
Prov: provider,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start oauth"})
return
}
oac, err := h.oAuth2Config(c, provider)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
url := oac.AuthCodeURL(st, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
c.Redirect(http.StatusFound, url)
}
// OAuthBindURL
// @Summary 已登录用户绑定 OAuth返回授权 URL
// @Tags oauth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param provider path string true "github|gitea|google|microsoft|linuxdo"
// @Param body body OAuthBindURLRequest true "returnTo"
// @Success 200 {object} map[string]interface{} "url"
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Failure 503 {object} map[string]interface{}
// @Router /api/auth/oauth/{provider}/bind [post]
func (h *Handler) OAuthBindURL(c *gin.Context) {
provider := strings.ToLower(strings.TrimSpace(c.Param("provider")))
if !isOAuthProvider(provider) {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"})
return
}
tok := bearerToken(c.GetHeader("Authorization"))
if tok == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), tok)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
bindUser, bfound, berr := h.store.GetUser(claims.Account)
if berr == nil && bfound && bindUser.Banned {
writeBanJSON(c, bindUser.BanReason)
return
}
var req OAuthBindURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
ret := strings.TrimSpace(req.ReturnTo)
if ret == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "returnTo is required"})
return
}
if !h.store.GetOAuthConfig().IsReturnURLAllowed(ret) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid returnTo"})
return
}
nonce, err := randomNonce()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start bind"})
return
}
st, err := h.signOAuthState(oauthStatePayload{
Exp: time.Now().Add(oauthStateTTL).Unix(),
Nonce: nonce,
Ret: ret,
Mode: "bind",
Acc: claims.Account,
Prov: provider,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start bind"})
return
}
oac, err := h.oAuth2Config(c, provider)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
authu := oac.AuthCodeURL(st, oauth2.AccessTypeOffline, oauth2.ApprovalForce)
c.JSON(http.StatusOK, gin.H{"url": authu})
}
// OAuthUnlink
// @Summary 解绑 OAuth 提供商
// @Tags oauth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param provider path string true "github|gitea|google|microsoft|linuxdo"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/oauth/{provider} [delete]
func (h *Handler) OAuthUnlink(c *gin.Context) {
provider := strings.ToLower(strings.TrimSpace(c.Param("provider")))
if !isOAuthProvider(provider) {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"})
return
}
tok := bearerToken(c.GetHeader("Authorization"))
if tok == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), tok)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
acc := claims.Account
var err2 error
switch provider {
case "github":
err2 = h.store.ClearUserGitHubID(acc)
case "gitea":
err2 = h.store.ClearUserGiteaID(acc)
case "google":
err2 = h.store.ClearUserGoogleID(acc)
case "microsoft":
err2 = h.store.ClearUserMicrosoftID(acc)
case "linuxdo":
err2 = h.store.ClearUserLinuxdoID(acc)
}
if err2 != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err2.Error()})
return
}
user, found, e := h.store.GetUser(acc)
if e != nil || !found {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if abortIfUserBanned(c, user) {
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()})
}
// OAuthCallback
// @Summary OAuth 回调IdP 重定向)
// @Description 成功时通常 302 回 return_to 并带 token失败时 JSON 或重定向错误页。
// @Tags oauth
// @Produce json
// @Param provider path string true "提供商"
// @Param code query string false "授权码"
// @Param state query string false "state"
// @Param error query string false "IdP 错误码"
// @Success 302 "重定向至客户端"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /api/auth/oauth/{provider}/callback [get]
func (h *Handler) OAuthCallback(c *gin.Context) {
provider := strings.ToLower(strings.TrimSpace(c.Param("provider")))
if !isOAuthProvider(provider) {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"})
return
}
h.store.MaybeSyncRuntimeConfigFromDB()
if errParam := strings.TrimSpace(c.Query("error")); errParam != "" {
if st := strings.TrimSpace(c.Query("state")); st != "" {
if p, perr := h.parseOAuthState(st); perr == nil {
h.redirectOAuthError(c, p.Ret, c.Query("error_description"))
return
}
}
c.String(http.StatusBadRequest, "oauth error: %s", errParam)
return
}
code := strings.TrimSpace(c.Query("code"))
st := strings.TrimSpace(c.Query("state"))
if code == "" || st == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"})
return
}
p, err := h.parseOAuthState(st)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
if p.Prov != provider {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider mismatch"})
return
}
if !h.store.GetOAuthConfig().IsReturnURLAllowed(p.Ret) {
c.JSON(http.StatusBadRequest, gin.H{"error": "stale return url"})
return
}
oac, err := h.oAuth2Config(c, provider)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
t, err := oac.Exchange(ctx, code)
if err != nil {
h.redirectOAuthError(c, p.Ret, "token_exchange_failed")
return
}
var extID, email, username, avatar string
oCfg := h.store.GetOAuthConfig()
switch provider {
case "github":
extID, email, username, avatar, err = h.fetchGitHubUser(ctx, t.AccessToken)
case "gitea":
extID, email, username, avatar, err = h.fetchGiteaUser(ctx, oCfg.GiteaBaseURL, t.AccessToken)
case "google":
extID, email, username, avatar, err = h.fetchGoogleUser(ctx, t.AccessToken)
case "microsoft":
extID, email, username, avatar, err = h.fetchMicrosoftUser(ctx, t.AccessToken)
case "linuxdo":
extID, email, username, avatar, err = h.fetchLinuxdoUser(ctx, oCfg.LinuxdoConnectBaseURL, t.AccessToken)
}
if err != nil || extID == "" {
h.redirectOAuthError(c, p.Ret, "userinfo_failed")
return
}
if p.Mode == "bind" {
acc := strings.TrimSpace(p.Acc)
if acc == "" {
h.redirectOAuthError(c, p.Ret, "bind_no_account")
return
}
switch provider {
case "github":
err = h.store.SetUserGitHubID(acc, extID)
case "gitea":
err = h.store.SetUserGiteaID(acc, extID)
case "google":
err = h.store.SetUserGoogleID(acc, extID)
case "microsoft":
err = h.store.SetUserMicrosoftID(acc, extID)
case "linuxdo":
err = h.store.SetUserLinuxdoID(acc, extID)
}
if err != nil {
h.redirectOAuthError(c, p.Ret, err.Error())
return
}
h.redirectOAuthBindDone(c, p.Ret)
return
}
// login
var linkedUser models.UserRecord
var linkedOK bool
var dberr error
switch provider {
case "github":
linkedUser, linkedOK, dberr = h.store.GetUserByGitHubID(extID)
case "gitea":
linkedUser, linkedOK, dberr = h.store.GetUserByGiteaID(extID)
case "google":
linkedUser, linkedOK, dberr = h.store.GetUserByGoogleID(extID)
case "microsoft":
linkedUser, linkedOK, dberr = h.store.GetUserByMicrosoftID(extID)
case "linuxdo":
linkedUser, linkedOK, dberr = h.store.GetUserByLinuxdoID(extID)
}
if dberr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
if linkedOK {
if linkedUser.Banned {
c.JSON(http.StatusForbidden, gin.H{"error": "banned", "banReason": linkedUser.BanReason})
return
}
h.issueTokenAndRedirect(c, p.Ret, linkedUser)
return
}
if !h.store.OAuthSignUpAllowed() {
h.redirectOAuthError(c, p.Ret, "oauth_signup_disabled")
return
}
email = strings.TrimSpace(email)
if email != "" {
_, efound, eerr := h.store.GetUserByEmail(email)
if eerr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
if efound {
h.redirectOAuthError(c, p.Ret, "email_already_registered")
return
}
}
acc, err := h.store.ProposeUniqueOAuthAccount()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "account allocation failed"})
return
}
if email == "" {
email = acc + "@oauth.local.sproutgate"
}
rp := make([]byte, 40)
_, _ = cryptorand.Read(rp)
randPass := base64.RawURLEncoding.EncodeToString(rp)
hash, err := bcrypt.GenerateFromPassword([]byte(randPass), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"})
return
}
rec := models.UserRecord{
Account: acc,
PasswordHash: string(hash),
Username: strings.TrimSpace(username),
Email: email,
Level: 0,
SproutCoins: 0,
AvatarURL: strings.TrimSpace(avatar),
CreatedAt: models.NowISO(),
UpdatedAt: models.NowISO(),
}
switch provider {
case "github":
rec.GitHubUserID = extID
case "gitea":
rec.GiteaUserID = extID
case "google":
rec.GoogleUserID = extID
case "microsoft":
rec.MicrosoftUserID = extID
case "linuxdo":
rec.LinuxdoUserID = extID
}
if err := h.store.CreateUser(rec); err != nil {
h.redirectOAuthError(c, p.Ret, "create_user_failed")
return
}
if u, found, e := h.store.GetUser(rec.Account); e == nil && found {
h.issueTokenAndRedirect(c, p.Ret, u)
}
}
func (h *Handler) issueTokenAndRedirect(c *gin.Context, returnTo string, u models.UserRecord) {
if u.Banned {
c.JSON(http.StatusForbidden, gin.H{"error": "banned", "banReason": u.BanReason})
return
}
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), u.Account, u.TokenEpoch, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token failed"})
return
}
h.redirectOAuthToken(c, returnTo, token, expiresAt.Format(time.RFC3339))
}
func (h *Handler) redirectOAuthToken(c *gin.Context, returnTo, token, expiresAt string) {
frag := url.Values{}
frag.Set("oauth_token", token)
frag.Set("oauth_expires_at", expiresAt)
u := returnTo
if i := strings.IndexByte(u, '#'); i >= 0 {
u = u[:i]
}
c.Redirect(http.StatusFound, u+"#"+frag.Encode())
}
func (h *Handler) redirectOAuthBindDone(c *gin.Context, returnTo string) {
frag := url.Values{}
frag.Set("oauth_bound", "1")
u := returnTo
if i := strings.IndexByte(u, '#'); i >= 0 {
u = u[:i]
}
c.Redirect(http.StatusFound, u+"#"+frag.Encode())
}
func (h *Handler) redirectOAuthError(c *gin.Context, returnTo, desc string) {
u := returnTo
if i := strings.IndexByte(u, '#'); i >= 0 {
u = u[:i]
}
frag := url.Values{}
frag.Set("oauth_error", "1")
if desc != "" {
frag.Set("oauth_error_description", desc)
}
if u == "" {
c.String(http.StatusBadRequest, "oauth error: %s", desc)
return
}
c.Redirect(http.StatusFound, u+"#"+frag.Encode())
}
func (h *Handler) fetchGitHubUser(ctx context.Context, access string) (id, email, username, avatar string, err error) {
type gh struct {
ID int64 `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
if err != nil {
return "", "", "", "", err
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "sproutgate-oauth")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", "", err
}
defer res.Body.Close()
b, rerr := readResponseBody(res)
if rerr != nil {
return "", "", "", "", rerr
}
if res.StatusCode != http.StatusOK {
return "", "", "", "", errors.New("github user api: " + string(b))
}
var u gh
if err := json.Unmarshal(b, &u); err != nil {
return "", "", "", "", err
}
email = strings.TrimSpace(u.Email)
username = strings.TrimSpace(u.Name)
if username == "" {
username = u.Login
}
if email == "" {
email, err = h.fetchGitHubPrimaryEmail(ctx, access)
if err != nil {
email = ""
}
}
avatar = strings.TrimSpace(u.AvatarURL)
return strconv.FormatInt(u.ID, 10), strings.TrimSpace(email), username, avatar, nil
}
func (h *Handler) fetchGitHubPrimaryEmail(ctx context.Context, access string) (string, error) {
type em struct {
Email string `json:"email"`
Primary bool `json:"primary"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/emails", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("User-Agent", "sproutgate-oauth")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
b, rerr := readResponseBody(res)
if rerr != nil {
return "", rerr
}
if res.StatusCode != http.StatusOK {
return "", errors.New("github emails")
}
var list []em
if err := json.Unmarshal(b, &list); err != nil {
return "", err
}
for _, e := range list {
if e.Primary {
return strings.TrimSpace(e.Email), nil
}
}
if len(list) > 0 {
return strings.TrimSpace(list[0].Email), nil
}
return "", nil
}
func (h *Handler) fetchGiteaUser(ctx context.Context, giteaBase, access string) (id, email, username, avatar string, err error) {
base := strings.TrimRight(strings.TrimSpace(giteaBase), "/")
if base == "" {
base = "https://git.shumengya.top"
}
type gu struct {
ID int64 `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FullName string `json:"full_name"`
AvatarURL string `json:"avatar_url"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil)
if err != nil {
return "", "", "", "", err
}
req.Header.Set("Authorization", "token "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", "", err
}
defer res.Body.Close()
b, rerr := readResponseBody(res)
if rerr != nil {
return "", "", "", "", rerr
}
if res.StatusCode != http.StatusOK {
return "", "", "", "", errors.New("gitea user api")
}
var u gu
if err := json.Unmarshal(b, &u); err != nil {
return "", "", "", "", err
}
username = strings.TrimSpace(u.FullName)
if username == "" {
username = u.Login
}
avatar = strings.TrimSpace(u.AvatarURL)
return strconv.FormatInt(u.ID, 10), strings.TrimSpace(u.Email), username, avatar, nil
}
func (h *Handler) fetchGoogleUser(ctx context.Context, access string) (id, email, username, avatar string, err error) {
type gu struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", "", "", "", err
}
req.Header.Set("Authorization", "Bearer "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", "", err
}
defer res.Body.Close()
b, rerr := readResponseBody(res)
if rerr != nil {
return "", "", "", "", rerr
}
if res.StatusCode != http.StatusOK {
return "", "", "", "", errors.New("google userinfo: " + string(b))
}
var u gu
if err := json.Unmarshal(b, &u); err != nil {
return "", "", "", "", err
}
id = strings.TrimSpace(u.ID)
if id == "" {
return "", "", "", "", errors.New("google userinfo: missing id")
}
email = strings.TrimSpace(u.Email)
username = strings.TrimSpace(u.Name)
avatar = strings.TrimSpace(u.Picture)
return id, email, username, avatar, nil
}
func (h *Handler) fetchMicrosoftUser(ctx context.Context, access string) (id, email, username, avatar string, err error) {
type mu struct {
ID string `json:"id"`
Mail string `json:"mail"`
UserPrincipalName string `json:"userPrincipalName"`
DisplayName string `json:"displayName"`
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://graph.microsoft.com/v1.0/me", nil)
if err != nil {
return "", "", "", "", err
}
req.Header.Set("Authorization", "Bearer "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", "", err
}
defer res.Body.Close()
b, rerr := readResponseBody(res)
if rerr != nil {
return "", "", "", "", rerr
}
if res.StatusCode != http.StatusOK {
return "", "", "", "", errors.New("microsoft graph me: " + string(b))
}
var u mu
if err := json.Unmarshal(b, &u); err != nil {
return "", "", "", "", err
}
id = strings.TrimSpace(u.ID)
if id == "" {
return "", "", "", "", errors.New("microsoft graph: missing id")
}
email = strings.TrimSpace(u.Mail)
if email == "" {
upn := strings.TrimSpace(u.UserPrincipalName)
if strings.Contains(upn, "@") {
email = upn
}
}
username = strings.TrimSpace(u.DisplayName)
return id, email, username, "", nil
}
// fetchLinuxdoUser 调用 LINUX DO Connect 用户接口(/api/user
// 文档: https://connect.linux.do 应用接入中给出的 User Endpoint。
func (h *Handler) fetchLinuxdoUser(ctx context.Context, connectBase, access string) (id, email, username, avatar string, err error) {
base := strings.TrimRight(strings.TrimSpace(connectBase), "/")
if base == "" {
base = "https://connect.linux.do"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/user", nil)
if err != nil {
return "", "", "", "", err
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", "", err
}
defer res.Body.Close()
b, rerr := readResponseBody(res)
if rerr != nil {
return "", "", "", "", rerr
}
if res.StatusCode != http.StatusOK {
return "", "", "", "", fmt.Errorf("linuxdo user api: status %d", res.StatusCode)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
return "", "", "", "", err
}
if v, ok := m["id"]; ok && v != nil {
id = fmt.Sprint(v)
}
if strings.TrimSpace(id) == "" {
return "", "", "", "", errors.New("linuxdo user: missing id")
}
if s, ok := m["username"].(string); ok {
username = strings.TrimSpace(s)
}
if s, ok := m["name"].(string); ok && username == "" {
username = strings.TrimSpace(s)
}
if s, ok := m["email"].(string); ok {
email = strings.TrimSpace(s)
}
if s, ok := m["avatar_url"].(string); ok {
avatar = strings.TrimSpace(s)
}
if avatar == "" {
if tpl, ok := m["avatar_template"].(string); ok && strings.TrimSpace(tpl) != "" {
tpl = strings.TrimSpace(tpl)
if strings.HasPrefix(tpl, "http://") || strings.HasPrefix(tpl, "https://") {
avatar = tpl
} else {
avatar = strings.TrimRight(base, "/") + tpl
}
}
}
return id, email, username, avatar, nil
}
func readResponseBody(res *http.Response) ([]byte, error) {
return io.ReadAll(io.LimitReader(res.Body, 1<<20))
}

View File

@@ -0,0 +1,123 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/storage"
)
// GetAdminOAuth
// @Summary OAuth 配置(脱敏)
// @Tags admin
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /api/admin/oauth [get]
func (h *Handler) GetAdminOAuth(c *gin.Context) {
cfg := h.store.GetOAuthConfig()
c.JSON(http.StatusOK, gin.H{
"githubEnabled": cfg.GitHubEnabled,
"giteaEnabled": cfg.GiteaEnabled,
"googleEnabled": cfg.GoogleEnabled,
"microsoftEnabled": cfg.MicrosoftEnabled,
"linuxdoEnabled": cfg.LinuxdoEnabled,
"githubClientId": strings.TrimSpace(cfg.GitHubClientID),
"githubClientSecretSet": strings.TrimSpace(cfg.GitHubClientSecret) != "",
"giteaBaseUrl": cfg.GiteaBaseURL,
"giteaClientId": strings.TrimSpace(cfg.GiteaClientID),
"giteaClientSecretSet": strings.TrimSpace(cfg.GiteaClientSecret) != "",
"googleClientId": strings.TrimSpace(cfg.GoogleClientID),
"googleClientSecretSet": strings.TrimSpace(cfg.GoogleClientSecret) != "",
"microsoftClientId": strings.TrimSpace(cfg.MicrosoftClientID),
"microsoftClientSecretSet": strings.TrimSpace(cfg.MicrosoftClientSecret) != "",
"microsoftTenant": strings.TrimSpace(cfg.MicrosoftTenant),
"linuxdoConnectBaseUrl": cfg.LinuxdoConnectBaseURL,
"linuxdoClientId": strings.TrimSpace(cfg.LinuxdoClientID),
"linuxdoClientSecretSet": strings.TrimSpace(cfg.LinuxdoClientSecret) != "",
"githubLogoUrl": strings.TrimSpace(cfg.GitHubLogoURL),
"giteaLogoUrl": strings.TrimSpace(cfg.GiteaLogoURL),
"googleLogoUrl": strings.TrimSpace(cfg.GoogleLogoURL),
"microsoftLogoUrl": strings.TrimSpace(cfg.MicrosoftLogoURL),
"linuxdoLogoUrl": strings.TrimSpace(cfg.LinuxdoLogoURL),
"allowedReturnPrefixes": cfg.AllowedReturnPrefixes,
"allowOAuthSignUpWhenInviteRequired": cfg.AllowOAuthSignUpWhenInviteRequired,
})
}
// PutAdminOAuth
// @Summary 更新 OAuth 配置
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body PutAdminOAuthRequest true "配置"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/oauth [put]
func (h *Handler) PutAdminOAuth(c *gin.Context) {
var req PutAdminOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
prefixes := make([]string, 0, len(req.AllowedReturnPrefixes))
for _, p := range req.AllowedReturnPrefixes {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if !strings.HasSuffix(p, "/") {
p += "/"
}
prefixes = append(prefixes, p)
}
if len(prefixes) == 0 {
prefixes = append([]string(nil), h.store.GetOAuthConfig().AllowedReturnPrefixes...)
}
giteaBase := strings.TrimRight(strings.TrimSpace(req.GiteaBaseURL), "/")
if giteaBase == "" {
giteaBase = "https://git.shumengya.top"
}
ldBase := strings.TrimRight(strings.TrimSpace(req.LinuxdoConnectBaseURL), "/")
if ldBase == "" {
ldBase = "https://connect.linux.do"
}
out := storage.OAuthConfig{
GitHubEnabled: req.GitHubEnabled,
GiteaEnabled: req.GiteaEnabled,
GoogleEnabled: req.GoogleEnabled,
MicrosoftEnabled: req.MicrosoftEnabled,
LinuxdoEnabled: req.LinuxdoEnabled,
GitHubClientID: strings.TrimSpace(req.GitHubClientID),
GitHubClientSecret: strings.TrimSpace(req.GitHubClientSecret),
GiteaBaseURL: giteaBase,
GiteaClientID: strings.TrimSpace(req.GiteaClientID),
GiteaClientSecret: strings.TrimSpace(req.GiteaClientSecret),
GoogleClientID: strings.TrimSpace(req.GoogleClientID),
GoogleClientSecret: strings.TrimSpace(req.GoogleClientSecret),
MicrosoftClientID: strings.TrimSpace(req.MicrosoftClientID),
MicrosoftClientSecret: strings.TrimSpace(req.MicrosoftClientSecret),
MicrosoftTenant: strings.TrimSpace(req.MicrosoftTenant),
LinuxdoConnectBaseURL: ldBase,
LinuxdoClientID: strings.TrimSpace(req.LinuxdoClientID),
LinuxdoClientSecret: strings.TrimSpace(req.LinuxdoClientSecret),
GitHubLogoURL: strings.TrimSpace(req.GitHubLogoURL),
GiteaLogoURL: strings.TrimSpace(req.GiteaLogoURL),
GoogleLogoURL: strings.TrimSpace(req.GoogleLogoURL),
MicrosoftLogoURL: strings.TrimSpace(req.MicrosoftLogoURL),
LinuxdoLogoURL: strings.TrimSpace(req.LinuxdoLogoURL),
AllowedReturnPrefixes: prefixes,
AllowOAuthSignUpWhenInviteRequired: req.AllowOAuthSignUpWhenInviteRequired,
}
if err := h.store.MergeUpdateOAuthConfig(out); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save oauth config"})
return
}
h.GetAdminOAuth(c)
}

View File

@@ -10,6 +10,18 @@ import (
"sproutgate-backend/internal/auth"
)
// UpdateProfile
// @Summary 更新个人资料(可选改密码)
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body UpdateProfileRequest true "资料字段"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/profile [put]
func (h *Handler) UpdateProfile(c *gin.Context) {
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {
@@ -21,7 +33,7 @@ func (h *Handler) UpdateProfile(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
var req updateProfileRequest
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return

View File

@@ -13,7 +13,13 @@ import (
"sproutgate-backend/internal/storage"
)
// ListPublicUsers 公开用户目录(未封禁用户,默认按注册时间从早到晚)。
// ListPublicUsers
// @Summary 公开用户目录
// @Tags public
// @Produce json
// @Success 200 {object} map[string]interface{} "total、users"
// @Failure 500 {object} map[string]interface{}
// @Router /api/public/users [get]
func (h *Handler) ListPublicUsers(c *gin.Context) {
users, err := h.store.ListUsers()
if err != nil {
@@ -33,6 +39,18 @@ func (h *Handler) ListPublicUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"total": len(out), "users": out})
}
// GetPublicUser
// @Summary 公开用户主页
// @Description 可选 Authorization登录用户可看到点赞状态等扩展字段。
// @Tags public
// @Produce json
// @Param account path string true "账号"
// @Param Authorization header string false "Bearer可选"
// @Success 200 {object} map[string]interface{} "user、profileLikeCount 等"
// @Failure 400 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/public/users/{account} [get]
func (h *Handler) GetPublicUser(c *gin.Context) {
account := strings.TrimSpace(c.Param("account"))
if account == "" {
@@ -83,6 +101,19 @@ func (h *Handler) GetPublicUser(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}
// PostPublicProfileLike
// @Summary 主页点赞
// @Tags public
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param account path string true "被点赞用户账号"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/public/users/{account}/like [post]
func (h *Handler) PostPublicProfileLike(c *gin.Context) {
likedParam := strings.TrimSpace(c.Param("account"))
if likedParam == "" {
@@ -130,9 +161,9 @@ func (h *Handler) PostPublicProfileLike(c *gin.Context) {
case errors.Is(err, storage.ErrDailyLikeLimitReached):
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"error": err.Error(),
"viewerLikesRemainingToday": rem,
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
})
return
case errors.Is(err, storage.ErrProfileLikeLiker):
@@ -146,9 +177,9 @@ func (h *Handler) PostPublicProfileLike(c *gin.Context) {
rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account)
c.JSON(http.StatusOK, gin.H{
"profileLikeCount": newCount,
"viewerHasLikedToday": true,
"profileLikeCount": newCount,
"viewerHasLikedToday": true,
"viewerLikesRemainingToday": rem,
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
"profileLikeDailyMax": storage.MaxProfileLikesPerDay,
})
}

View File

@@ -2,19 +2,41 @@ package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/storage"
)
// GetPublicRegistrationPolicy 公开:是否必须邀请码、自助注册账号规则说明(不含具体邀请码)。
// GetPublicRegistrationPolicy
// @Summary 公开注册策略与 OAuth/Turnstile 开关
// @Tags public
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/public/registration-policy [get]
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
h.store.MaybeSyncRuntimeConfigFromDB()
o := h.store.PublicOAuthFlags()
oc := h.store.GetOAuthConfig()
tc := h.store.GetTurnstileConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": h.store.RegistrationRequireInvite(),
"requireInviteCode": h.store.RegistrationRequireInvite(),
"inviteRegisterRewardCoins": h.store.RegistrationInviteRegisterRewardCoins(),
"selfServiceAccountMin": storage.MinSelfServiceAccountLen,
"selfServiceAccountMax": storage.MaxSelfServiceAccountLen,
"selfServiceAccountHint": "lowercase letters and digits only",
"githubOAuthEnabled": o.GitHubEnabled,
"giteaOAuthEnabled": o.GiteaEnabled,
"googleOAuthEnabled": o.GoogleEnabled,
"microsoftOAuthEnabled": o.MicrosoftEnabled,
"linuxdoOAuthEnabled": o.LinuxdoEnabled,
"githubOAuthLogoUrl": strings.TrimSpace(oc.GitHubLogoURL),
"giteaOAuthLogoUrl": strings.TrimSpace(oc.GiteaLogoURL),
"googleOAuthLogoUrl": strings.TrimSpace(oc.GoogleLogoURL),
"microsoftOAuthLogoUrl": strings.TrimSpace(oc.MicrosoftLogoURL),
"linuxdoOAuthLogoUrl": strings.TrimSpace(oc.LinuxdoLogoURL),
"turnstileEnabled": tc.Enabled,
"turnstileSiteKey": strings.TrimSpace(tc.SiteKey),
})
}

View File

@@ -7,18 +7,38 @@ import (
"github.com/gin-gonic/gin"
)
// GetAdminRegistration
// @Summary 注册策略与邀请码列表
// @Tags admin
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /api/admin/registration [get]
func (h *Handler) GetAdminRegistration(c *gin.Context) {
cfg := h.store.GetRegistrationConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": cfg.RequireInviteCode,
"forbiddenAccounts": cfg.ForbiddenAccounts,
"requireInviteCode": cfg.RequireInviteCode,
"forbiddenAccounts": cfg.ForbiddenAccounts,
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
"invites": cfg.Invites,
"invites": cfg.Invites,
})
}
// PutAdminRegistrationPolicy
// @Summary 更新注册策略
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body UpdateRegistrationPolicyRequest true "策略"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/registration [put]
func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
var req updateRegistrationPolicyRequest
var req UpdateRegistrationPolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -29,14 +49,25 @@ func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
}
cfg := h.store.GetRegistrationConfig()
c.JSON(http.StatusOK, gin.H{
"requireInviteCode": cfg.RequireInviteCode,
"forbiddenAccounts": cfg.ForbiddenAccounts,
"requireInviteCode": cfg.RequireInviteCode,
"forbiddenAccounts": cfg.ForbiddenAccounts,
"inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins,
})
}
// PostAdminInvite
// @Summary 创建邀请码
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body CreateInviteRequest true "邀请配置"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /api/admin/registration/invites [post]
func (h *Handler) PostAdminInvite(c *gin.Context) {
var req createInviteRequest
var req CreateInviteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -49,6 +80,17 @@ func (h *Handler) PostAdminInvite(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"invite": entry})
}
// DeleteAdminInvite
// @Summary 删除邀请码
// @Tags admin
// @Produce json
// @Security AdminToken
// @Param code path string true "邀请码"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Router /api/admin/registration/invites/{code} [delete]
func (h *Handler) DeleteAdminInvite(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if err := h.store.DeleteInviteEntry(code); err != nil {

View File

@@ -6,31 +6,38 @@ import (
"strings"
)
type loginRequest struct {
Account string `json:"account"`
Password string `json:"password"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
// LoginRequest POST /api/auth/login JSON body
type LoginRequest struct {
Account string `json:"account"`
Password string `json:"password"`
ClientID string `json:"clientId"`
ClientName string `json:"clientName"`
TurnstileToken string `json:"turnstileToken"`
}
type verifyRequest struct {
// VerifyRequest POST /api/auth/verify
type VerifyRequest struct {
Token string `json:"token"`
}
type registerRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
InviteCode string `json:"inviteCode"`
// RegisterRequest POST /api/auth/register
type RegisterRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
Email string `json:"email"`
InviteCode string `json:"inviteCode"`
TurnstileToken string `json:"turnstileToken"`
}
type verifyEmailRequest struct {
// VerifyEmailRequest POST /api/auth/verify-email
type VerifyEmailRequest struct {
Account string `json:"account"`
Code string `json:"code"`
}
type updateProfileRequest struct {
// UpdateProfileRequest PUT /api/auth/profile
type UpdateProfileRequest struct {
Password *string `json:"password"`
Username *string `json:"username"`
Phone *string `json:"phone"`
@@ -39,43 +46,51 @@ type updateProfileRequest struct {
Bio *string `json:"bio"`
}
type forgotPasswordRequest struct {
// ForgotPasswordRequest POST /api/auth/forgot-password
type ForgotPasswordRequest struct {
Account string `json:"account"`
Email string `json:"email"`
}
type resetPasswordRequest struct {
// ResetPasswordRequest POST /api/auth/reset-password
type ResetPasswordRequest struct {
Account string `json:"account"`
Code string `json:"code"`
NewPassword string `json:"newPassword"`
}
type secondaryEmailRequest struct {
// SecondaryEmailRequest POST /api/auth/secondary-email/request
type SecondaryEmailRequest struct {
Email string `json:"email"`
}
type verifySecondaryEmailRequest struct {
// VerifySecondaryEmailRequest POST /api/auth/secondary-email/verify
type VerifySecondaryEmailRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type updateCheckInConfigRequest struct {
// UpdateCheckInConfigRequest PUT /api/admin/check-in/config
type UpdateCheckInConfigRequest struct {
RewardCoins int `json:"rewardCoins"`
}
type updateRegistrationPolicyRequest struct {
RequireInviteCode bool `json:"requireInviteCode"`
ForbiddenAccounts string `json:"forbiddenAccounts"`
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项
// UpdateRegistrationPolicyRequest PUT /api/admin/registration
type UpdateRegistrationPolicyRequest struct {
RequireInviteCode bool `json:"requireInviteCode"`
ForbiddenAccounts string `json:"forbiddenAccounts"`
InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项
}
type createInviteRequest struct {
// CreateInviteRequest POST /api/admin/registration/invites
type CreateInviteRequest struct {
Note string `json:"note"`
MaxUses int `json:"maxUses"`
ExpiresAt string `json:"expiresAt"`
}
type createUserRequest struct {
// CreateUserRequest POST /api/admin/users
type CreateUserRequest struct {
Account string `json:"account"`
Password string `json:"password"`
Username string `json:"username"`
@@ -91,7 +106,8 @@ type createUserRequest struct {
const maxBanReasonLen = 500
type updateUserRequest struct {
// UpdateUserRequest PUT /api/admin/users/:account
type UpdateUserRequest struct {
Password *string `json:"password"`
Username *string `json:"username"`
Email *string `json:"email"`
@@ -106,6 +122,47 @@ type updateUserRequest struct {
BanReason *string `json:"banReason"`
}
// OAuthBindURLRequest POST /api/auth/oauth/:provider/bind
type OAuthBindURLRequest struct {
ReturnTo string `json:"returnTo"`
}
// PutAdminOAuthRequest PUT /api/admin/oauth
type PutAdminOAuthRequest struct {
GitHubEnabled bool `json:"githubEnabled"`
GiteaEnabled bool `json:"giteaEnabled"`
GoogleEnabled bool `json:"googleEnabled"`
MicrosoftEnabled bool `json:"microsoftEnabled"`
LinuxdoEnabled bool `json:"linuxdoEnabled"`
GitHubClientID string `json:"githubClientId"`
GitHubClientSecret string `json:"githubClientSecret"`
GiteaBaseURL string `json:"giteaBaseUrl"`
GiteaClientID string `json:"giteaClientId"`
GiteaClientSecret string `json:"giteaClientSecret"`
GoogleClientID string `json:"googleClientId"`
GoogleClientSecret string `json:"googleClientSecret"`
MicrosoftClientID string `json:"microsoftClientId"`
MicrosoftClientSecret string `json:"microsoftClientSecret"`
MicrosoftTenant string `json:"microsoftTenant"`
LinuxdoConnectBaseURL string `json:"linuxdoConnectBaseUrl"`
LinuxdoClientID string `json:"linuxdoClientId"`
LinuxdoClientSecret string `json:"linuxdoClientSecret"`
GitHubLogoURL string `json:"githubLogoUrl"`
GiteaLogoURL string `json:"giteaLogoUrl"`
GoogleLogoURL string `json:"googleLogoUrl"`
MicrosoftLogoURL string `json:"microsoftLogoUrl"`
LinuxdoLogoURL string `json:"linuxdoLogoUrl"`
AllowedReturnPrefixes []string `json:"allowedReturnPrefixes"`
AllowOAuthSignUpWhenInviteRequired bool `json:"allowOAuthSignUpWhenInviteRequired"`
}
// PutAdminTurnstileRequest PUT /api/admin/turnstile
type PutAdminTurnstileRequest struct {
Enabled bool `json:"enabled"`
SiteKey string `json:"siteKey"`
SecretKey string `json:"secretKey"`
}
const maxWebsiteURLLen = 2048
func normalizePublicWebsiteURL(raw string) (string, error) {

View File

@@ -13,6 +13,18 @@ import (
"sproutgate-backend/internal/models"
)
// RequestSecondaryEmail
// @Summary 请求验证辅助邮箱(发邮件)
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body SecondaryEmailRequest true "邮箱"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/secondary-email/request [post]
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {
@@ -24,7 +36,7 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
var req secondaryEmailRequest
var req SecondaryEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
@@ -87,6 +99,18 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
})
}
// VerifySecondaryEmail
// @Summary 验证并绑定辅助邮箱
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param body body VerifySecondaryEmailRequest true "邮箱与验证码"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/secondary-email/verify [post]
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {
@@ -98,7 +122,7 @@ func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
var req verifySecondaryEmailRequest
var req VerifySecondaryEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return

View File

@@ -0,0 +1,95 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"sproutgate-backend/internal/storage"
)
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
type turnstileVerifyResp struct {
Success bool `json:"success"`
}
// verifyTurnstileToken 向 Cloudflare 验证 Turnstile token失败返回用户可见错误。
func verifyTurnstileToken(ctx context.Context, secretKey, token, remoteIP string) error {
if strings.TrimSpace(token) == "" {
return fmt.Errorf("请完成人机验证")
}
form := url.Values{}
form.Set("secret", secretKey)
form.Set("response", token)
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.PostForm(turnstileVerifyURL, form)
if err != nil {
return fmt.Errorf("人机验证服务暂时不可用,请稍后重试")
}
defer resp.Body.Close()
var result turnstileVerifyResp
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("人机验证响应解析失败")
}
if !result.Success {
return fmt.Errorf("人机验证未通过,请重试")
}
return nil
}
// GetAdminTurnstile
// @Summary Turnstile 配置(脱敏)
// @Tags admin
// @Produce json
// @Security AdminToken
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Router /api/admin/turnstile [get]
func (h *Handler) GetAdminTurnstile(c *gin.Context) {
cfg := h.store.GetTurnstileConfig()
c.JSON(http.StatusOK, gin.H{
"enabled": cfg.Enabled,
"siteKey": strings.TrimSpace(cfg.SiteKey),
"secretKeySet": strings.TrimSpace(cfg.SecretKey) != "",
})
}
// PutAdminTurnstile
// @Summary 更新 Turnstile 配置
// @Tags admin
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body PutAdminTurnstileRequest true "siteKey、secretKey"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/admin/turnstile [put]
func (h *Handler) PutAdminTurnstile(c *gin.Context) {
var req PutAdminTurnstileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
in := storage.TurnstileConfig{
Enabled: req.Enabled,
SiteKey: strings.TrimSpace(req.SiteKey),
SecretKey: strings.TrimSpace(req.SecretKey),
}
if err := h.store.UpdateTurnstileConfig(in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save turnstile config"})
return
}
h.GetAdminTurnstile(c)
}

View File

@@ -32,6 +32,11 @@ type UserRecord struct {
BannedAt string `json:"bannedAt,omitempty"`
TokenEpoch int64 `json:"-"` // 递增使用户此前签发的 JWT 全局失效;不对外 JSON 序列化
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
GitHubUserID string `json:"-"` // OAuth 子;不直接序列化到通用 JSON
GiteaUserID string `json:"-"`
LinuxdoUserID string `json:"-"`
GoogleUserID string `json:"-"`
MicrosoftUserID string `json:"-"`
}
type UserPublic struct {
@@ -62,6 +67,11 @@ type UserPublic struct {
BanReason string `json:"banReason,omitempty"`
BannedAt string `json:"bannedAt,omitempty"`
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
GitHubLinked bool `json:"githubLinked,omitempty"`
GiteaLinked bool `json:"giteaLinked,omitempty"`
LinuxdoLinked bool `json:"linuxdoLinked,omitempty"`
GoogleLinked bool `json:"googleLinked,omitempty"`
MicrosoftLinked bool `json:"microsoftLinked,omitempty"`
}
func (u UserRecord) Public() UserPublic {
@@ -117,6 +127,11 @@ func (u UserRecord) OwnerPublic() UserPublic {
if len(u.AuthClients) > 0 {
p.AuthClients = append([]AuthClientEntry(nil), u.AuthClients...)
}
p.GitHubLinked = strings.TrimSpace(u.GitHubUserID) != ""
p.GiteaLinked = strings.TrimSpace(u.GiteaUserID) != ""
p.LinuxdoLinked = strings.TrimSpace(u.LinuxdoUserID) != ""
p.GoogleLinked = strings.TrimSpace(u.GoogleUserID) != ""
p.MicrosoftLinked = strings.TrimSpace(u.MicrosoftUserID) != ""
return p
}

View File

@@ -0,0 +1,72 @@
package storage
import (
"strings"
"time"
)
const configHotReloadMinInterval = 2 * time.Second
// MaybeSyncRuntimeConfigFromDB 在「管理后台在其它节点保存了配置」时,用 MySQL 中的最新行覆盖本进程内存缓存,避免多副本部署必须重启才生效。
// 带最短间隔节流,减少读库频率。
func (s *Store) MaybeSyncRuntimeConfigFromDB() {
s.hotReloadMu.Lock()
elapsed := time.Since(s.lastHotReload)
should := s.lastHotReload.IsZero() || elapsed >= configHotReloadMinInterval
if should {
s.lastHotReload = time.Now()
}
s.hotReloadMu.Unlock()
if !should {
return
}
_ = s.syncRuntimeConfigFromDB()
}
func (s *Store) syncRuntimeConfigFromDB() error {
if err := s.reloadOAuthConfigFromDB(); err != nil {
return err
}
if err := s.reloadTurnstileConfigFromDB(); err != nil {
return err
}
// 含邀请码列表等
return s.loadOrCreateRegistrationConfig()
}
func (s *Store) reloadOAuthConfigFromDB() error {
var cfg OAuthConfig
found, err := s.getConfig("oauth", &cfg)
if err != nil || !found {
return err
}
if strings.TrimSpace(cfg.GiteaBaseURL) == "" {
cfg.GiteaBaseURL = defaultGiteaBaseURL
}
cfg.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GiteaBaseURL), "/")
if strings.TrimSpace(cfg.LinuxdoConnectBaseURL) == "" {
cfg.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL
}
cfg.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LinuxdoConnectBaseURL), "/")
if strings.TrimSpace(cfg.MicrosoftTenant) == "" {
cfg.MicrosoftTenant = defaultMicrosoftTenant
} else {
cfg.MicrosoftTenant = strings.TrimSpace(cfg.MicrosoftTenant)
}
s.mu.Lock()
s.oauthConfig = cfg
s.mu.Unlock()
return nil
}
func (s *Store) reloadTurnstileConfigFromDB() error {
var cfg TurnstileConfig
_, err := s.getConfig("turnstile", &cfg)
if err != nil {
return err
}
s.mu.Lock()
s.turnstileConfig = cfg
s.mu.Unlock()
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"sproutgate-backend/internal/models"
)
@@ -104,10 +105,30 @@ type DBUser struct {
BannedAt string `gorm:"column:banned_at;size:50"`
TokenEpoch int64 `gorm:"column:token_epoch;default:0"`
AuthClients AuthClientSlice `gorm:"column:auth_clients;type:mediumtext"`
GitHubUserID *string `gorm:"column:github_user_id;size:64;uniqueIndex:idx_users_github_id"`
GiteaUserID *string `gorm:"column:gitea_user_id;size:64;uniqueIndex:idx_users_gitea_id"`
LinuxdoUserID *string `gorm:"column:linuxdo_user_id;size:64;uniqueIndex:idx_users_linuxdo_id"`
GoogleUserID *string `gorm:"column:google_user_id;size:128;uniqueIndex:idx_users_google_id"`
MicrosoftUserID *string `gorm:"column:microsoft_user_id;size:128;uniqueIndex:idx_users_microsoft_id"`
}
func (DBUser) TableName() string { return "users" }
func strPtrOrNil(s string) *string {
t := strings.TrimSpace(s)
if t == "" {
return nil
}
return &t
}
func strDeref(p *string) string {
if p == nil {
return ""
}
return *p
}
// DBUserFromRecord 将领域模型转换为 GORM 模型(导出供 migrate 工具使用)。
func DBUserFromRecord(r models.UserRecord) DBUser {
return dbUserFromRecord(r)
@@ -141,6 +162,11 @@ func dbUserFromRecord(r models.UserRecord) DBUser {
BannedAt: r.BannedAt,
TokenEpoch: r.TokenEpoch,
AuthClients: AuthClientSlice(r.AuthClients),
GitHubUserID: strPtrOrNil(r.GitHubUserID),
GiteaUserID: strPtrOrNil(r.GiteaUserID),
LinuxdoUserID: strPtrOrNil(r.LinuxdoUserID),
GoogleUserID: strPtrOrNil(r.GoogleUserID),
MicrosoftUserID: strPtrOrNil(r.MicrosoftUserID),
}
}
@@ -172,6 +198,11 @@ func (d DBUser) toRecord() models.UserRecord {
BannedAt: d.BannedAt,
TokenEpoch: d.TokenEpoch,
AuthClients: []models.AuthClientEntry(d.AuthClients),
GitHubUserID: strDeref(d.GitHubUserID),
GiteaUserID: strDeref(d.GiteaUserID),
LinuxdoUserID: strDeref(d.LinuxdoUserID),
GoogleUserID: strDeref(d.GoogleUserID),
MicrosoftUserID: strDeref(d.MicrosoftUserID),
}
}

View File

@@ -0,0 +1,509 @@
package storage
import (
"errors"
"net/url"
"strings"
"gorm.io/gorm"
"sproutgate-backend/internal/models"
)
const defaultGiteaBaseURL = "https://git.shumengya.top"
const defaultLinuxdoConnectBaseURL = "https://connect.linux.do"
const defaultMicrosoftTenant = "common"
func (s *Store) loadOrCreateOAuthConfig() error {
var cfg OAuthConfig
found, err := s.getConfig("oauth", &cfg)
if err != nil {
return err
}
changed := !found
if strings.TrimSpace(cfg.GiteaBaseURL) == "" {
cfg.GiteaBaseURL = defaultGiteaBaseURL
changed = true
}
cfg.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GiteaBaseURL), "/")
if strings.TrimSpace(cfg.LinuxdoConnectBaseURL) == "" {
cfg.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL
changed = true
}
cfg.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LinuxdoConnectBaseURL), "/")
if strings.TrimSpace(cfg.MicrosoftTenant) == "" {
cfg.MicrosoftTenant = defaultMicrosoftTenant
changed = true
} else {
cfg.MicrosoftTenant = strings.TrimSpace(cfg.MicrosoftTenant)
}
if len(cfg.AllowedReturnPrefixes) == 0 {
cfg.AllowedReturnPrefixes = []string{
"http://localhost:5173/",
"http://127.0.0.1:5173/",
}
changed = true
}
if changed {
if err := s.setConfig("oauth", cfg); err != nil {
return err
}
}
s.mu.Lock()
s.oauthConfig = cfg
s.mu.Unlock()
return nil
}
// GetOAuthConfig 返回当前 OAuth 设置副本(含密钥;仅服务端使用)。
func (s *Store) GetOAuthConfig() OAuthConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.oauthConfig
}
// MergeUpdateOAuthConfig 更新 OAuth 设置;空字符串的 ClientSecret 表示保留原值。
func (s *Store) MergeUpdateOAuthConfig(in OAuthConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
prev := s.oauthConfig
if strings.TrimSpace(in.GitHubClientSecret) == "" {
in.GitHubClientSecret = prev.GitHubClientSecret
}
if strings.TrimSpace(in.GiteaClientSecret) == "" {
in.GiteaClientSecret = prev.GiteaClientSecret
}
if strings.TrimSpace(in.LinuxdoClientSecret) == "" {
in.LinuxdoClientSecret = prev.LinuxdoClientSecret
}
if strings.TrimSpace(in.GoogleClientSecret) == "" {
in.GoogleClientSecret = prev.GoogleClientSecret
}
if strings.TrimSpace(in.MicrosoftClientSecret) == "" {
in.MicrosoftClientSecret = prev.MicrosoftClientSecret
}
if strings.TrimSpace(in.GiteaBaseURL) == "" {
in.GiteaBaseURL = defaultGiteaBaseURL
}
in.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(in.GiteaBaseURL), "/")
if strings.TrimSpace(in.LinuxdoConnectBaseURL) == "" {
in.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL
}
in.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(in.LinuxdoConnectBaseURL), "/")
if in.AllowedReturnPrefixes == nil || len(in.AllowedReturnPrefixes) == 0 {
in.AllowedReturnPrefixes = prev.AllowedReturnPrefixes
}
if strings.TrimSpace(in.MicrosoftTenant) == "" {
if strings.TrimSpace(prev.MicrosoftTenant) != "" {
in.MicrosoftTenant = prev.MicrosoftTenant
} else {
in.MicrosoftTenant = defaultMicrosoftTenant
}
} else {
in.MicrosoftTenant = strings.TrimSpace(in.MicrosoftTenant)
}
if err := s.setConfig("oauth", in); err != nil {
return err
}
s.oauthConfig = in
return nil
}
// PublicOAuthFlags 可对外暴露的开关(无密钥)。
type PublicOAuthFlags struct {
GitHubEnabled bool `json:"githubEnabled"`
GiteaEnabled bool `json:"giteaEnabled"`
GoogleEnabled bool `json:"googleEnabled"`
MicrosoftEnabled bool `json:"microsoftEnabled"`
LinuxdoEnabled bool `json:"linuxdoEnabled"`
}
// PublicOAuthFlags 返回是否启用各提供商。
func (s *Store) PublicOAuthFlags() PublicOAuthFlags {
s.mu.RLock()
defer s.mu.RUnlock()
return PublicOAuthFlags{
GitHubEnabled: s.oauthConfig.GitHubEnabled,
GiteaEnabled: s.oauthConfig.GiteaEnabled,
GoogleEnabled: s.oauthConfig.GoogleEnabled,
MicrosoftEnabled: s.oauthConfig.MicrosoftEnabled,
LinuxdoEnabled: s.oauthConfig.LinuxdoEnabled,
}
}
// OAuthSignUpAllowed 是否允许用 OAuth 创建新用户(结合注册策略与后台开关)。
func (s *Store) OAuthSignUpAllowed() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.oauthConfig.AllowOAuthSignUpWhenInviteRequired {
return true
}
if s.registrationConfig.RequireInviteCode {
return false
}
return true
}
// GetUserByEmail 主邮箱精确匹配(大小写不敏感,存库一般为小写)。
func (s *Store) GetUserByEmail(email string) (models.UserRecord, bool, error) {
email = strings.TrimSpace(email)
if email == "" {
return models.UserRecord{}, false, nil
}
var row DBUser
result := s.db.First(&row, "LOWER(email) = LOWER(?)", email)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
// GetUserByGitHubID 非空时查询。
func (s *Store) GetUserByGitHubID(id string) (models.UserRecord, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return models.UserRecord{}, false, nil
}
var row DBUser
result := s.db.First(&row, "github_user_id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
// GetUserByGiteaID 非空时查询。
func (s *Store) GetUserByGiteaID(id string) (models.UserRecord, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return models.UserRecord{}, false, nil
}
var row DBUser
result := s.db.First(&row, "gitea_user_id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
// SetUserGitHubID 将 GitHub 用户 ID 绑定到账户id 被他人占用时返回错误。
func (s *Store) SetUserGitHubID(account, githubID string) error {
account = strings.TrimSpace(account)
githubID = strings.TrimSpace(githubID)
if account == "" || githubID == "" {
return errors.New("account and id required")
}
other, found, err := s.GetUserByGitHubID(githubID)
if err != nil {
return err
}
if found && !strings.EqualFold(other.Account, account) {
return errors.New("github account already linked to another user")
}
u, found, err := s.GetUser(account)
if err != nil || !found {
return errors.New("user not found")
}
if strings.TrimSpace(u.GitHubUserID) != "" && u.GitHubUserID != githubID {
return errors.New("user already has a different GitHub link")
}
u.GitHubUserID = githubID
return s.SaveUser(u)
}
// SetUserGiteaID 将 Gitea 用户 ID 绑定到账户。
func (s *Store) SetUserGiteaID(account, giteaID string) error {
account = strings.TrimSpace(account)
giteaID = strings.TrimSpace(giteaID)
if account == "" || giteaID == "" {
return errors.New("account and id required")
}
other, found, err := s.GetUserByGiteaID(giteaID)
if err != nil {
return err
}
if found && !strings.EqualFold(other.Account, account) {
return errors.New("gitea account already linked to another user")
}
u, found, err := s.GetUser(account)
if err != nil || !found {
return errors.New("user not found")
}
if strings.TrimSpace(u.GiteaUserID) != "" && u.GiteaUserID != giteaID {
return errors.New("user already has a different Gitea link")
}
u.GiteaUserID = giteaID
return s.SaveUser(u)
}
// ClearUserGitHubID 解绑 GitHub。
func (s *Store) ClearUserGitHubID(account string) error {
u, found, err := s.GetUser(strings.TrimSpace(account))
if err != nil {
return err
}
if !found {
return errors.New("user not found")
}
u.GitHubUserID = ""
return s.SaveUser(u)
}
// ClearUserGiteaID 解绑 Gitea。
func (s *Store) ClearUserGiteaID(account string) error {
u, found, err := s.GetUser(strings.TrimSpace(account))
if err != nil {
return err
}
if !found {
return errors.New("user not found")
}
u.GiteaUserID = ""
return s.SaveUser(u)
}
// GetUserByLinuxdoID 非空时查询LINUX DO Connect 用户主键)。
func (s *Store) GetUserByLinuxdoID(id string) (models.UserRecord, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return models.UserRecord{}, false, nil
}
var row DBUser
result := s.db.First(&row, "linuxdo_user_id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
// SetUserLinuxdoID 绑定 LINUX DO 用户 ID。
func (s *Store) SetUserLinuxdoID(account, linuxdoID string) error {
account = strings.TrimSpace(account)
linuxdoID = strings.TrimSpace(linuxdoID)
if account == "" || linuxdoID == "" {
return errors.New("account and id required")
}
other, found, err := s.GetUserByLinuxdoID(linuxdoID)
if err != nil {
return err
}
if found && !strings.EqualFold(other.Account, account) {
return errors.New("linux.do account already linked to another user")
}
u, found, err := s.GetUser(account)
if err != nil || !found {
return errors.New("user not found")
}
if strings.TrimSpace(u.LinuxdoUserID) != "" && u.LinuxdoUserID != linuxdoID {
return errors.New("user already has a different LINUX DO link")
}
u.LinuxdoUserID = linuxdoID
return s.SaveUser(u)
}
// ClearUserLinuxdoID 解绑 LINUX DO。
func (s *Store) ClearUserLinuxdoID(account string) error {
u, found, err := s.GetUser(strings.TrimSpace(account))
if err != nil {
return err
}
if !found {
return errors.New("user not found")
}
u.LinuxdoUserID = ""
return s.SaveUser(u)
}
// GetUserByGoogleID 非空时查询。
func (s *Store) GetUserByGoogleID(id string) (models.UserRecord, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return models.UserRecord{}, false, nil
}
var row DBUser
result := s.db.First(&row, "google_user_id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
// SetUserGoogleID 将 Google 用户 ID 绑定到账户。
func (s *Store) SetUserGoogleID(account, googleID string) error {
account = strings.TrimSpace(account)
googleID = strings.TrimSpace(googleID)
if account == "" || googleID == "" {
return errors.New("account and id required")
}
other, found, err := s.GetUserByGoogleID(googleID)
if err != nil {
return err
}
if found && !strings.EqualFold(other.Account, account) {
return errors.New("google account already linked to another user")
}
u, found, err := s.GetUser(account)
if err != nil || !found {
return errors.New("user not found")
}
if strings.TrimSpace(u.GoogleUserID) != "" && u.GoogleUserID != googleID {
return errors.New("user already has a different Google link")
}
u.GoogleUserID = googleID
return s.SaveUser(u)
}
// ClearUserGoogleID 解绑 Google。
func (s *Store) ClearUserGoogleID(account string) error {
u, found, err := s.GetUser(strings.TrimSpace(account))
if err != nil {
return err
}
if !found {
return errors.New("user not found")
}
u.GoogleUserID = ""
return s.SaveUser(u)
}
// GetUserByMicrosoftID 非空时查询。
func (s *Store) GetUserByMicrosoftID(id string) (models.UserRecord, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return models.UserRecord{}, false, nil
}
var row DBUser
result := s.db.First(&row, "microsoft_user_id = ?", id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return models.UserRecord{}, false, nil
}
if result.Error != nil {
return models.UserRecord{}, false, result.Error
}
return row.toRecord(), true, nil
}
// SetUserMicrosoftID 将 Microsoft 用户 ID 绑定到账户。
func (s *Store) SetUserMicrosoftID(account, msID string) error {
account = strings.TrimSpace(account)
msID = strings.TrimSpace(msID)
if account == "" || msID == "" {
return errors.New("account and id required")
}
other, found, err := s.GetUserByMicrosoftID(msID)
if err != nil {
return err
}
if found && !strings.EqualFold(other.Account, account) {
return errors.New("microsoft account already linked to another user")
}
u, found, err := s.GetUser(account)
if err != nil || !found {
return errors.New("user not found")
}
if strings.TrimSpace(u.MicrosoftUserID) != "" && u.MicrosoftUserID != msID {
return errors.New("user already has a different Microsoft link")
}
u.MicrosoftUserID = msID
return s.SaveUser(u)
}
// ClearUserMicrosoftID 解绑 Microsoft。
func (s *Store) ClearUserMicrosoftID(account string) error {
u, found, err := s.GetUser(strings.TrimSpace(account))
if err != nil {
return err
}
if !found {
return errors.New("user not found")
}
u.MicrosoftUserID = ""
return s.SaveUser(u)
}
// ProposeUniqueOAuthAccount 生成未占用的随机自助格式账户名,用于 OAuth 注册。
func (s *Store) ProposeUniqueOAuthAccount() (string, error) {
for i := 0; i < 48; i++ {
raw, err := randomOAuthLocalPart(12)
if err != nil {
return "", err
}
acc := "oauth" + raw
if err := s.ValidateSelfServiceAccount(acc); err != nil {
continue
}
_, found, err := s.GetUser(acc)
if err != nil {
return "", err
}
if !found {
return acc, nil
}
}
return "", errors.New("failed to allocate unique account")
}
// randomOAuthLocalPart 小写字母与数字,长度 n。
func randomOAuthLocalPart(n int) (string, error) {
if n < 1 {
n = 8
}
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
buf := make([]byte, n)
randBytes, err := generateSecret()
if err != nil {
return "", err
}
for i := 0; i < n; i++ {
buf[i] = alphabet[int(randBytes[i%len(randBytes)])%len(alphabet)]
}
return string(buf), nil
}
// IsReturnURLAllowed 校验回跳地址是否以白名单中某一前缀开头(需为 http(s) 绝对地址)。
func (c OAuthConfig) IsReturnURLAllowed(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
if u.Scheme != "https" && u.Scheme != "http" {
return false
}
norm := u.String()
for _, pfx := range c.AllowedReturnPrefixes {
pfx = strings.TrimSpace(pfx)
if pfx == "" {
continue
}
if !strings.HasSuffix(pfx, "/") {
pfx += "/"
}
if strings.HasPrefix(norm, pfx) {
return true
}
base := strings.TrimRight(pfx, "/")
if norm == base || strings.HasPrefix(norm, base+"/") {
return true
}
}
return false
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"strings"
"sync"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -40,6 +41,42 @@ type CheckInConfig struct {
RewardCoins int `json:"rewardCoins"`
}
// OAuthConfig 第三方登录GitHub / Gitea / Google / Microsoft / Linux.do密钥也可由环境变量覆盖。
type OAuthConfig struct {
GitHubEnabled bool `json:"githubEnabled"`
GiteaEnabled bool `json:"giteaEnabled"`
GoogleEnabled bool `json:"googleEnabled"`
MicrosoftEnabled bool `json:"microsoftEnabled"`
LinuxdoEnabled bool `json:"linuxdoEnabled"`
GitHubClientID string `json:"githubClientId"`
GitHubClientSecret string `json:"githubClientSecret"`
GiteaBaseURL string `json:"giteaBaseUrl"`
GiteaClientID string `json:"giteaClientId"`
GiteaClientSecret string `json:"giteaClientSecret"`
GoogleClientID string `json:"googleClientId"`
GoogleClientSecret string `json:"googleClientSecret"`
MicrosoftClientID string `json:"microsoftClientId"`
MicrosoftClientSecret string `json:"microsoftClientSecret"`
MicrosoftTenant string `json:"microsoftTenant"`
LinuxdoConnectBaseURL string `json:"linuxdoConnectBaseUrl"`
LinuxdoClientID string `json:"linuxdoClientId"`
LinuxdoClientSecret string `json:"linuxdoClientSecret"`
GitHubLogoURL string `json:"githubLogoUrl"`
GiteaLogoURL string `json:"giteaLogoUrl"`
GoogleLogoURL string `json:"googleLogoUrl"`
MicrosoftLogoURL string `json:"microsoftLogoUrl"`
LinuxdoLogoURL string `json:"linuxdoLogoUrl"`
AllowedReturnPrefixes []string `json:"allowedReturnPrefixes"`
AllowOAuthSignUpWhenInviteRequired bool `json:"allowOAuthSignUpWhenInviteRequired"`
}
// TurnstileConfig Cloudflare Turnstile 人机验证配置。
type TurnstileConfig struct {
Enabled bool `json:"enabled"`
SiteKey string `json:"siteKey"`
SecretKey string `json:"secretKey"`
}
// ─── Store ─────────────────────────────────────────────────────────────────────
type Store struct {
@@ -50,7 +87,11 @@ type Store struct {
emailConfig EmailConfig
checkInConfig CheckInConfig
registrationConfig RegistrationConfig
oauthConfig OAuthConfig
turnstileConfig TurnstileConfig
mu sync.RWMutex
hotReloadMu sync.Mutex
lastHotReload time.Time
}
// NewStore 接收已建立连接的 *gorm.DB自动迁移表结构并加载配置。
@@ -85,6 +126,12 @@ func NewStore(db *gorm.DB) (*Store, error) {
if err := store.loadOrCreateRegistrationConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateOAuthConfig(); err != nil {
return nil, err
}
if err := store.loadOrCreateTurnstileConfig(); err != nil {
return nil, err
}
return store, nil
}

View File

@@ -0,0 +1,33 @@
package storage
import "strings"
func (s *Store) GetTurnstileConfig() TurnstileConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.turnstileConfig
}
func (s *Store) UpdateTurnstileConfig(in TurnstileConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
if strings.TrimSpace(in.SecretKey) == "" {
in.SecretKey = s.turnstileConfig.SecretKey
}
in.SiteKey = strings.TrimSpace(in.SiteKey)
if err := s.setConfig("turnstile", in); err != nil {
return err
}
s.turnstileConfig = in
return nil
}
func (s *Store) loadOrCreateTurnstileConfig() error {
var cfg TurnstileConfig
_, err := s.getConfig("turnstile", &cfg)
if err != nil {
return err
}
s.turnstileConfig = cfg
return nil
}