Update SproutGate

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

View File

@@ -5,6 +5,6 @@
data
node_modules
dist
# 勿忽略 dist/:默认 Dockerfile 从 dist/sproutgate-backend 复制(先 build-linux-amd64.bat
*.log

View File

@@ -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"]

View File

@@ -0,0 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
call "%~dp0build-linux-amd64.bat"
exit /b %errorlevel%

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
)

View File

@@ -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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 JWTAuthorization 头填写「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 == "" {