Update SproutGate
This commit is contained in:
@@ -5,6 +5,6 @@
|
||||
|
||||
data
|
||||
node_modules
|
||||
dist
|
||||
# 勿忽略 dist/:默认 Dockerfile 从 dist/sproutgate-backend 复制(先 build-linux-amd64.bat)
|
||||
|
||||
*.log
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
FROM golang:1.20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/sproutgate-backend .
|
||||
# 默认:只复制本机/CI 已构建的 Linux amd64 二进制,不在镜像里执行 go build。
|
||||
# 1) 在 sproutgate-backend 目录执行: build-linux-amd64.bat
|
||||
# 或: set CGO_ENABLED=0&& set GOOS=linux&& set GOARCH=amd64&& go build -trimpath -ldflags="-s -w" -o dist\sproutgate-backend .
|
||||
# 2) 再: docker compose build
|
||||
#
|
||||
# 若必须在 Docker 里从源码编译,请用: docker build -f Dockerfile.build -t ... .
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
@@ -14,8 +12,7 @@ WORKDIR /app
|
||||
ENV PORT=8080
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
COPY --from=builder /out/sproutgate-backend /usr/local/bin/sproutgate-backend
|
||||
COPY API_DOCS.md /app/API_DOCS.md
|
||||
COPY dist/sproutgate-backend /usr/local/bin/sproutgate-backend
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
5
sproutgate-backend/build-Linux-amd.bat
Normal file
5
sproutgate-backend/build-Linux-amd.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
call "%~dp0build-linux-amd64.bat"
|
||||
exit /b %errorlevel%
|
||||
58
sproutgate-backend/build-linux-amd64.bat
Normal file
58
sproutgate-backend/build-linux-amd64.bat
Normal file
@@ -0,0 +1,58 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
cd /d "%SCRIPT_DIR%"
|
||||
if errorlevel 1 goto fail_cd
|
||||
|
||||
set "APP_NAME=sproutgate-backend"
|
||||
set "DIST_DIR=%SCRIPT_DIR%dist"
|
||||
set "OUTPUT_FILE=%DIST_DIR%\%APP_NAME%"
|
||||
set "GOCACHE_DIR=%TEMP%\%APP_NAME%-gocache"
|
||||
|
||||
where go >nul 2>nul
|
||||
if errorlevel 1 goto fail_go
|
||||
|
||||
if not exist "%DIST_DIR%" mkdir "%DIST_DIR%"
|
||||
if errorlevel 1 goto fail_dist
|
||||
|
||||
if not exist "%GOCACHE_DIR%" mkdir "%GOCACHE_DIR%"
|
||||
|
||||
echo [INFO] Working directory: %CD%
|
||||
echo [INFO] Building %APP_NAME% for linux amd64...
|
||||
echo [INFO] Output: %OUTPUT_FILE%
|
||||
|
||||
set "CGO_ENABLED=0"
|
||||
set "GOOS=linux"
|
||||
set "GOARCH=amd64"
|
||||
set "GOCACHE=%GOCACHE_DIR%"
|
||||
|
||||
echo [INFO] Regenerating Swagger docs (swag)...
|
||||
go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o docs --parseInternal
|
||||
if errorlevel 1 goto fail_build
|
||||
|
||||
go build -trimpath -ldflags "-s -w" -o "%OUTPUT_FILE%" .
|
||||
if errorlevel 1 goto fail_build
|
||||
|
||||
echo [OK] Build completed: %OUTPUT_FILE%
|
||||
exit /b 0
|
||||
|
||||
:fail_cd
|
||||
echo [ERROR] Cannot change to script directory: %SCRIPT_DIR%
|
||||
goto pause_and_exit
|
||||
|
||||
:fail_go
|
||||
echo [ERROR] Go was not found in PATH.
|
||||
goto pause_and_exit
|
||||
|
||||
:fail_dist
|
||||
echo [ERROR] Cannot create output directory: %DIST_DIR%
|
||||
goto pause_and_exit
|
||||
|
||||
:fail_build
|
||||
echo [ERROR] Build failed. Check the Go output above.
|
||||
goto pause_and_exit
|
||||
|
||||
:pause_and_exit
|
||||
pause
|
||||
exit /b 1
|
||||
2190
sproutgate-backend/docs/docs.go
Normal file
2190
sproutgate-backend/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
2170
sproutgate-backend/docs/swagger.json
Normal file
2170
sproutgate-backend/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1428
sproutgate-backend/docs/swagger.yaml
Normal file
1428
sproutgate-backend/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,35 @@
|
||||
module sproutgate-backend
|
||||
|
||||
go 1.20
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.5.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
golang.org/x/crypto v0.23.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
@@ -24,22 +37,25 @@ require (
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.20.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
@@ -16,11 +24,24 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
|
||||
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
@@ -33,23 +54,34 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -57,16 +89,19 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -74,31 +109,78 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
961
sproutgate-backend/internal/handlers/oauth.go
Normal file
961
sproutgate-backend/internal/handlers/oauth.go
Normal 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))
|
||||
}
|
||||
123
sproutgate-backend/internal/handlers/oauth_admin.go
Normal file
123
sproutgate-backend/internal/handlers/oauth_admin.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
95
sproutgate-backend/internal/handlers/turnstile.go
Normal file
95
sproutgate-backend/internal/handlers/turnstile.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
72
sproutgate-backend/internal/storage/config_hot_reload.go
Normal file
72
sproutgate-backend/internal/storage/config_hot_reload.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
509
sproutgate-backend/internal/storage/oauth.go
Normal file
509
sproutgate-backend/internal/storage/oauth.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
sproutgate-backend/internal/storage/turnstile.go
Normal file
33
sproutgate-backend/internal/storage/turnstile.go
Normal 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
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
//go:generate swag init -g main.go -o docs --parseInternal
|
||||
|
||||
// @title 萌芽账户认证中心 API
|
||||
// @version 0.1.0
|
||||
// @description 统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。
|
||||
|
||||
// @securityDefinitions.apikey AdminToken
|
||||
// @in header
|
||||
// @name X-Admin-Token
|
||||
// @description 管理端令牌;也可使用 Query `token` 或与部分客户端相同的 `Authorization` 头(见实现)。
|
||||
|
||||
// @schemes http https
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -8,12 +27,72 @@ import (
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
_ "sproutgate-backend/docs"
|
||||
"sproutgate-backend/internal/database"
|
||||
"sproutgate-backend/internal/handlers"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func apiIntroPayload() gin.H {
|
||||
return gin.H{
|
||||
"name": "SproutGate API",
|
||||
"title": "萌芽账户认证中心",
|
||||
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
|
||||
"version": "0.1.0",
|
||||
"links": gin.H{
|
||||
"health": "GET /api/health",
|
||||
"docs": "GET /swagger/index.html",
|
||||
},
|
||||
"routePrefixes": []string{
|
||||
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
|
||||
"/api/public — 公开用户目录与资料、主页点赞、注册策略",
|
||||
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRoot
|
||||
// @Summary API 简介 JSON
|
||||
// @Description 与 GET /api 相同,返回名称、版本与路由前缀说明。
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router / [get]
|
||||
func GetRoot(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntroPayload())
|
||||
}
|
||||
|
||||
// GetAPI
|
||||
// @Summary API 简介 JSON
|
||||
// @Description 与 GET / 相同。
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api [get]
|
||||
func GetAPI(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntroPayload())
|
||||
}
|
||||
|
||||
// GetHealth
|
||||
// @Summary 健康检查
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/health [get]
|
||||
func GetHealth(c *gin.Context) {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"env": env,
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := database.Open()
|
||||
if err != nil {
|
||||
@@ -35,37 +114,15 @@ func main() {
|
||||
|
||||
handler := handlers.NewHandler(store)
|
||||
|
||||
apiIntro := gin.H{
|
||||
"name": "SproutGate API",
|
||||
"title": "萌芽账户认证中心",
|
||||
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
|
||||
"version": "0.1.0",
|
||||
"links": gin.H{
|
||||
"health": "GET /api/health",
|
||||
},
|
||||
"routePrefixes": []string{
|
||||
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
|
||||
"/api/public — 公开用户目录与资料、主页点赞、注册策略",
|
||||
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)",
|
||||
},
|
||||
}
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntro)
|
||||
})
|
||||
router.GET("/api", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntro)
|
||||
})
|
||||
router.GET("/", GetRoot)
|
||||
router.GET("/api", GetAPI)
|
||||
|
||||
router.GET("/api/health", func(c *gin.Context) {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"env": env,
|
||||
})
|
||||
router.GET("/api/health", GetHealth)
|
||||
|
||||
router.GET("/api/docs", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/swagger/index.html")
|
||||
})
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
router.POST("/api/auth/login", handler.Login)
|
||||
router.POST("/api/auth/register", handler.Register)
|
||||
@@ -78,6 +135,10 @@ func main() {
|
||||
router.GET("/api/auth/me", handler.Me)
|
||||
router.POST("/api/auth/check-in", handler.CheckIn)
|
||||
router.PUT("/api/auth/profile", handler.UpdateProfile)
|
||||
router.GET("/api/auth/oauth/:provider/start", handler.OAuthStart)
|
||||
router.GET("/api/auth/oauth/:provider/callback", handler.OAuthCallback)
|
||||
router.POST("/api/auth/oauth/:provider/bind", handler.OAuthBindURL)
|
||||
router.DELETE("/api/auth/oauth/:provider", handler.OAuthUnlink)
|
||||
router.GET("/api/public/users", handler.ListPublicUsers)
|
||||
router.GET("/api/public/users/:account", handler.GetPublicUser)
|
||||
router.POST("/api/public/users/:account/like", handler.PostPublicProfileLike)
|
||||
@@ -95,6 +156,10 @@ func main() {
|
||||
admin.PUT("/registration", handler.PutAdminRegistrationPolicy)
|
||||
admin.POST("/registration/invites", handler.PostAdminInvite)
|
||||
admin.DELETE("/registration/invites/:code", handler.DeleteAdminInvite)
|
||||
admin.GET("/oauth", handler.GetAdminOAuth)
|
||||
admin.PUT("/oauth", handler.PutAdminOAuth)
|
||||
admin.GET("/turnstile", handler.GetAdminTurnstile)
|
||||
admin.PUT("/turnstile", handler.PutAdminTurnstile)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
|
||||
Reference in New Issue
Block a user