From 3759de99d9286575f0d7a7fd613da0836dacbbc3 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 12 Jun 2025 09:49:09 -0700 Subject: [PATCH 01/10] Make dockerfile multi-stage With this it's possible to build the entire Cloudreve suite with a single image. This changes the assets version to use .Tag instead of .Version because without it we get a mismatch between the frontend and the backend. --- .goreleaser.yaml | 2 +- Dockerfile | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3730cf59..7b12c706 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,7 +4,7 @@ before: hooks: - go mod tidy - chmod +x ./.build/build-assets.sh - - ./.build/build-assets.sh {{.Version}} + - ./.build/build-assets.sh {{.Tag}} builds: - env: diff --git a/Dockerfile b/Dockerfile index f14a98de..b60a1312 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,19 @@ +# Build stage + +FROM ghcr.io/goreleaser/goreleaser:v2.10.2 AS builder + +WORKDIR /src + +# Install goreleaser +RUN apk add --no-cache bash curl git npm nodejs tar zip +RUN npm install -g yarn + +# Perform the build +COPY . . + +RUN goreleaser build --single-target --snapshot + +# Runtime stage FROM alpine:latest WORKDIR /cloudreve @@ -16,7 +32,7 @@ ENV CR_ENABLE_ARIA2=1 \ CR_SETTING_DEFAULT_media_meta_ffprobe=1 COPY .build/aria2.supervisor.conf .build/entrypoint.sh ./ -COPY cloudreve ./cloudreve +COPY --from=builder /src/dist/*/cloudreve ./cloudreve RUN chmod +x ./cloudreve \ && chmod +x ./entrypoint.sh From ba5779b353046271a3690ba48350dbb463a6925a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:44:40 +0000 Subject: [PATCH 02/10] chore(deps): bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 Bumps [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) from 0.5.12 to 0.5.14. - [Commits](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.14) --- updated-dependencies: - dependency-name: github.com/ulikunitz/xz dependency-version: 0.5.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c1834d6f..f0439b6e 100644 --- a/go.mod +++ b/go.mod @@ -135,7 +135,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - github.com/ulikunitz/xz v0.5.12 // indirect + github.com/ulikunitz/xz v0.5.14 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zclconf/go-cty v1.8.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect diff --git a/go.sum b/go.sum index ed10902b..b36e8f86 100644 --- a/go.sum +++ b/go.sum @@ -966,8 +966,8 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= -github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg= +github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/upyun/go-sdk v2.1.0+incompatible h1:OdjXghQ/TVetWV16Pz3C1/SUpjhGBVPr+cLiqZLLyq0= github.com/upyun/go-sdk v2.1.0+incompatible/go.mod h1:eu3F5Uz4b9ZE5bE5QsCL6mgSNWRwfj0zpJ9J626HEqs= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= From 02ddb420596e522aa6c8e18700a79ee30396b85c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 07:09:09 +0000 Subject: [PATCH 03/10] chore(deps): bump github.com/nwaples/rardecode/v2 from 2.1.0 to 2.2.0 Bumps [github.com/nwaples/rardecode/v2](https://github.com/nwaples/rardecode) from 2.1.0 to 2.2.0. - [Commits](https://github.com/nwaples/rardecode/compare/v2.1.0...v2.2.0) --- updated-dependencies: - dependency-name: github.com/nwaples/rardecode/v2 dependency-version: 2.2.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f059f966..db2c5388 100644 --- a/go.mod +++ b/go.mod @@ -129,7 +129,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mozillazg/go-httpheader v0.4.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/nwaples/rardecode/v2 v2.1.0 // indirect + github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/go.sum b/go.sum index f978d9b4..de601c1b 100644 --- a/go.sum +++ b/go.sum @@ -748,8 +748,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt3d53pc1VYcphSCIaYAJtnPYnr3Zyn8fMq2wvPGPso= github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= -github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= -github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= +github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= From 5a13b1037cf3413c72e2267cc5ee0b3d1eb779f6 Mon Sep 17 00:00:00 2001 From: Ricky Date: Mon, 15 Jun 2026 15:06:29 +0800 Subject: [PATCH 04/10] feat(oidc): add OIDC provider support --- inventory/setting.go | 6 +- pkg/auth/oidc.go | 123 +++++++++++++++++++++++++++++++++++ routers/controllers/oauth.go | 17 +++++ routers/router.go | 2 + service/oauth/oauth.go | 47 ++++++++++++- service/oauth/oidc.go | 77 ++++++++++++++++++++++ service/oauth/response.go | 18 +++++ 7 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 pkg/auth/oidc.go create mode 100644 service/oauth/oidc.go diff --git a/inventory/setting.go b/inventory/setting.go index 3e2aef1c..34ebe6f2 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -524,6 +524,7 @@ var DefaultSettings = map[string]string{ "theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`, "max_parallel_transfer": `4`, "secret_key": util.RandStringRunesCrypto(256), + "oidc_signing_private_key": "", "temp_path": "temp", "avatar_path": "avatar", "avatar_size": "4194304", @@ -688,8 +689,9 @@ var DefaultSettings = map[string]string{ } var RedactedSettings = map[string]struct{}{ - "encrypt_master_key": {}, - "secret_key": {}, + "encrypt_master_key": {}, + "secret_key": {}, + "oidc_signing_private_key": {}, } func init() { diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 00000000..987ac250 --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/golang-jwt/jwt/v5" +) + +const OIDCSigningPrivateKeySetting = "oidc_signing_private_key" + +type OIDCIDTokenClaims struct { + jwt.RegisteredClaims + Nonce string `json:"nonce,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Picture string `json:"picture,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` +} + +type JWKSet struct { + Keys []JWK `json:"keys"` +} + +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +func SignOIDCIDToken(ctx context.Context, settingClient inventory.SettingClient, claims *OIDCIDTokenClaims) (string, error) { + key, kid, err := loadOrCreateOIDCSigningKey(ctx, settingClient) + if err != nil { + return "", err + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = kid + return token.SignedString(key) +} + +func OIDCJWKSet(ctx context.Context, settingClient inventory.SettingClient) (*JWKSet, error) { + key, kid, err := loadOrCreateOIDCSigningKey(ctx, settingClient) + if err != nil { + return nil, err + } + + return &JWKSet{Keys: []JWK{buildJWK(&key.PublicKey, kid)}}, nil +} + +func loadOrCreateOIDCSigningKey(ctx context.Context, settingClient inventory.SettingClient) (*rsa.PrivateKey, string, error) { + privateKeyRaw, err := settingClient.Get(ctx, OIDCSigningPrivateKeySetting) + if err != nil && !ent.IsNotFound(err) { + return nil, "", fmt.Errorf("failed to load OIDC signing key: %w", err) + } + if privateKeyRaw != "" { + key, err := parseRSAPrivateKey(privateKeyRaw) + if err != nil { + return nil, "", err + } + return key, oidcSigningKeyID(&key.PublicKey), nil + } + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, "", fmt.Errorf("failed to generate OIDC signing key: %w", err) + } + + privateKeyRaw = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) + if err := settingClient.Set(ctx, map[string]string{OIDCSigningPrivateKeySetting: privateKeyRaw}); err != nil { + return nil, "", fmt.Errorf("failed to persist OIDC signing key: %w", err) + } + + return key, oidcSigningKeyID(&key.PublicKey), nil +} + +func parseRSAPrivateKey(privateKeyRaw string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(privateKeyRaw)) + if block == nil { + return nil, fmt.Errorf("invalid OIDC signing key PEM") + } + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid OIDC signing key: %w", err) + } + + return key, nil +} + +func oidcSigningKeyID(key *rsa.PublicKey) string { + der, _ := x509.MarshalPKIXPublicKey(key) + sum := sha256.Sum256(der) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func buildJWK(key *rsa.PublicKey, kid string) JWK { + return JWK{ + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + } +} diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go index 9c4472bf..bc196af2 100644 --- a/routers/controllers/oauth.go +++ b/routers/controllers/oauth.go @@ -6,6 +6,23 @@ import ( "github.com/gin-gonic/gin" ) +func OpenIDConfiguration(c *gin.Context) { + service := &oauth.DiscoveryService{} + c.JSON(200, service.Get(c)) +} + +func OpenIDJWKS(c *gin.Context) { + service := &oauth.JWKService{} + res, err := service.Get(c) + if err != nil { + c.JSON(500, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, res) +} + func GetAppRegistration(c *gin.Context) { service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{}) app, err := service.Get(c) diff --git a/routers/router.go b/routers/router.go index 7c5a84cd..8ed0bbfb 100644 --- a/routers/router.go +++ b/routers/router.go @@ -208,6 +208,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { */ r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) r.Use(middleware.SharePreview(dep)) + r.GET(".well-known/openid-configuration", controllers.OpenIDConfiguration) r.Use(middleware.FrontendFileHandler(dep)) r.GET("manifest.json", controllers.Manifest) @@ -333,6 +334,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromForm[oauth.ExchangeTokenService](oauth.ExchangeTokenParamCtx{}), controllers.ExchangeToken, ) + oauthRouter.GET("jwks", controllers.OpenIDJWKS) oauthRouter.GET("userinfo", middleware.LoginRequired(), controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}), diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index a15940e1..bdf71f9a 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -16,6 +16,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/samber/lo" ) @@ -50,6 +51,7 @@ type ( ResponseType string `json:"response_type" binding:"required,eq=code"` RedirectURI string `json:"redirect_uri" binding:"required"` State string `json:"state" binding:"max=4096"` + Nonce string `json:"nonce" binding:"max=4096"` Scope string `json:"scope" binding:"required"` CodeChallenge string `json:"code_challenge" binding:"max=255"` CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"` @@ -84,7 +86,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { } // Parse requested scopes (space-separated per OAuth 2.0 spec) - requestedScopes := strings.Split(s.Scope, " ") + requestedScopes := strings.Fields(s.Scope) // Validate requested scopes: must be a subset of registered app scopes if !auth.ValidateScopes(requestedScopes, app.Scopes) { @@ -108,6 +110,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { UserID: user.ID, Scopes: requestedScopes, RedirectURI: s.RedirectURI, + Nonce: s.Nonce, CodeChallenge: s.CodeChallenge, } @@ -129,6 +132,7 @@ type ( ClientSecret string `form:"client_secret" binding:"required"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` Code string `form:"code" binding:"required"` + RedirectURI string `form:"redirect_uri" binding:"required"` CodeVerifier string `form:"code_verifier"` } ) @@ -159,6 +163,9 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) if authCode.ClientID != s.ClientID { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil) } + if authCode.RedirectURI != s.RedirectURI { + return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Redirect URI mismatch", nil) + } // 3. Verify PKCE: SHA256(code_verifier) should match code_challenge if authCode.CodeChallenge != "" { @@ -230,9 +237,47 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) } } + if lo.Contains(authCode.Scopes, types.ScopeOpenID) { + idToken, err := buildIDToken(c, dep, user, s.ClientID, authCode.Scopes, token.AccessExpires, authCode.Nonce) + if err != nil { + return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue ID token", err) + } + resp.IDToken = idToken + } + return resp, nil } +func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID string, scopes []string, expires time.Time, nonce string) (string, error) { + sub := hashid.EncodeUserID(dep.HashIDEncoder(), user.ID) + claims := &auth.OIDCIDTokenClaims{ + Nonce: nonce, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: oidcIssuer(c).String(), + Subject: sub, + Audience: jwt.ClaimStrings{clientID}, + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expires), + }, + } + + for _, scope := range scopes { + switch scope { + case types.ScopeProfile: + siteUrl := dep.SettingProvider().SiteURL(c) + claims.Name = user.Nick + claims.PreferredUsername = user.Nick + claims.Picture = routes.MasterUserAvatarUrl(siteUrl, sub).String() + claims.UpdatedAt = user.UpdatedAt.Unix() + case types.ScopeEmail: + claims.Email = user.Email + claims.EmailVerified = true + } + } + + return auth.SignOIDCIDToken(c, dep.SettingClient(), claims) +} + type ( DeleteOAuthGrantParamCtx struct{} DeleteOAuthGrantService struct { diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go new file mode 100644 index 00000000..73e4a035 --- /dev/null +++ b/service/oauth/oidc.go @@ -0,0 +1,77 @@ +package oauth + +import ( + "net/url" + + "github.com/cloudreve/Cloudreve/v4/application/constants" + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/gin-gonic/gin" +) + +type DiscoveryService struct{} + +type JWKService struct{} + +func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { + issuer := oidcIssuer(c) + return &DiscoveryResponse{ + Issuer: issuer.String(), + AuthorizationEndpoint: oidcEndpoint(issuer, "/session/authorize"), + TokenEndpoint: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/token"), + UserInfoEndpoint: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/userinfo"), + JWKSURI: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/jwks"), + ResponseTypesSupported: []string{ + "code", + }, + GrantTypesSupported: []string{ + "authorization_code", + }, + SubjectTypesSupported: []string{ + "public", + }, + IDTokenSigningAlgValuesSupported: []string{ + "RS256", + }, + TokenEndpointAuthMethods: []string{ + "client_secret_post", + }, + CodeChallengeMethodsSupported: []string{ + "S256", + }, + ScopesSupported: []string{ + types.ScopeOpenID, + types.ScopeProfile, + types.ScopeEmail, + }, + ClaimsSupported: []string{ + "sub", + "name", + "preferred_username", + "picture", + "updated_at", + "email", + "email_verified", + }, + } +} + +func (s *JWKService) Get(c *gin.Context) (*auth.JWKSet, error) { + dep := dependency.FromContext(c) + return auth.OIDCJWKSet(c, dep.SettingClient()) +} + +func oidcIssuer(c *gin.Context) *url.URL { + dep := dependency.FromContext(c) + issuer := *dep.SettingProvider().SiteURL(setting.UseFirstSiteUrl(c)) + issuer.RawQuery = "" + issuer.Fragment = "" + return &issuer +} + +func oidcEndpoint(issuer *url.URL, endpoint string) string { + route, _ := url.Parse(endpoint) + return issuer.ResolveReference(route).String() +} diff --git a/service/oauth/response.go b/service/oauth/response.go index 49a99df1..88d591d1 100644 --- a/service/oauth/response.go +++ b/service/oauth/response.go @@ -19,6 +19,22 @@ type AppRegistration struct { ConstentedScopes []string `json:"consented_scopes,omitempty"` } +type DiscoveryResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` +} + func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration { res := &AppRegistration{ ID: app.GUID, @@ -50,6 +66,7 @@ type TokenResponse struct { ExpiresIn int64 `json:"expires_in"` RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` Scope string `json:"scope"` } @@ -77,6 +94,7 @@ type AuthorizationCode struct { UserID int `json:"user_id"` Scopes []string `json:"scopes"` RedirectURI string `json:"redirect_uri"` + Nonce string `json:"nonce"` CodeChallenge string `json:"code_challenge"` } From 29efc6034c11fe28a454bedb0da00bfa32cbb9ae Mon Sep 17 00:00:00 2001 From: ayushi-work Date: Mon, 6 Jul 2026 21:00:44 +0530 Subject: [PATCH 05/10] feat(util): support IP range/CIDR filtering in event logs Signed-off-by: ayushi-work --- pkg/util/ip.go | 236 ++++++++++++++++++++++++ pkg/util/ip_test.go | 432 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 668 insertions(+) create mode 100644 pkg/util/ip.go create mode 100644 pkg/util/ip_test.go diff --git a/pkg/util/ip.go b/pkg/util/ip.go new file mode 100644 index 00000000..cebcc06d --- /dev/null +++ b/pkg/util/ip.go @@ -0,0 +1,236 @@ +package util + +import ( + "fmt" + "net" + "strings" +) + +// IPMatcher checks whether an IP address matches a given filter. +// The filter can be: +// - A single IP address (e.g., "192.168.1.1" or "::1") +// - A CIDR notation (e.g., "192.168.1.0/24" or "2001:db8::/32") +// - An IP range (e.g., "192.168.1.1-192.168.1.255") +// - A wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*") +type IPMatcher struct { + cidr *net.IPNet + ipStart net.IP + ipEnd net.IP + exact net.IP +} + +// NewIPMatcher creates an IPMatcher from a filter string. +// Supported formats: +// - CIDR: "192.168.1.0/24", "2001:db8::/32" +// - Range: "192.168.1.1-192.168.1.255", "::1-::10" +// - Wildcard: "192.168.1.*", "192.168.*.*", "10.*.*.*" +// - Single IP: "192.168.1.1", "::1" +func NewIPMatcher(filter string) (*IPMatcher, error) { + filter = strings.TrimSpace(filter) + if filter == "" { + return nil, fmt.Errorf("empty IP filter") + } + + // Try CIDR notation first + if strings.Contains(filter, "/") { + _, cidrNet, err := net.ParseCIDR(filter) + if err == nil { + return &IPMatcher{cidr: cidrNet}, nil + } + // If it has "/" but isn't valid CIDR, continue to try other formats + } + + // Try IP range (e.g., "192.168.1.1-192.168.1.255") + if strings.Count(filter, "-") == 1 { + parts := strings.SplitN(filter, "-", 2) + start := net.ParseIP(strings.TrimSpace(parts[0])) + end := net.ParseIP(strings.TrimSpace(parts[1])) + if start != nil && end != nil { + // Ensure same IP version + if (start.To4() != nil) == (end.To4() != nil) { + return &IPMatcher{ipStart: start, ipEnd: end}, nil + } + return nil, fmt.Errorf("IP range must contain same IP version: %s", filter) + } + } + + // Try wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*") + if strings.Contains(filter, "*") { + cidr, err := wildcardToCIDR(filter) + if err == nil { + return &IPMatcher{cidr: cidr}, nil + } + return nil, err + } + + // Try single exact IP + ip := net.ParseIP(filter) + if ip != nil { + return &IPMatcher{exact: ip}, nil + } + + return nil, fmt.Errorf("invalid IP filter format: %s", filter) +} + +// Match checks if the given IP address matches the filter. +// The ip parameter should be a string representation of an IP address. +// Returns an error if the IP string is invalid. +func (m *IPMatcher) Match(ip string) (bool, error) { + parsedIP := net.ParseIP(strings.TrimSpace(ip)) + if parsedIP == nil { + return false, fmt.Errorf("invalid IP address: %s", ip) + } + + return m.MatchIP(parsedIP), nil +} + +// MatchIP checks if the given parsed IP address matches the filter. +func (m *IPMatcher) MatchIP(ip net.IP) bool { + if ip == nil { + return false + } + + switch { + case m.cidr != nil: + return m.cidr.Contains(ip) + case m.ipStart != nil && m.ipEnd != nil: + return ipInRange(ip, m.ipStart, m.ipEnd) + case m.exact != nil: + return m.exact.Equal(ip) + } + + return false +} + +// ipInRange checks if ip is within the inclusive range [start, end]. +func ipInRange(ip, start, end net.IP) bool { + // Normalize to 16-byte representation for comparison + ip16 := ip.To16() + start16 := start.To16() + end16 := end.To16() + + return bytesCompare(start16, ip16) <= 0 && bytesCompare(ip16, end16) <= 0 +} + +// bytesCompare compares two byte slices lexicographically. +// Returns -1 if a < b, 0 if a == b, 1 if a > b. +func bytesCompare(a, b []byte) int { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + return 0 +} + +// wildcardToCIDR converts a wildcard IP pattern to a CIDR net. +// Only supports IPv4 wildcards (e.g., "192.168.1.*" -> "192.168.1.0/24"). +func wildcardToCIDR(pattern string) (*net.IPNet, error) { + pattern = strings.TrimSpace(pattern) + parts := strings.Split(pattern, ".") + + if len(parts) != 4 { + return nil, fmt.Errorf("wildcard pattern currently only supports IPv4: %s", pattern) + } + + // Count how many fixed octets + fixedOctets := 0 + for i, part := range parts { + part = strings.TrimSpace(part) + if part == "*" { + // Fill remaining octets with 0 for the network address + for j := i; j < 4; j++ { + parts[j] = "0" + } + break + } + fixedOctets++ + } + + // All wildcards would be 0.0.0.0/0 + if fixedOctets == 0 { + return &net.IPNet{ + IP: net.IPv4(0, 0, 0, 0), + Mask: net.CIDRMask(0, 32), + }, nil + } + + // Construct CIDR network + cidrStr := fmt.Sprintf("%s/%d", strings.Join(parts, "."), fixedOctets*8) + _, cidrNet, err := net.ParseCIDR(cidrStr) + if err != nil { + return nil, fmt.Errorf("invalid wildcard pattern: %s (%w)", pattern, err) + } + + return cidrNet, nil +} + +// IsCIDRNotation checks if a filter string is a valid CIDR notation. +func IsCIDRNotation(filter string) bool { + _, _, err := net.ParseCIDR(strings.TrimSpace(filter)) + return err == nil +} + +// IPFilterToCIDRs converts an IP filter string to a list of CIDR networks. +// This is useful when you need to convert user-friendly IP filters +// to CIDR notation for display or storage. +// Supports CIDR, wildcard, single IP, and IP range (expanded). +func IPFilterToCIDRs(filter string) ([]*net.IPNet, error) { + matcher, err := NewIPMatcher(filter) + if err != nil { + return nil, err + } + + switch { + case matcher.cidr != nil: + return []*net.IPNet{matcher.cidr}, nil + case matcher.exact != nil: + // Single IP -> /32 or /128 + if matcher.exact.To4() != nil { + return []*net.IPNet{{ + IP: matcher.exact, + Mask: net.CIDRMask(32, 32), + }}, nil + } + return []*net.IPNet{{ + IP: matcher.exact, + Mask: net.CIDRMask(128, 128), + }}, nil + case matcher.ipStart != nil && matcher.ipEnd != nil: + // IP range - return as two /32 or /128 CIDRs + // Note: This is a simplified representation; the caller should + // use Match/MatchIP for exact range matching + cidrs := make([]*net.IPNet, 0) + if matcher.ipStart.To4() != nil { + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipStart, + Mask: net.CIDRMask(32, 32), + }) + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipEnd, + Mask: net.CIDRMask(32, 32), + }) + } else { + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipStart, + Mask: net.CIDRMask(128, 128), + }) + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipEnd, + Mask: net.CIDRMask(128, 128), + }) + } + return cidrs, nil + } + + return nil, fmt.Errorf("cannot convert filter to CIDR: %s", filter) +} diff --git a/pkg/util/ip_test.go b/pkg/util/ip_test.go new file mode 100644 index 00000000..df794d25 --- /dev/null +++ b/pkg/util/ip_test.go @@ -0,0 +1,432 @@ +package util + +import ( + "net" + "testing" +) + +func TestNewIPMatcher_CIDR(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 CIDR /24", "192.168.1.0/24", false}, + {"IPv4 CIDR /16", "10.0.0.0/16", false}, + {"IPv4 CIDR /32", "192.168.1.1/32", false}, + {"IPv4 CIDR /0", "0.0.0.0/0", false}, + {"IPv6 CIDR /64", "2001:db8::/64", false}, + {"IPv6 CIDR /128", "::1/128", false}, + {"Invalid CIDR - bad mask", "192.168.1.0/33", true}, + {"Invalid CIDR - bad IP", "999.999.999.999/24", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.cidr == nil { + t.Errorf("NewIPMatcher(%q) expected CIDR matcher, got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_Range(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 range", "192.168.1.1-192.168.1.255", false}, + {"IPv4 range with spaces", "192.168.1.1 - 192.168.1.255", false}, + {"IPv6 range", "::1-::10", false}, + {"Cross IP version", "192.168.1.1-::1", true}, + {"Invalid start", "abc-192.168.1.255", true}, + {"Invalid end", "192.168.1.1-abc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.ipStart == nil || matcher.ipEnd == nil { + t.Errorf("NewIPMatcher(%q) expected range matcher, got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_Wildcard(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"One wildcard", "192.168.1.*", false}, + {"Two wildcards", "192.168.*.*", false}, + {"Three wildcards", "192.*.*.*", false}, + {"All wildcards", "*.*.*.*", false}, + {"IPv6 not supported", "2001:db8::*", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.cidr == nil { + t.Errorf("NewIPMatcher(%q) expected CIDR matcher (from wildcard), got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_SingleIP(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 single", "192.168.1.1", false}, + {"IPv6 single", "::1", false}, + {"IPv6 full", "2001:db8::1", false}, + {"Invalid", "not-an-ip", true}, + {"Empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.exact == nil { + t.Errorf("NewIPMatcher(%q) expected exact IP matcher, got nil", tt.filter) + } + }) + } +} + +func TestIPMatcher_Match_CIDR(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.0/24") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.1.255", true}, + {"192.168.1.0", true}, + {"192.168.2.1", false}, + {"10.0.0.1", false}, + {"invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if tt.ip == "invalid" { + if err == nil { + t.Errorf("Match(%q) expected error", tt.ip) + } + return + } + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_IPv6CIDR(t *testing.T) { + matcher, err := NewIPMatcher("2001:db8::/32") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"2001:db8::1", true}, + {"2001:db8:1234::1", true}, + {"2001:db9::1", false}, + {"::1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_Range(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.10-192.168.1.20") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.10", true}, + {"192.168.1.15", true}, + {"192.168.1.20", true}, + {"192.168.1.9", false}, + {"192.168.1.21", false}, + {"192.168.2.1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_Wildcard(t *testing.T) { + matcher, err := NewIPMatcher("192.168.*.*") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.255.255", true}, + {"192.168.0.0", true}, + {"192.169.1.1", false}, + {"10.0.0.1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_SingleIP(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.1") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.1.2", false}, + {"192.168.1.0", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, _ := matcher.Match(tt.ip) + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_MatchIP_WithNetIP(t *testing.T) { + matcher, err := NewIPMatcher("10.0.0.0/8") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + // Test MatchIP with pre-parsed net.IP + ip := net.ParseIP("10.255.255.255") + if !matcher.MatchIP(ip) { + t.Errorf("MatchIP(%v) = false, want true", ip) + } + + ip = net.ParseIP("11.0.0.1") + if matcher.MatchIP(ip) { + t.Errorf("MatchIP(%v) = true, want false", ip) + } + + // Test nil IP + if matcher.MatchIP(nil) { + t.Errorf("MatchIP(nil) = true, want false") + } +} + +func TestWildcardToCIDR(t *testing.T) { + tests := []struct { + pattern string + cidr string + wantErr bool + }{ + {"192.168.1.*", "192.168.1.0/24", false}, + {"192.168.*.*", "192.168.0.0/16", false}, + {"192.*.*.*", "192.0.0.0/8", false}, + {"*.*.*.*", "0.0.0.0/0", false}, + {"10.0.*.*", "10.0.0.0/16", false}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + cidrNet, err := wildcardToCIDR(tt.pattern) + if tt.wantErr { + if err == nil { + t.Errorf("wildcardToCIDR(%q) expected error, got nil", tt.pattern) + } + return + } + if err != nil { + t.Errorf("wildcardToCIDR(%q) unexpected error: %v", tt.pattern, err) + return + } + if cidrNet.String() != tt.cidr { + t.Errorf("wildcardToCIDR(%q) = %q, want %q", tt.pattern, cidrNet.String(), tt.cidr) + } + }) + } +} + +func TestIsCIDRNotation(t *testing.T) { + tests := []struct { + filter string + isCIDR bool + }{ + {"192.168.1.0/24", true}, + {"2001:db8::/32", true}, + {"0.0.0.0/0", true}, + {"192.168.1.1", false}, + {"not-a-cidr", false}, + {"192.168.1.*", false}, + {"192.168.1.1-192.168.1.255", false}, + } + + for _, tt := range tests { + t.Run(tt.filter, func(t *testing.T) { + if got := IsCIDRNotation(tt.filter); got != tt.isCIDR { + t.Errorf("IsCIDRNotation(%q) = %v, want %v", tt.filter, got, tt.isCIDR) + } + }) + } +} + +func TestIPMatcher_BackwardCompatible(t *testing.T) { + // Ensures existing exact IP filtering still works + matcher, err := NewIPMatcher("123.45.67.89") + if err != nil { + t.Fatalf("Failed to create matcher for exact IP: %v", err) + } + + matched, err := matcher.Match("123.45.67.89") + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if !matched { + t.Error("Exact IP should match") + } + + matched, err = matcher.Match("123.45.67.90") + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched { + t.Error("Different exact IP should not match") + } +} + +func TestIPMatcher_IPv6Range(t *testing.T) { + matcher, err := NewIPMatcher("::1-::5") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"::1", true}, + {"::3", true}, + {"::5", true}, + {"::6", false}, + {"::0", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} From ffe5bc114874f01904397e001105c876359c1d8e Mon Sep 17 00:00:00 2001 From: Ricky Date: Tue, 11 Aug 2026 15:58:12 +0800 Subject: [PATCH 06/10] fix(oidc): refine provider integration --- inventory/migration.go | 17 ++++++++++--- inventory/setting.go | 19 ++++++++++++++- pkg/auth/oidc.go | 46 +++++------------------------------- pkg/cluster/routes/routes.go | 5 ++++ pkg/setting/provider.go | 6 +++++ service/oauth/oauth.go | 8 ++++--- service/oauth/oidc.go | 19 ++++++--------- 7 files changed, 61 insertions(+), 59 deletions(-) diff --git a/inventory/migration.go b/inventory/migration.go index f581dbc3..993e71ef 100644 --- a/inventory/migration.go +++ b/inventory/migration.go @@ -27,6 +27,14 @@ import ( // needMigration exams if required schema version is satisfied. func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion string) bool { c, _ := client.Setting.Query().Where(setting.NameEQ(DBVersionPrefix + requiredDbVersion)).Count(ctx) + if c == 0 { + return true + } + + c, _ = client.Setting.Query().Where( + setting.NameEQ(OIDCSigningPrivateKeySetting), + setting.ValueNEQ(""), + ).Count(ctx) return c == 0 } @@ -66,19 +74,22 @@ func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Co } // List existing settings into a map - existingSettings := make(map[string]struct{}) + existingSettings := make(map[string]*ent.Setting) settings, err := client.Setting.Query().All(ctx) if err != nil { l.Warning("Failed to query existing settings: %s", err) } for _, s := range settings { - existingSettings[s.Name] = struct{}{} + existingSettings[s.Name] = s } l.Info("Insert default settings...") for k, v := range DefaultSettings { - if _, ok := existingSettings[k]; ok { + if existing, ok := existingSettings[k]; ok { + if k == OIDCSigningPrivateKeySetting && existing.Value == "" { + client.Setting.UpdateOne(existing).SetValue(v).SaveX(ctx) + } l.Debug("Skip inserting setting %s, already exists.", k) continue } diff --git a/inventory/setting.go b/inventory/setting.go index 34ebe6f2..1b1494fa 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -3,8 +3,11 @@ package inventory import ( "context" "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" @@ -483,6 +486,20 @@ var mailTemplateContents = []MailTemplateContent{ }, } +const OIDCSigningPrivateKeySetting = "oidc_signing_private_key" + +func mustGenerateOIDCSigningPrivateKey() string { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(fmt.Errorf("failed to generate OIDC signing key: %w", err)) + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) +} + var DefaultSettings = map[string]string{ "siteURL": `http://localhost:5212`, "siteName": `Cloudreve`, @@ -524,7 +541,7 @@ var DefaultSettings = map[string]string{ "theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`, "max_parallel_transfer": `4`, "secret_key": util.RandStringRunesCrypto(256), - "oidc_signing_private_key": "", + OIDCSigningPrivateKeySetting: mustGenerateOIDCSigningPrivateKey(), "temp_path": "temp", "avatar_path": "avatar", "avatar_size": "4194304", diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go index 987ac250..8b935253 100644 --- a/pkg/auth/oidc.go +++ b/pkg/auth/oidc.go @@ -1,8 +1,6 @@ package auth import ( - "context" - "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" @@ -11,13 +9,9 @@ import ( "fmt" "math/big" - "github.com/cloudreve/Cloudreve/v4/ent" - "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/golang-jwt/jwt/v5" ) -const OIDCSigningPrivateKeySetting = "oidc_signing_private_key" - type OIDCIDTokenClaims struct { jwt.RegisteredClaims Nonce string `json:"nonce,omitempty"` @@ -42,55 +36,27 @@ type JWK struct { E string `json:"e"` } -func SignOIDCIDToken(ctx context.Context, settingClient inventory.SettingClient, claims *OIDCIDTokenClaims) (string, error) { - key, kid, err := loadOrCreateOIDCSigningKey(ctx, settingClient) +func SignOIDCIDToken(privateKeyRaw string, claims *OIDCIDTokenClaims) (string, error) { + key, err := parseRSAPrivateKey(privateKeyRaw) if err != nil { return "", err } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - token.Header["kid"] = kid + token.Header["kid"] = oidcSigningKeyID(&key.PublicKey) return token.SignedString(key) } -func OIDCJWKSet(ctx context.Context, settingClient inventory.SettingClient) (*JWKSet, error) { - key, kid, err := loadOrCreateOIDCSigningKey(ctx, settingClient) +func OIDCJWKSet(privateKeyRaw string) (*JWKSet, error) { + key, err := parseRSAPrivateKey(privateKeyRaw) if err != nil { return nil, err } + kid := oidcSigningKeyID(&key.PublicKey) return &JWKSet{Keys: []JWK{buildJWK(&key.PublicKey, kid)}}, nil } -func loadOrCreateOIDCSigningKey(ctx context.Context, settingClient inventory.SettingClient) (*rsa.PrivateKey, string, error) { - privateKeyRaw, err := settingClient.Get(ctx, OIDCSigningPrivateKeySetting) - if err != nil && !ent.IsNotFound(err) { - return nil, "", fmt.Errorf("failed to load OIDC signing key: %w", err) - } - if privateKeyRaw != "" { - key, err := parseRSAPrivateKey(privateKeyRaw) - if err != nil { - return nil, "", err - } - return key, oidcSigningKeyID(&key.PublicKey), nil - } - - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, "", fmt.Errorf("failed to generate OIDC signing key: %w", err) - } - - privateKeyRaw = string(pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - })) - if err := settingClient.Set(ctx, map[string]string{OIDCSigningPrivateKeySetting: privateKeyRaw}); err != nil { - return nil, "", fmt.Errorf("failed to persist OIDC signing key: %w", err) - } - - return key, oidcSigningKeyID(&key.PublicKey), nil -} - func parseRSAPrivateKey(privateKeyRaw string) (*rsa.PrivateKey, error) { block, _ := pem.Decode([]byte(privateKeyRaw)) if block == nil { diff --git a/pkg/cluster/routes/routes.go b/pkg/cluster/routes/routes.go index bd51d215..79b18f1b 100644 --- a/pkg/cluster/routes/routes.go +++ b/pkg/cluster/routes/routes.go @@ -43,6 +43,11 @@ func MasterPingUrl(base *url.URL) *url.URL { return base.ResolveReference(masterPing) } +func MasterOIDCEndpointUrl(base *url.URL, endpoint string) string { + route, _ := url.Parse(endpoint) + return base.ResolveReference(route).String() +} + func MasterSlaveCallbackUrl(base *url.URL, driver, id, secret string) *url.URL { apiBaseURI, _ := url.Parse(path.Join(constants.APIPrefix+"/callback", driver, id, secret)) return base.ResolveReference(apiBaseURI) diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index a95eaa24..5d427373 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -52,6 +52,8 @@ type ( SiteURL(ctx context.Context) *url.URL // SecretKey returns the secret key for general signature. SecretKey(ctx context.Context) string + // OIDCSigningPrivateKey returns the private key used to sign OIDC ID tokens. + OIDCSigningPrivateKey(ctx context.Context) string // ActivationEmailTemplate returns the email template for activation. ActivationEmailTemplate(ctx context.Context) []EmailTemplate // ResetEmailTemplate returns the email template for reset password. @@ -737,6 +739,10 @@ func (s *settingProvider) SecretKey(ctx context.Context) string { return s.getString(ctx, "secret_key", "") } +func (s *settingProvider) OIDCSigningPrivateKey(ctx context.Context) string { + return s.getString(ctx, "oidc_signing_private_key", "") +} + func (s *settingProvider) AllSiteURLs(ctx context.Context) []*url.URL { rawUrls := s.getStringList(ctx, "siteURL", []string{"http://localhost"}) if len(rawUrls) == 0 { diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index bdf71f9a..064799ad 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -132,7 +132,7 @@ type ( ClientSecret string `form:"client_secret" binding:"required"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` Code string `form:"code" binding:"required"` - RedirectURI string `form:"redirect_uri" binding:"required"` + RedirectURI string `form:"redirect_uri"` CodeVerifier string `form:"code_verifier"` } ) @@ -163,7 +163,9 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) if authCode.ClientID != s.ClientID { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil) } - if authCode.RedirectURI != s.RedirectURI { + if s.RedirectURI == "" { + dep.Logger().Warning("OAuth client %q did not provide redirect_uri in token request; it may become required in a future release", s.ClientID) + } else if authCode.RedirectURI != s.RedirectURI { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Redirect URI mismatch", nil) } @@ -275,7 +277,7 @@ func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID s } } - return auth.SignOIDCIDToken(c, dep.SettingClient(), claims) + return auth.SignOIDCIDToken(dep.SettingProvider().OIDCSigningPrivateKey(c), claims) } type ( diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go index 73e4a035..c37861ac 100644 --- a/service/oauth/oidc.go +++ b/service/oauth/oidc.go @@ -7,7 +7,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/auth" - "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" "github.com/gin-gonic/gin" ) @@ -19,10 +19,10 @@ func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { issuer := oidcIssuer(c) return &DiscoveryResponse{ Issuer: issuer.String(), - AuthorizationEndpoint: oidcEndpoint(issuer, "/session/authorize"), - TokenEndpoint: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/token"), - UserInfoEndpoint: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/userinfo"), - JWKSURI: oidcEndpoint(issuer, constants.APIPrefix+"/session/oauth/jwks"), + AuthorizationEndpoint: routes.MasterOIDCEndpointUrl(issuer, "/session/authorize"), + TokenEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/token"), + UserInfoEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/userinfo"), + JWKSURI: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/jwks"), ResponseTypesSupported: []string{ "code", }, @@ -60,18 +60,13 @@ func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { func (s *JWKService) Get(c *gin.Context) (*auth.JWKSet, error) { dep := dependency.FromContext(c) - return auth.OIDCJWKSet(c, dep.SettingClient()) + return auth.OIDCJWKSet(dep.SettingProvider().OIDCSigningPrivateKey(c)) } func oidcIssuer(c *gin.Context) *url.URL { dep := dependency.FromContext(c) - issuer := *dep.SettingProvider().SiteURL(setting.UseFirstSiteUrl(c)) + issuer := *dep.SettingProvider().SiteURL(c) issuer.RawQuery = "" issuer.Fragment = "" return &issuer } - -func oidcEndpoint(issuer *url.URL, endpoint string) string { - route, _ := url.Parse(endpoint) - return issuer.ResolveReference(route).String() -} From c49587ba7742fdf6e5e641243775b7f3940f67a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:49:55 +0000 Subject: [PATCH 07/10] chore(deps): bump github.com/gorilla/websocket from 1.5.0 to 1.5.3 Bumps [github.com/gorilla/websocket](https://github.com/gorilla/websocket) from 1.5.0 to 1.5.3. - [Release notes](https://github.com/gorilla/websocket/releases) - [Commits](https://github.com/gorilla/websocket/compare/v1.5.0...v1.5.3) --- updated-dependencies: - dependency-name: github.com/gorilla/websocket dependency-version: 1.5.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 12bd255d..0ec723b5 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.2.2 - github.com/gorilla/websocket v1.5.0 + github.com/gorilla/websocket v1.5.3 github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible github.com/jinzhu/gorm v1.9.11 github.com/jpillora/backoff v1.0.0 diff --git a/go.sum b/go.sum index bd2bf36e..6113a27e 100644 --- a/go.sum +++ b/go.sum @@ -505,8 +505,8 @@ github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8L github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= From ae2ceb564c3cb6c307621f90b31de93e7492d83f Mon Sep 17 00:00:00 2001 From: Darren Yu Date: Fri, 18 Sep 2026 16:50:16 +0800 Subject: [PATCH 08/10] fix: revalidate share and direct links after permission changes Revalidate existing share links against the owner's current group permission and load the group alongside the share owner. Return ErrSourceFileInvalid when sharing is disabled. Reject redirected direct links when the owner's group disables direct links. Preserve the existing Context-Hint TTL and redirect caching. Add regression tests for permission revocation, group reassignment, permission restoration, and inactive or banned owners. Co-authored-by: Codex --- inventory/share.go | 10 ++- inventory/share_test.go | 83 ++++++++++++++++++ pkg/filemanager/fs/dbfs/manage.go | 6 ++ .../fs/dbfs/manage_direct_link_test.go | 87 +++++++++++++++++++ 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 inventory/share_test.go create mode 100644 pkg/filemanager/fs/dbfs/manage_direct_link_test.go diff --git a/inventory/share.go b/inventory/share.go index 3f18b40c..f0005fe4 100644 --- a/inventory/share.go +++ b/inventory/share.go @@ -229,6 +229,13 @@ func IsValidShare(share *ent.Share) error { return ErrOwnerInactive } + // Check the owner's current share permission. + ownerGroup, err := owner.Edges.GroupOrErr() + if err != nil || ownerGroup.Permissions == nil || + !ownerGroup.Permissions.Enabled(int(types.GroupPermissionShare)) { + return ErrSourceFileInvalid + } + // Check source file status file, err := share.Edges.FileOrErr() if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID { @@ -418,7 +425,8 @@ func withShareEagerLoading(ctx context.Context, q *ent.ShareQuery) *ent.ShareQue } if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v { q.WithUser(func(q *ent.UserQuery) { - withUserEagerLoading(ctx, q) + userCtx := context.WithValue(ctx, LoadUserGroup{}, true) + withUserEagerLoading(userCtx, q) }) } diff --git a/inventory/share_test.go b/inventory/share_test.go new file mode 100644 index 00000000..ee3415ba --- /dev/null +++ b/inventory/share_test.go @@ -0,0 +1,83 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + entuser "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/stretchr/testify/require" +) + +func TestIsValidShareChecksOwnerAccess(t *testing.T) { + permissions := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionShare, true, permissions) + allowedGroup := &ent.Group{Permissions: permissions} + + tests := []struct { + name string + status entuser.Status + group *ent.Group + wantErr error + }{ + {name: "active owner with share permission", status: entuser.StatusActive, group: allowedGroup}, + {name: "active owner without share permission", status: entuser.StatusActive, group: &ent.Group{Permissions: &boolset.BooleanSet{}}, wantErr: ErrSourceFileInvalid}, + {name: "missing group", status: entuser.StatusActive, wantErr: ErrSourceFileInvalid}, + {name: "missing permissions", status: entuser.StatusActive, group: &ent.Group{}, wantErr: ErrSourceFileInvalid}, + {name: "manually banned owner", status: entuser.StatusManualBanned, group: allowedGroup, wantErr: ErrOwnerInactive}, + {name: "system banned owner", status: entuser.StatusSysBanned, group: allowedGroup, wantErr: ErrOwnerInactive}, + {name: "inactive owner", status: entuser.StatusInactive, group: allowedGroup, wantErr: ErrOwnerInactive}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := &ent.User{ID: 1, Status: tt.status} + owner.SetGroup(tt.group) + share := &ent.Share{} + share.SetUser(owner) + share.SetFile(&ent.File{OwnerID: owner.ID, FileChildren: 1}) + + require.ErrorIs(t, IsValidShare(share), tt.wantErr) + }) + } +} + +func TestShareClientRevalidatesOwnerGroup(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + permissions := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionShare, true, permissions) + group := client.Group.Create().SetName("sharing enabled").SetPermissions(permissions).SaveX(ctx) + restrictedGroup := client.Group.Create().SetName("sharing disabled").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + owner := client.User.Create().SetEmail("owner@example.com").SetNick("owner").SetGroup(group).SaveX(ctx) + root := client.File.Create().SetName(RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx) + file := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(root).SaveX(ctx) + share := client.Share.Create().SetUser(owner).SetFile(file).SaveX(ctx) + shareClient := NewShareClient(client, conf.SQLiteDB, nil) + + // Share-info and listing callers only request the owner and file edges. + ctx = context.WithValue(ctx, LoadShareUser{}, true) + ctx = context.WithValue(ctx, LoadShareFile{}, true) + checkShare := func(wantErr error) { + t.Helper() + current, err := shareClient.GetByID(ctx, share.ID) + require.NoError(t, err) + require.ErrorIs(t, IsValidShare(current), wantErr) + } + + checkShare(nil) + client.Group.UpdateOne(group).SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + checkShare(ErrSourceFileInvalid) + client.Group.UpdateOne(group).SetPermissions(permissions).SaveX(ctx) + checkShare(nil) + client.User.UpdateOne(owner).SetGroup(restrictedGroup).SaveX(ctx) + checkShare(ErrSourceFileInvalid) + client.User.UpdateOne(owner).SetGroup(group).SaveX(ctx) + checkShare(nil) +} diff --git a/pkg/filemanager/fs/dbfs/manage.go b/pkg/filemanager/fs/dbfs/manage.go index f0471df5..00981cc3 100644 --- a/pkg/filemanager/fs/dbfs/manage.go +++ b/pkg/filemanager/fs/dbfs/manage.go @@ -643,6 +643,12 @@ func (f *DBFS) GetFileFromDirectLink(ctx context.Context, dl *ent.DirectLink) (f return nil, fs.ErrDirectLinkInvalid.WithError(fmt.Errorf("file owner is not active")) } + // Check the owner's current direct-link permission. + group, err := owner.Edges.GroupOrErr() + if err != nil || group.Settings == nil || group.Settings.SourceBatchSize <= 0 { + return nil, fs.ErrDirectLinkInvalid + } + file := newFile(nil, fileModel) // Traverse to the root file diff --git a/pkg/filemanager/fs/dbfs/manage_direct_link_test.go b/pkg/filemanager/fs/dbfs/manage_direct_link_test.go new file mode 100644 index 00000000..b6f8d82c --- /dev/null +++ b/pkg/filemanager/fs/dbfs/manage_direct_link_test.go @@ -0,0 +1,87 @@ +package dbfs + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + entuser "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/require" +) + +type directLinkFileClient struct { + inventory.FileClient + root *ent.File +} + +func (c *directLinkFileClient) GetParentFile(_ context.Context, file *ent.File, _ bool) (*ent.File, error) { + if file.FileChildren == c.root.ID { + return c.root, nil + } + return nil, &ent.NotFoundError{} +} + +type directLinkSettingProvider struct { + setting.Provider +} + +func (directLinkSettingProvider) DBFS(context.Context) *setting.DBFS { + return &setting.DBFS{} +} + +func TestGetFileFromDirectLinkChecksOwnerAccess(t *testing.T) { + allowedGroup := &ent.Group{Settings: &types.GroupSetting{SourceBatchSize: 1}} + tests := []struct { + name string + status entuser.Status + group *ent.Group + wantErr bool + }{ + {name: "active owner with direct link permission", status: entuser.StatusActive, group: allowedGroup}, + {name: "active owner without direct link permission", status: entuser.StatusActive, group: &ent.Group{Settings: &types.GroupSetting{}}, wantErr: true}, + {name: "negative batch size", status: entuser.StatusActive, group: &ent.Group{Settings: &types.GroupSetting{SourceBatchSize: -1}}, wantErr: true}, + {name: "missing group", status: entuser.StatusActive, wantErr: true}, + {name: "missing group settings", status: entuser.StatusActive, group: &ent.Group{}, wantErr: true}, + {name: "manually banned owner", status: entuser.StatusManualBanned, group: allowedGroup, wantErr: true}, + {name: "system banned owner", status: entuser.StatusSysBanned, group: allowedGroup, wantErr: true}, + {name: "inactive owner", status: entuser.StatusInactive, group: allowedGroup, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := &ent.User{ID: 1, Status: tt.status} + owner.SetGroup(tt.group) + root := &ent.File{ID: 1, Name: inventory.RootFolderName, OwnerID: owner.ID} + file := &ent.File{ID: 2, Name: "shared.txt", OwnerID: owner.ID, FileChildren: root.ID} + file.SetOwner(owner) + link := &ent.DirectLink{} + link.SetFile(file) + dbfs := &DBFS{ + user: owner, + fileClient: &directLinkFileClient{root: root}, + settingClient: directLinkSettingProvider{}, + } + + got, err := dbfs.GetFileFromDirectLink(context.Background(), link) + if got != nil { + t.Cleanup(got.(*File).Parent.Recycle) + } + if tt.wantErr { + require.Nil(t, got) + var appErr serializer.AppError + require.ErrorAs(t, err, &appErr) + require.Equal(t, fs.ErrDirectLinkInvalid.Code, appErr.Code) + require.Equal(t, fs.ErrDirectLinkInvalid.Msg, appErr.Msg) + return + } + + require.NoError(t, err) + require.Same(t, file, got.(*File).Model) + }) + } +} From aff2ffa1562029d00f5031dc4eeb2dfaa827787e Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 15:54:06 +0200 Subject: [PATCH 09/10] chore: repair stale test suite, guard nil config, add fork roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs: ROADMAP.md — full analysis of upstream state, security posture, issue/PR triage, Pro feature map, desktop+android port plan - pkg/request: nil-guard config access in Request() — NewClientDeprecated leaves config nil, panicking GeneralClient and callers - pkg/request/request_test.go: WithSlaveMeta takes int, drop dead cache call - pkg/cache: update memo/redis tests to current constructor + Delete(prefix, keys...) - Remove driver_test.go, conf_test.go — they exercise v3 APIs deleted upstream - go.mod: mholt/archives v0.1.5 (rardecode 2.2.0 compat) Authored By: TDvorak --- ROADMAP.md | 160 ++++++++++++++++++++++++++++++++++++ go.mod | 19 +++-- go.sum | 20 +++++ pkg/cache/driver_test.go | 61 -------------- pkg/cache/memo_test.go | 19 +++-- pkg/cache/redis_test.go | 18 ++-- pkg/conf/conf_test.go | 94 --------------------- pkg/request/request.go | 3 +- pkg/request/request_test.go | 4 +- 9 files changed, 212 insertions(+), 186 deletions(-) create mode 100644 ROADMAP.md delete mode 100644 pkg/cache/driver_test.go delete mode 100644 pkg/conf/conf_test.go diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..13ccccd9 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,160 @@ +# Cloudreve Fork — Analysis & Roadmap + +Fork: `Dvorinka/cloudreve` · Upstream: `cloudreve/cloudreve` · Baseline: `4.19.1` (exact upstream HEAD, zero divergence) + +Original work by the Cloudreve authors (cloudreve.org). This fork continues it as a fully open-source project — every "Pro" feature reimplemented and free, desktop client on all platforms, native Android app. + +--- + +## 1. Analysis + +### 1.1 Repo state + +| Fact | Value | +|---|---| +| Fork vs upstream | `0 ahead / 0 behind` — clean mirror of `master` @ 4.19.1 | +| Backend | Go 1.26, Gin, ent ORM, `go build ./...` compiles (only `assets.zip` embed fails until frontend is built — expected) | +| Frontend | `assets/` submodule → `cloudreve/frontend` (React + Vite + TS, 69 deps), not yet initialized locally | +| Storage drivers present | local, S3, OSS, COS, Qiniu, Upyun, OneDrive, remote node (`service/explorer/slave.go`) | +| Auth present | password+2FA, passkey (`ent/schema/passkey.go`), generic OAuth client/grant, WebDAV accounts (`davaccount.go`) | + +### 1.2 Security posture + +16 published GHSAs on upstream — **all fixed at our baseline** (vuln ranges ≤ 4.17.0, we run 4.19.1). Spot-verified in tree, not just by version: + +- GHSA-f8xp (account takeover, insecure PRNG): `pkg/util/common.go` uses `crypto/rand` primary, `math/rand` only on crypto failure — fix present +- GHSA-vgj4 (OAuth scope bypass, missing client_id): `service/oauth/oauth.go:159` validates `authCode.ClientID != s.ClientID` — fix present +- Remaining highs (WebDAV path traversal, quota TOCTOU, OneDrive cred update via Admin.Read) — all ≤ 4.16.x ranges, patched + +Ongoing security work is in the roadmap (§5), not the backlog. + +### 1.3 Pro feature map (cloudreve.org/pricing — all 5 slides captured) + +The community repo contains **zero Pro code** — Pro ships as a separate licensed binary (`--license-key`, `proupgrade` DB migration). BUT the community **frontend already contains the full Pro UI skeleton** — every surface below renders a `ProChip` badge that opens `ProDialog.tsx`. Implementation = backend endpoints + remove the chips. + +| Pro slide | Features | Frontend hooks found | +|---|---|---| +| Sharing & collaboration | write/upload/delete via share link, paid share links, granular file permissions (users/groups/anonymous), anonymous upload via share, default shares for new users | `ShareSection.tsx` (group editor) | +| Storage policy mgmt | multiple policies per group, per-directory policies, load-balancer policy, file migration between policies | `SelectProvider.tsx`, `storage_policy_id` exists (single) on group | +| User & auth | multi-account switching, Logto SSO, OIDC SSO, QQ Connect, sign-up email filtering | `SSOSettings.tsx`, `SSO/` | +| Monetization (VAS) | storage plans, membership plans, redemption codes, credits | `VAS/` dir: `GroupProducts`, `StorageProducts`, `PaymentProviders`, `GiftCodes` — **full UI exists** | +| System extensions | activity/audit logs, site announcements, node selection, report abuse | `Events.tsx` (admin), `Home.tsx` | + +Backend gaps are concrete: `ShareProps` = `{share_view, show_read_me}` only; `group.storage_policy_id` is single; no order/product/credit entities at all. `NavigatorCapability_CommunityPlaceholder1–9` in `pkg/filemanager/fs/dbfs/navigator.go` are the reserved capability slots Pro fills. + +### 1.4 Org repo decisions + +| Repo | Verdict | Reason | +|---|---|---| +| `cloudreve` (this fork) | **Keep — base** | The core | +| `frontend` | **Fork — required** | UI is source-available and already holds Pro skeleton; we need our own fork to strip gates | +| `desktop` | **Fork — port** | Tauri+React; portable core (`cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync`) vs Windows-only glue (`cfapi`, `shellext`, `win32_notif`) | +| `docs` | **Fork later** | Needed when we ship; low priority | +| `docker-compose` | **Fork — small** | One file we extend (add pro-less compose + dev compose) | +| `taskqueue` | **Skip** | Dead since 2024; OneDrive offload queue superseded by in-app queue | +| `remote-server` | **Skip** | Dead PHP-era remote; v4 has native remote nodes | +| `ios-feedback` | **Skip** | Tracker for closed-source iOS app; we do Android instead | +| `theme-editor`, `frontend_v2`, `v2` | **Skip** | Archived/ancient | + +### 1.5 Upstream issues — 137 open, grouped + +| Group | Count | Examples | +|---|---|---| +| Bug — upload/download/sync | ~25 | #3574 trash_bin_collect OOM, #3454 PG FK on upload, #3118 WebDAV 500 on large files, #3005 WebDAV fragments, #2938 upload stuck | +| Bug — WebDAV | ~8 | #2878 mount path 404 after move, #3118, #3409 read-only groups | +| Bug — DB/migration | ~8 | #3452 MySQL HeatWave, #2880 unix socket, #2934 v3→v4 sqlite, #2981 PG18 | +| Enhancement — sharing/permissions | ~15 | #3555 preview-only shares, #3390 default share visibility, #3340 upload-only folders, #3033/#3032 multi-share ops | +| Enhancement — storage policy | ~8 | #3518 encrypt on relocation, #2961 enable/disable policy, #2262 site-wide migration | +| Enhancement — auth/SSO | ~7 | #3464 OIDC, #3056 auto-OIDC, #2179 TOTP manual key, #3479 IP whitelist | +| Enhancement — download/tasks | ~10 | #3491 advanced remote download, #3259 yt-dlp, #2427 download quota, #2270 cancel tasks | +| UX/polish | ~20 | #3288 breadcrumb restore, #3223 deselect on empty click, #3508 loop video, #3507 gallery names | +| Pro-related (becomes free here) | ~10 | #3572 ADFS OIDC, #3515 recurring billing, #3180 group-expiry downgrade, #3171 subaddress ban | +| Mobile/iOS requests | ~5 | #3003 captcha incompat, #2826 login fail, #2863 capacity 0KB — Android solves | +| Chinese-titled (mixed) | ~40 | folded into groups above after translation | +| wontfix by upstream (revisit) | ~10 | #2494 multi-group, #2883 slide captcha, #2842 file audit — **candidates for us** | + +### 1.6 Upstream PRs — verdicts + +| PR | Change | Verdict | +|---|---|---| +| #3524 | revalidate share/direct links after permission change (+185/-1, CLEAN) — fixes #3544, same class as GHSA-vx2m | **Merge** — security | +| #3549 | gorilla/websocket 1.5.0→1.5.3 | **Merge** — dep CVE hygiene | +| #2964 | nwaples/rardecode 2.1.0→2.2.0 | **Merge** — dep hygiene | +| #2851 | ulikunitz/xz 0.5.12→0.5.14 | **Merge** — dep hygiene | +| #3490 | IP range/CIDR filter in event logs (+668, 2 files) — fixes #3480 | **Merge** — small, self-contained | +| #3472 | OIDC provider support (+292/-6, 10 files) — fixes #3464, overlaps Pro SSO | **Merge after review** — strategic (free OIDC) | +| #2481 | multi-stage Dockerfile (+18/-2) | **Review** — conflicts w/ our own docker work likely; take if clean | +| #2507 | p2p QUIC (draft) | **Skip** — draft, scope creep | +| #2499 | multi-group users (draft, WIP) | **Watch** — big feature, matches #2494 wontfix; revisit when upstream matures it | +| #1802 | checksum on WOPI/text-edit update (draft, 2024) | **Skip** — stale draft | + +--- + +## 2. Immediate actions (this change set) + +- [x] Analysis + this roadmap +- [ ] Enable Issues on fork; label taxonomy (`upstream-####`, `group:*`, `pro-free`, `desktop`, `android`, `security`) +- [ ] Migrate upstream issues → fork (translate Chinese titles, tag `upstream-NNNN` + group labels, link originals) +- [ ] Cherry-pick merge: #3524, #3549, #2964, #2851, #3490 (and #3472 after compile review) +- [ ] `go build ./...` + `go test ./...` green + +## 3. Phase A — foundation hardening (first weeks) + +- Sync-fork automation: weekly `upstream → fork` merge workflow (GitHub Action) so security fixes keep landing +- Dependabot/renovate on the fork +- CI: build + test + vet + frontend build on PR (upstream azure-pipelines is theirs; ours = GitHub Actions) +- `docker-compose` dev stack (postgres + app + frontend hot reload) +- Remove `ProDialog`/`ProChip` gates in frontend fork; point `assets` submodule at our frontend fork + +## 4. Phase B — Pro features, free (the big one) + +Order = user-visible value first; each ships with backend + UI + tests. + +1. **Share collaboration** — write/upload/delete via share link, anonymous upload, share ACL (users/groups), preview-only mode (fixes #3555, #3390, #3340, #3517, #3578; uses `NavigatorCapability` placeholder slots + `ShareProps` extension + `share` entity fields) +2. **Storage policy advanced** — multiple policies per group (group→policies join table), per-directory binding, load-balancer policy, file migration between policies (fixes #3518, #2961, #2262) +3. **SSO** — generic OIDC provider (PR #3472 base), Logto connector, multi-account switching, sign-up email filtering (fixes #3464, #3056, #3505) +4. **VAS/monetization-free** — credits + redemption codes as *free* features (gift codes for admin use), storage/membership plan definitions; skip payment processor integration initially — YAGNI until a real user asks (fixes #3231) +5. **System extensions** — activity/audit log surfaced in admin, site announcements, report-abuse queue (fixes #3480, #3479 IP whitelist) + +## 5. Phase C — security + quality + +- Own security review on top of upstream fixes: session/token entropy audit, SSRF guard re-test (NAT64 class), rate limiting on auth endpoints +- Fix upstream bug backlog by impact: #3574 OOM (trash_bin_collect streaming), #3118/#3005 WebDAV large-file, #3454 PG FK, #3375 SMTP auth discovery +- `desloppify` + `security-reviewer` passes; scorecard appended to README + +## 6. Phase D — desktop, all platforms + +Goal: Windows + macOS + Linux from one Tauri codebase (`cloudreve/desktop` fork). + +| Layer | Windows (exists) | macOS | Linux | +|---|---|---|---| +| Placeholders/hydration | cfapi (keep) | File Provider ext (Swift bridge) | FUSE (`fuser`) or plain sync folder | +| Shell integration | shellext (keep) | Finder sync extension | Nautilus/Dolphin plugin (later) | +| Notifications | win32_notif → replace | `tauri-plugin-notification` (all platforms) | same | +| Sync core | shared: `cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync` | same | same | + +- Port order: (1) strip `win32_notif`→tauri notifications (all platforms benefit), (2) abstract `drive/` behind a `HydrationProvider` trait (cfapi impl on Windows, stub→FUSE on Linux, FileProvider on macOS), (3) CI matrix build all 3, (4) MSIX→also ship .dmg/.AppImage/.deb. +- Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine). + +## 7. Phase E — Android app (native, no iOS) + +New repo `Dvorinka/cloudreve-android`. Kotlin + Jetpack Compose, Material 3. + +- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec +- **Core features**: browse/download/upload files, share links, camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager +- **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events +- **Auth**: webview OAuth flow → token; later passkey if backend exposes +- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures +- Non-goals: iOS, tablet-first layouts (works, not optimized) + +## 8. Governance + +- License/credit: keep `LICENSE` (GPL-3.0), add `AUTHORS`/credit line to original Cloudreve authors in README — attribution without endorsement +- Release cadence: tag `fork-4.19.x` line first (cherry-picks only), then `5.0.0-fork` once Phase B lands +- Every merge: build + test + lint green (pre-push gate, non-negotiable) + +--- + +## Issue migration format + +Each fork issue: title translated to English when needed, body = `Upstream: cloudreve/cloudreve#NNNN` + short restatement + group labels. Epics get `epic` label and link children. Upstream `wontfix` items we want get `revisit` label. diff --git a/go.mod b/go.mod index 27c2ff2f..8cbde9dd 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/semver/v3 v3.3.1 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 github.com/aws/aws-sdk-go v1.34.0 - github.com/bodgit/sevenzip v1.6.0 + github.com/bodgit/sevenzip v1.6.1 github.com/cloudflare/cfssl v1.6.1 github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 github.com/dsoprea/go-exif/v3 v3.0.1 @@ -43,7 +43,7 @@ require ( github.com/ks3sdklib/aws-sdk-go v1.6.2 github.com/lib/pq v1.10.9 github.com/meilisearch/meilisearch-go v0.36.0 - github.com/mholt/archives v0.1.3 + github.com/mholt/archives v0.1.5 github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2 github.com/pquerna/otp v1.2.0 github.com/qiniu/go-sdk/v7 v7.19.0 @@ -69,9 +69,9 @@ require ( require ( ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect cloud.google.com/go v0.81.0 // indirect - github.com/STARRY-S/zip v0.2.1 // indirect + github.com/STARRY-S/zip v0.2.3 // indirect github.com/agext/levenshtein v1.2.1 // indirect - github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/windows v1.0.1 // indirect @@ -115,14 +115,14 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jmespath/go-jmespath v0.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect - github.com/minio/minlz v1.0.0 // indirect + github.com/minio/minlz v1.0.1 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -131,16 +131,17 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sorairolake/lzip-go v0.3.5 // indirect + github.com/sorairolake/lzip-go v0.3.8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/ulikunitz/xz v0.5.14 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zclconf/go-cty v1.8.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect diff --git a/go.sum b/go.sum index 981a25b4..f1355df4 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= @@ -102,6 +104,8 @@ github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Sn github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= @@ -142,6 +146,8 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A= github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= @@ -621,6 +627,8 @@ github.com/kisom/goutils v1.4.3/go.mod h1:Lp5qrquG7yhYnWzZCI/68Pa/GpFynw//od6EkG github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -694,6 +702,8 @@ github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458= github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= @@ -701,6 +711,8 @@ github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZz github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ= github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -786,6 +798,8 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -893,12 +907,16 @@ github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw= github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= @@ -967,6 +985,8 @@ github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oW github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg= github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/upyun/go-sdk v2.1.0+incompatible h1:OdjXghQ/TVetWV16Pz3C1/SUpjhGBVPr+cLiqZLLyq0= github.com/upyun/go-sdk v2.1.0+incompatible/go.mod h1:eu3F5Uz4b9ZE5bE5QsCL6mgSNWRwfj0zpJ9J626HEqs= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= diff --git a/pkg/cache/driver_test.go b/pkg/cache/driver_test.go deleted file mode 100644 index d30a67f2..00000000 --- a/pkg/cache/driver_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package cache - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestSet(t *testing.T) { - asserts := assert.New(t) - - asserts.NoError(Set("123", "321", -1)) -} - -func TestGet(t *testing.T) { - asserts := assert.New(t) - asserts.NoError(Set("123", "321", -1)) - - value, ok := Get("123") - asserts.True(ok) - asserts.Equal("321", value) - - value, ok = Get("not_exist") - asserts.False(ok) -} - -func TestDeletes(t *testing.T) { - asserts := assert.New(t) - asserts.NoError(Set("123", "321", -1)) - err := Deletes([]string{"123"}, "") - asserts.NoError(err) - _, exist := Get("123") - asserts.False(exist) -} - -func TestGetSettings(t *testing.T) { - asserts := assert.New(t) - asserts.NoError(Set("test_1", "1", -1)) - - values, missed := GetSettings([]string{"1", "2"}, "test_") - asserts.Equal(map[string]string{"1": "1"}, values) - asserts.Equal([]string{"2"}, missed) -} - -func TestSetSettings(t *testing.T) { - asserts := assert.New(t) - - err := SetSettings(map[string]string{"3": "3", "4": "4"}, "test_") - asserts.NoError(err) - value1, _ := Get("test_3") - value2, _ := Get("test_4") - asserts.Equal("3", value1) - asserts.Equal("4", value2) -} - -func TestInit(t *testing.T) { - asserts := assert.New(t) - - asserts.NotPanics(func() { - Init() - }) -} diff --git a/pkg/cache/memo_test.go b/pkg/cache/memo_test.go index 2efbb703..1f812018 100644 --- a/pkg/cache/memo_test.go +++ b/pkg/cache/memo_test.go @@ -1,6 +1,7 @@ package cache import ( + "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/stretchr/testify/assert" "testing" "time" @@ -9,7 +10,7 @@ import ( func TestNewMemoStore(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) asserts.NotNil(store) asserts.NotNil(store.Store) } @@ -17,7 +18,7 @@ func TestNewMemoStore(t *testing.T) { func TestMemoStore_Set(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Set("KEY", "vAL", -1) asserts.NoError(err) @@ -28,7 +29,7 @@ func TestMemoStore_Set(t *testing.T) { func TestMemoStore_Get(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) // 正常情况 { @@ -72,7 +73,7 @@ func TestMemoStore_Get(t *testing.T) { func TestMemoStore_Gets(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Set("1", "1,val", -1) err = store.Set("2", "2,val", -1) @@ -97,7 +98,7 @@ func TestMemoStore_Gets(t *testing.T) { func TestMemoStore_Sets(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Sets(map[string]interface{}{ "1": "1.val", @@ -119,7 +120,7 @@ func TestMemoStore_Sets(t *testing.T) { func TestMemoStore_Delete(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Sets(map[string]interface{}{ "1": "1.val", @@ -129,7 +130,7 @@ func TestMemoStore_Delete(t *testing.T) { }, "test_") asserts.NoError(err) - err = store.Delete([]string{"1", "2"}, "test_") + err = store.Delete("test_", "1", "2") asserts.NoError(err) values, miss := store.Gets([]string{"1", "2", "3", "4"}, "test_") asserts.Equal([]string{"1", "2"}, miss) @@ -138,10 +139,10 @@ func TestMemoStore_Delete(t *testing.T) { func TestMemoStore_GarbageCollect(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) store.Set("test", 1, 1) time.Sleep(time.Duration(2000) * time.Millisecond) - store.GarbageCollect() + store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug)) _, ok := store.Get("test") asserts.False(ok) } diff --git a/pkg/cache/redis_test.go b/pkg/cache/redis_test.go index 609eba16..91342af6 100644 --- a/pkg/cache/redis_test.go +++ b/pkg/cache/redis_test.go @@ -3,6 +3,8 @@ package cache import ( "errors" "fmt" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/gomodule/redigo/redis" "github.com/rafaeljusto/redigomock" "github.com/stretchr/testify/assert" @@ -13,16 +15,16 @@ import ( func TestNewRedisStore(t *testing.T) { asserts := assert.New(t) - store := NewRedisStore(10, "tcp", "", "", "0") + store := NewRedisStore(logging.NewConsoleLogger(logging.LevelDebug), 10, &conf.Redis{Network: "tcp", Server: "", Password: "", DB: "0"}) asserts.NotNil(store) - conn, err := store.pool.Dial() - asserts.Nil(conn) - asserts.Error(err) + asserts.Panics(func() { + store.pool.Dial() + }) testConn := redigomock.NewConn() cmd := testConn.Command("PING").Expect("PONG") - err = store.pool.TestOnBorrow(testConn, time.Now()) + err := store.pool.TestOnBorrow(testConn, time.Now()) if testConn.Stats(cmd) != 1 { fmt.Println("Command was not used") return @@ -291,7 +293,7 @@ func TestRedisStore_Delete(t *testing.T) { // 正常 { cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectSlice("OK") - err := store.Delete([]string{"1", "2", "3", "4"}, "test_") + err := store.Delete("test_", "1", "2", "3", "4") asserts.NoError(err) if conn.Stats(cmd) != 1 { fmt.Println("Command was not used") @@ -303,7 +305,7 @@ func TestRedisStore_Delete(t *testing.T) { { conn.Clear() cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectError(errors.New("error")) - err := store.Delete([]string{"1", "2", "3", "4"}, "test_") + err := store.Delete("test_", "1", "2", "3", "4") asserts.Error(err) if conn.Stats(cmd) != 1 { fmt.Println("Command was not used") @@ -318,7 +320,7 @@ func TestRedisStore_Delete(t *testing.T) { Dial: func() (redis.Conn, error) { return nil, errors.New("error") }, MaxIdle: 10, } - err := store.Delete([]string{"1", "2", "3", "4"}, "test_") + err := store.Delete("test_", "1", "2", "3", "4") asserts.Error(err) } } diff --git a/pkg/conf/conf_test.go b/pkg/conf/conf_test.go deleted file mode 100644 index e9bc646d..00000000 --- a/pkg/conf/conf_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package conf - -import ( - "github.com/cloudreve/Cloudreve/v4/pkg/util" - "github.com/stretchr/testify/assert" - "io/ioutil" - "os" - "testing" -) - -// 测试Init日志路径错误 -func TestInitPanic(t *testing.T) { - asserts := assert.New(t) - - // 日志路径不存在时 - asserts.NotPanics(func() { - Init("not/exist/path") - }) - - asserts.True(util.Exists("conf.ini")) - -} - -// TestInitDelimiterNotFound 日志路径存在但 Key 格式错误时 -func TestInitDelimiterNotFound(t *testing.T) { - asserts := assert.New(t) - testCase := `[Database] -Type = mysql -User = root -Password233root -Host = 127.0.0.1:3306 -Name = v3 -TablePrefix = v3_` - err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644) - defer func() { err = os.Remove("testConf.ini") }() - if err != nil { - panic(err) - } - asserts.Panics(func() { - Init("testConf.ini") - }) -} - -// TestInitNoPanic 日志路径存在且合法时 -func TestInitNoPanic(t *testing.T) { - asserts := assert.New(t) - testCase := ` -[System] -Listen = 3000 -HashIDSalt = 1 - -[Database] -Type = mysql -User = root -Password = root -Host = 127.0.0.1:3306 -Name = v3 -TablePrefix = v3_` - err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644) - defer func() { err = os.Remove("testConf.ini") }() - if err != nil { - panic(err) - } - asserts.NotPanics(func() { - Init("testConf.ini") - }) -} - -func TestMapSection(t *testing.T) { - asserts := assert.New(t) - - //正常情况 - testCase := ` -[System] -Listen = 3000 -HashIDSalt = 1 - -[Database] -Type = mysql -User = root -Password:root -Host = 127.0.0.1:3306 -Name = v3 -TablePrefix = v3_` - err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644) - defer func() { err = os.Remove("testConf.ini") }() - if err != nil { - panic(err) - } - Init("testConf.ini") - err = mapSection("Database", DatabaseConfig) - asserts.NoError(err) - -} diff --git a/pkg/request/request.go b/pkg/request/request.go index afd8b063..d987814b 100644 --- a/pkg/request/request.go +++ b/pkg/request/request.go @@ -156,8 +156,7 @@ func (c *HTTPClient) Request(method, target string, body io.Reader, opts ...Opti req.Header.Add(CorrelationHeader, logging.CorrelationID(options.ctx).String()) } - mode := c.config.System().Mode - if options.masterMeta && mode == conf.MasterMode { + if c.config != nil && options.masterMeta && c.config.System().Mode == conf.MasterMode { req.Header.Add(SiteURLHeader, options.siteURL) req.Header.Add(SiteIDHeader, options.siteID) req.Header.Add(SiteVersionHeader, constants.BackendVersion) diff --git a/pkg/request/request_test.go b/pkg/request/request_test.go index 4e062df9..a42f1f37 100644 --- a/pkg/request/request_test.go +++ b/pkg/request/request_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "github.com/cloudreve/Cloudreve/v4/pkg/auth" - "github.com/cloudreve/Cloudreve/v4/pkg/cache" "github.com/stretchr/testify/assert" testMock "github.com/stretchr/testify/mock" "io" @@ -54,7 +53,7 @@ func TestWithContext(t *testing.T) { func TestHTTPClient_Request(t *testing.T) { asserts := assert.New(t) - client := NewClientDeprecated(WithSlaveMeta("test")) + client := NewClientDeprecated(WithSlaveMeta(1)) // 正常 { @@ -230,7 +229,6 @@ func TestNopRSCloser_SetFirstFakeChunk(t *testing.T) { func TestBlackHole(t *testing.T) { a := assert.New(t) - cache.Set("setting_reset_after_upload_failed", "true", 0) a.NotPanics(func() { BlackHole(strings.NewReader("TestBlackHole")) }) From ed98eba10ab27e0753e893e221c8dad04d550718 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 15:55:39 +0200 Subject: [PATCH 10/10] chore: ignore playwright snapshot artifacts Authored By: TDvorak --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3588d300..f5933be8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ data/ tmp/ .devcontainer/ cloudreve -.DS_Store \ No newline at end of file +.DS_Store +# Playwright MCP snapshots +.playwright-mcp/