diff --git a/.gitignore b/.gitignore index 9ebd0f56..76c41faf 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ cloudreve # Monorepo staging dir for asset packaging (transient) /assets/ pkg/util/test/ +.desloppify/ +scorecard.png diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 193c51a1..833f2ad1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -102,6 +102,25 @@ dockers: - .build/entrypoint.sh image_templates: - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-arm64" + - dockerfile: Dockerfile.slim + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--provenance=false" + goos: linux + goarch: amd64 + goamd64: v1 + image_templates: + - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-amd64" + - dockerfile: Dockerfile.slim + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + - "--provenance=false" + goos: linux + goarch: arm64 + image_templates: + - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-arm64" docker_manifests: - name_template: "ghcr.io/dvorinka/cloudreve:latest" @@ -116,3 +135,11 @@ docker_manifests: image_templates: - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-amd64" - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-arm64" + - name_template: "ghcr.io/dvorinka/cloudreve:slim" + image_templates: + - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-amd64" + - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-arm64" + - name_template: "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim" + image_templates: + - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-amd64" + - "ghcr.io/dvorinka/cloudreve:{{ .Tag }}-slim-arm64" diff --git a/Dockerfile.slim b/Dockerfile.slim new file mode 100644 index 00000000..bb41271a --- /dev/null +++ b/Dockerfile.slim @@ -0,0 +1,35 @@ +# Slim variant: no LibreOffice, fonts, aria2, or media/thumbnail tooling. +# 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 + +RUN apk update \ + && apk add --no-cache tzdata \ + && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && echo "Asia/Shanghai" > /etc/timezone + +COPY --from=builder /src/dist/*/cloudreve ./cloudreve + +RUN chmod +x ./cloudreve + +EXPOSE 5212 + +VOLUME ["/cloudreve/data"] + +ENTRYPOINT ["./cloudreve"] diff --git a/application/application.go b/application/application.go index 0380872a..52f867e8 100644 --- a/application/application.go +++ b/application/application.go @@ -188,7 +188,7 @@ func (s *server) Close() { } ctx := context.Background() - if conf.SystemConfig.GracePeriod != 0 { + if s.config.System().GracePeriod != 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(s.config.System().GracePeriod)*time.Second) defer cancel() @@ -231,8 +231,8 @@ func (s *server) runUnix(server *http.Server) error { defer listener.Close() defer os.Remove(s.config.Unix().Listen) - if conf.UnixConfig.Perm > 0 { - err = os.Chmod(conf.UnixConfig.Listen, os.FileMode(s.config.Unix().Perm)) + if s.config.Unix().Perm > 0 { + err = os.Chmod(s.config.Unix().Listen, os.FileMode(s.config.Unix().Perm)) if err != nil { s.logger.Warning( "Failed to set permission to %q for socket file %q: %s", diff --git a/application/constants/constants.go b/application/constants/constants.go index bf97df7f..2c737aa7 100644 --- a/application/constants/constants.go +++ b/application/constants/constants.go @@ -3,7 +3,7 @@ package constants // These values will be injected at build time, DO NOT EDIT. // BackendVersion 当前后端版本号 -var BackendVersion = "4.14.0" +var BackendVersion = "4.19.1" // IsPro 是否为Pro版本 var IsPro = "false" diff --git a/application/dependency/dependency.go b/application/dependency/dependency.go index d9963780..a4bab971 100644 --- a/application/dependency/dependency.go +++ b/application/dependency/dependency.go @@ -72,9 +72,9 @@ type Dep interface { SettingClient() inventory.SettingClient // SettingProvider Get a singleton setting.Provider instance for access setting store in strong type. SettingProvider() setting.Provider - // UserClient Creates a new inventory.UserClient instance for access DB user store. + // UserClient Get a singleton inventory.UserClient instance for access DB user store. UserClient() inventory.UserClient - // GroupClient Creates a new inventory.GroupClient instance for access DB group store. + // GroupClient Get a singleton inventory.GroupClient instance for access DB group store. GroupClient() inventory.GroupClient // EmailClient Get a singleton email.Driver instance for sending emails. EmailClient(ctx context.Context) email.Driver @@ -82,15 +82,15 @@ type Dep interface { GeneralAuth() auth.Auth // Shutdown the dependencies gracefully. Shutdown(ctx context.Context) error - // FileClient Creates a new inventory.FileClient instance for access DB file store. + // FileClient Get a singleton inventory.FileClient instance for access DB file store. FileClient() inventory.FileClient - // NodeClient Creates a new inventory.NodeClient instance for access DB node store. + // NodeClient Get a singleton inventory.NodeClient instance for access DB node store. NodeClient() inventory.NodeClient - // DavAccountClient Creates a new inventory.DavAccountClient instance for access DB dav account store. + // DavAccountClient Get a singleton inventory.DavAccountClient instance for access DB dav account store. DavAccountClient() inventory.DavAccountClient - // DirectLinkClient Creates a new inventory.DirectLinkClient instance for access DB direct link store. + // DirectLinkClient Get a singleton inventory.DirectLinkClient instance for access DB direct link store. DirectLinkClient() inventory.DirectLinkClient - // OAuthClientClient Creates a new inventory.OAuthClientClient instance for access DB OAuth client store. + // OAuthClientClient Get a singleton inventory.OAuthClientClient instance for access DB OAuth client store. OAuthClientClient() inventory.OAuthClientClient // HashIDEncoder Get a singleton hashid.Encoder instance for encoding/decoding hashids. HashIDEncoder() hashid.Encoder @@ -98,13 +98,13 @@ type Dep interface { TokenAuth() auth.TokenAuth // LockSystem Get a singleton lock.LockSystem instance for file lock management. LockSystem() lock.LockSystem - // ShareClient Creates a new inventory.ShareClient instance for access DB share store. + // StoragePolicyClient Get a singleton inventory.StoragePolicyClient instance for access DB storage policy store. StoragePolicyClient() inventory.StoragePolicyClient // RequestClient Creates a new request.Client instance for HTTP requests. RequestClient(opts ...request.Option) request.Client - // ShareClient Creates a new inventory.ShareClient instance for access DB share store. + // ShareClient Get a singleton inventory.ShareClient instance for access DB share store. ShareClient() inventory.ShareClient - // TaskClient Creates a new inventory.TaskClient instance for access DB task store. + // TaskClient Get a singleton inventory.TaskClient instance for access DB task store. TaskClient() inventory.TaskClient // ForkWithLogger create a shallow copy of dependency with a new correlated logger, used as per-request dep. ForkWithLogger(ctx context.Context, l logging.Logger) context.Context @@ -450,7 +450,8 @@ func (d *dependency) FsEventClient() inventory.FsEventClient { if d.fsEventClient != nil { return d.fsEventClient } - return inventory.NewFsEventClient(d.DBClient(), d.ConfigProvider().Database().Type) + d.fsEventClient = inventory.NewFsEventClient(d.DBClient(), d.ConfigProvider().Database().Type) + return d.fsEventClient } func (d *dependency) SettingClient() inventory.SettingClient { @@ -499,7 +500,8 @@ func (d *dependency) UserClient() inventory.UserClient { return d.userClient } - return inventory.NewUserClient(d.DBClient()) + d.userClient = inventory.NewUserClient(d.DBClient()) + return d.userClient } func (d *dependency) GroupClient() inventory.GroupClient { @@ -507,7 +509,8 @@ func (d *dependency) GroupClient() inventory.GroupClient { return d.groupClient } - return inventory.NewGroupClient(d.DBClient(), d.ConfigProvider().Database().Type, d.KV()) + d.groupClient = inventory.NewGroupClient(d.DBClient(), d.ConfigProvider().Database().Type, d.KV()) + return d.groupClient } func (d *dependency) NodeClient() inventory.NodeClient { @@ -515,7 +518,8 @@ func (d *dependency) NodeClient() inventory.NodeClient { return d.nodeClient } - return inventory.NewNodeClient(d.DBClient()) + d.nodeClient = inventory.NewNodeClient(d.DBClient()) + return d.nodeClient } func (d *dependency) NodePool(ctx context.Context) (cluster.NodePool, error) { @@ -557,7 +561,8 @@ func (d *dependency) OAuthClientClient() inventory.OAuthClientClient { return d.oAuthClient } - return inventory.NewOAuthClientClient(d.DBClient(), d.ConfigProvider().Database().Type) + d.oAuthClient = inventory.NewOAuthClientClient(d.DBClient(), d.ConfigProvider().Database().Type) + return d.oAuthClient } func (d *dependency) MimeDetector(ctx context.Context) mime.MimeDetector { @@ -801,7 +806,8 @@ func (d *dependency) FileClient() inventory.FileClient { return d.fileClient } - return inventory.NewFileClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + d.fileClient = inventory.NewFileClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + return d.fileClient } func (d *dependency) ShareClient() inventory.ShareClient { @@ -809,7 +815,8 @@ func (d *dependency) ShareClient() inventory.ShareClient { return d.shareClient } - return inventory.NewShareClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + d.shareClient = inventory.NewShareClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + return d.shareClient } func (d *dependency) TaskClient() inventory.TaskClient { @@ -817,7 +824,8 @@ func (d *dependency) TaskClient() inventory.TaskClient { return d.taskClient } - return inventory.NewTaskClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + d.taskClient = inventory.NewTaskClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + return d.taskClient } func (d *dependency) DavAccountClient() inventory.DavAccountClient { @@ -825,7 +833,8 @@ func (d *dependency) DavAccountClient() inventory.DavAccountClient { return d.davAccountClient } - return inventory.NewDavAccountClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + d.davAccountClient = inventory.NewDavAccountClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + return d.davAccountClient } func (d *dependency) DirectLinkClient() inventory.DirectLinkClient { @@ -833,7 +842,8 @@ func (d *dependency) DirectLinkClient() inventory.DirectLinkClient { return d.directLinkClient } - return inventory.NewDirectLinkClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + d.directLinkClient = inventory.NewDirectLinkClient(d.DBClient(), d.ConfigProvider().Database().Type, d.HashIDEncoder()) + return d.directLinkClient } func (d *dependency) HashIDEncoder() hashid.Encoder { @@ -887,7 +897,8 @@ func (d *dependency) StoragePolicyClient() inventory.StoragePolicyClient { return d.storagePolicyClient } - return inventory.NewStoragePolicyClient(d.DBClient(), d.KV()) + d.storagePolicyClient = inventory.NewStoragePolicyClient(d.DBClient(), d.KV()) + return d.storagePolicyClient } func (d *dependency) ThumbPipeline() thumb.Generator { diff --git a/application/migrator/migrator.go b/application/migrator/migrator.go index 5bb53520..3e9806f9 100644 --- a/application/migrator/migrator.go +++ b/application/migrator/migrator.go @@ -181,6 +181,7 @@ func (m *Migrator) Migrate() error { // Continue from the current step if m.state.Step <= StepSchema { m.l.Info("Creating basic v4 table schema...") + inventory.RepairHeatWaveNotSecondary(m.l, m.v4client, context.Background()) if err := m.v4client.Schema.Create(context.Background()); err != nil { return fmt.Errorf("failed creating schema resources: %w", err) } diff --git a/application/migrator/share.go b/application/migrator/share.go index 8e5900d3..8fba330e 100644 --- a/application/migrator/share.go +++ b/application/migrator/share.go @@ -36,7 +36,6 @@ func (m *Migrator) migrateShare() error { tx, err := m.v4client.Tx(ctx) if err != nil { - _ = tx.Rollback() return fmt.Errorf("failed to start transaction: %w", err) } diff --git a/ent/internal/schema.go b/ent/internal/schema.go index d3aec62f..d71dd19b 100644 --- a/ent/internal/schema.go +++ b/ent/internal/schema.go @@ -6,4 +6,4 @@ // Package internal holds a loadable version of the latest schema. package internal -const Schema = "{\"Schema\":\"github.com/cloudreve/Cloudreve/v4/ent/schema\",\"Package\":\"github.com/cloudreve/Cloudreve/v4/ent\",\"Schemas\":[{\"name\":\"DavAccount\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"owner\",\"type\":\"User\",\"field\":\"owner_id\",\"ref_name\":\"dav_accounts\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"uri\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"options\",\"type\":{\"Type\":5,\"Ident\":\"*boolset.BooleanSet\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"PkgName\":\"boolset\",\"Nillable\":true,\"RType\":{\"Name\":\"BooleanSet\",\"Ident\":\"boolset.BooleanSet\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"Methods\":{\"Enabled\":{\"In\":[{\"Name\":\"int\",\"Ident\":\"int\",\"Kind\":2,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.DavAccountProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"DavAccountProps\",\"Ident\":\"types.DavAccountProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"owner_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"owner_id\",\"password\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"DirectLink\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"file\",\"type\":\"File\",\"field\":\"file_id\",\"ref_name\":\"direct_links\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"downloads\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"speed\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Entity\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"file\",\"type\":\"File\",\"ref_name\":\"entities\",\"inverse\":true},{\"name\":\"user\",\"type\":\"User\",\"field\":\"created_by\",\"ref_name\":\"entities\",\"unique\":true,\"inverse\":true},{\"name\":\"storage_policy\",\"type\":\"StoragePolicy\",\"field\":\"storage_policy_entities\",\"ref_name\":\"entities\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"type\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"source\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"size\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"reference_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage_policy_entities\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"created_by\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"upload_session_id\",\"type\":{\"Type\":4,\"Ident\":\"uuid.UUID\",\"PkgPath\":\"github.com/gofrs/uuid\",\"PkgName\":\"uuid\",\"Nillable\":false,\"RType\":{\"Name\":\"UUID\",\"Ident\":\"uuid.UUID\",\"Kind\":17,\"PkgPath\":\"github.com/gofrs/uuid\",\"Methods\":{\"Bytes\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"Format\":{\"In\":[{\"Name\":\"State\",\"Ident\":\"fmt.State\",\"Kind\":20,\"PkgPath\":\"fmt\",\"Methods\":null},{\"Name\":\"int32\",\"Ident\":\"int32\",\"Kind\":5,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalText\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"SetVariant\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"SetVersion\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalText\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Variant\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]},\"Version\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.EntityProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"EntityProps\",\"Ident\":\"types.EntityProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"storage_key\":\"recycle_options\",\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"File\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"owner\",\"type\":\"User\",\"field\":\"owner_id\",\"ref_name\":\"files\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"storage_policies\",\"type\":\"StoragePolicy\",\"field\":\"storage_policy_files\",\"ref_name\":\"files\",\"unique\":true,\"inverse\":true},{\"name\":\"parent\",\"type\":\"File\",\"field\":\"file_children\",\"ref\":{\"name\":\"children\",\"type\":\"File\"},\"unique\":true,\"inverse\":true},{\"name\":\"metadata\",\"type\":\"Metadata\"},{\"name\":\"entities\",\"type\":\"Entity\"},{\"name\":\"shares\",\"type\":\"Share\"},{\"name\":\"direct_links\",\"type\":\"DirectLink\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"type\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"owner_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"size\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"primary_entity\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_children\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_symbolic\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.FileProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"FileProps\",\"Ident\":\"types.FileProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage_policy_files\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"file_children\",\"name\"]},{\"fields\":[\"file_children\",\"type\",\"updated_at\"]},{\"fields\":[\"file_children\",\"type\",\"created_at\"]},{\"fields\":[\"file_children\",\"type\",\"size\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}]},{\"name\":\"FsEvent\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_fsevent\",\"ref_name\":\"fsevents\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"event\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"subscriber\",\"type\":{\"Type\":4,\"Ident\":\"uuid.UUID\",\"PkgPath\":\"github.com/gofrs/uuid\",\"PkgName\":\"uuid\",\"Nillable\":false,\"RType\":{\"Name\":\"UUID\",\"Ident\":\"uuid.UUID\",\"Kind\":17,\"PkgPath\":\"github.com/gofrs/uuid\",\"Methods\":{\"Bytes\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"Format\":{\"In\":[{\"Name\":\"State\",\"Ident\":\"fmt.State\",\"Kind\":20,\"PkgPath\":\"fmt\",\"Methods\":null},{\"Name\":\"int32\",\"Ident\":\"int32\",\"Kind\":5,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalText\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"SetVariant\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"SetVersion\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalText\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Variant\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]},\"Version\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"user_fsevent\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Group\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\"},{\"name\":\"storage_policies\",\"type\":\"StoragePolicy\",\"field\":\"storage_policy_id\",\"ref_name\":\"groups\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_storage\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"speed_limit\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"permissions\",\"type\":{\"Type\":5,\"Ident\":\"*boolset.BooleanSet\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"PkgName\":\"boolset\",\"Nillable\":true,\"RType\":{\"Name\":\"BooleanSet\",\"Ident\":\"boolset.BooleanSet\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"Methods\":{\"Enabled\":{\"In\":[{\"Name\":\"int\",\"Ident\":\"int\",\"Kind\":2,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.GroupSetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"GroupSetting\",\"Ident\":\"types.GroupSetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage_policy_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Metadata\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"file\",\"type\":\"File\",\"field\":\"file_id\",\"ref_name\":\"metadata\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_public\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"file_id\",\"name\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Node\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"storage_policy\",\"type\":\"StoragePolicy\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"node.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"suspended\",\"V\":\"suspended\"}],\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"node.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"master\",\"V\":\"master\"},{\"N\":\"slave\",\"V\":\"slave\"}],\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"server\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"slave_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"capabilities\",\"type\":{\"Type\":5,\"Ident\":\"*boolset.BooleanSet\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"PkgName\":\"boolset\",\"Nillable\":true,\"RType\":{\"Name\":\"BooleanSet\",\"Ident\":\"boolset.BooleanSet\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"Methods\":{\"Enabled\":{\"In\":[{\"Name\":\"int\",\"Ident\":\"int\",\"Kind\":2,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.NodeSetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"NodeSetting\",\"Ident\":\"types.NodeSetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"weight\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"OAuthClient\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"grants\",\"type\":\"OAuthGrant\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"guid\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":255,\"unique\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"secret\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":255,\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":255,\"validators\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"homepage_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2048,\"optional\":true,\"validators\":1,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"redirect_uris\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.OAuthClientProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"OAuthClientProps\",\"Ident\":\"types.OAuthClientProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_enabled\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":true,\"default_kind\":1,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"OAuthGrant\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"oauth_grants\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"client\",\"type\":\"OAuthClient\",\"field\":\"client_id\",\"ref_name\":\"grants\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"client_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_used_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"client_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Passkey\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"passkey\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"credential_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"credential\",\"type\":{\"Type\":3,\"Ident\":\"*webauthn.Credential\",\"PkgPath\":\"github.com/go-webauthn/webauthn/webauthn\",\"PkgName\":\"webauthn\",\"Nillable\":true,\"RType\":{\"Name\":\"Credential\",\"Ident\":\"webauthn.Credential\",\"Kind\":22,\"PkgPath\":\"github.com/go-webauthn/webauthn/webauthn\",\"Methods\":{\"Descriptor\":{\"In\":[],\"Out\":[{\"Name\":\"CredentialDescriptor\",\"Ident\":\"protocol.CredentialDescriptor\",\"Kind\":25,\"PkgPath\":\"github.com/go-webauthn/webauthn/protocol\",\"Methods\":null}]},\"Verify\":{\"In\":[{\"Name\":\"Provider\",\"Ident\":\"metadata.Provider\",\"Kind\":20,\"PkgPath\":\"github.com/go-webauthn/webauthn/metadata\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"used_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"credential_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Setting\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Share\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"ref_name\":\"shares\",\"unique\":true,\"inverse\":true},{\"name\":\"file\",\"type\":\"File\",\"ref_name\":\"shares\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"views\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"downloads\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"expires\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"remain_downloads\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.ShareProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"ShareProps\",\"Ident\":\"types.ShareProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"StoragePolicy\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"groups\",\"type\":\"Group\"},{\"name\":\"files\",\"type\":\"File\"},{\"name\":\"entities\",\"type\":\"Entity\"},{\"name\":\"node\",\"type\":\"Node\",\"field\":\"node_id\",\"ref_name\":\"storage_policy\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"server\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"bucket_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_private\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"access_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"secret_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_size\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"dir_name_rule\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_name_rule\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.PolicySetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"PolicySetting\",\"Ident\":\"types.PolicySetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"file_type\":null,\"native_media_processing\":false,\"s3_path_style\":false,\"token\":\"\"},\"default_kind\":22,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"node_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Task\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_tasks\",\"ref_name\":\"tasks\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"type\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"task.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"queued\",\"V\":\"queued\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"suspending\",\"V\":\"suspending\"},{\"N\":\"error\",\"V\":\"error\"},{\"N\":\"canceled\",\"V\":\"canceled\"},{\"N\":\"completed\",\"V\":\"completed\"}],\"default\":true,\"default_value\":\"queued\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"public_state\",\"type\":{\"Type\":3,\"Ident\":\"*types.TaskPublicState\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"TaskPublicState\",\"Ident\":\"types.TaskPublicState\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"private_state\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"correlation_id\",\"type\":{\"Type\":4,\"Ident\":\"uuid.UUID\",\"PkgPath\":\"github.com/gofrs/uuid\",\"PkgName\":\"uuid\",\"Nillable\":false,\"RType\":{\"Name\":\"UUID\",\"Ident\":\"uuid.UUID\",\"Kind\":17,\"PkgPath\":\"github.com/gofrs/uuid\",\"Methods\":{\"Bytes\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"Format\":{\"In\":[{\"Name\":\"State\",\"Ident\":\"fmt.State\",\"Kind\":20,\"PkgPath\":\"fmt\",\"Methods\":null},{\"Name\":\"int32\",\"Ident\":\"int32\",\"Kind\":5,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalText\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"SetVariant\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"SetVersion\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalText\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Variant\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]},\"Version\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"user_tasks\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"group\",\"type\":\"Group\",\"field\":\"group_users\",\"ref_name\":\"users\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"files\",\"type\":\"File\"},{\"name\":\"dav_accounts\",\"type\":\"DavAccount\"},{\"name\":\"shares\",\"type\":\"Share\"},{\"name\":\"passkey\",\"type\":\"Passkey\"},{\"name\":\"tasks\",\"type\":\"Task\"},{\"name\":\"fsevents\",\"type\":\"FsEvent\"},{\"name\":\"entities\",\"type\":\"Entity\"},{\"name\":\"oauth_grants\",\"type\":\"OAuthGrant\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":100,\"unique\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"nick\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":100,\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"user.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"inactive\",\"V\":\"inactive\"},{\"N\":\"manual_banned\",\"V\":\"manual_banned\"},{\"N\":\"sys_banned\",\"V\":\"sys_banned\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"two_factor_secret\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.UserSetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"UserSetting\",\"Ident\":\"types.UserSetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"group_users\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]}],\"Features\":[\"intercept\",\"schema/snapshot\",\"sql/upsert\",\"sql/upsert\",\"sql/execquery\"]}" +const Schema = "{\"Schema\":\"github.com/cloudreve/Cloudreve/v4/ent/schema\",\"Package\":\"github.com/cloudreve/Cloudreve/v4/ent\",\"Schemas\":[{\"name\":\"DavAccount\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"owner\",\"type\":\"User\",\"field\":\"owner_id\",\"ref_name\":\"dav_accounts\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"uri\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"options\",\"type\":{\"Type\":5,\"Ident\":\"*boolset.BooleanSet\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"PkgName\":\"boolset\",\"Nillable\":true,\"RType\":{\"Name\":\"BooleanSet\",\"Ident\":\"boolset.BooleanSet\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"Methods\":{\"Enabled\":{\"In\":[{\"Name\":\"int\",\"Ident\":\"int\",\"Kind\":2,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.DavAccountProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"DavAccountProps\",\"Ident\":\"types.DavAccountProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"owner_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"owner_id\",\"password\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"DirectLink\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"file\",\"type\":\"File\",\"field\":\"file_id\",\"ref_name\":\"direct_links\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"downloads\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"speed\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Entity\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"file\",\"type\":\"File\",\"ref_name\":\"entities\",\"inverse\":true},{\"name\":\"user\",\"type\":\"User\",\"field\":\"created_by\",\"ref_name\":\"entities\",\"unique\":true,\"inverse\":true},{\"name\":\"storage_policy\",\"type\":\"StoragePolicy\",\"field\":\"storage_policy_entities\",\"ref_name\":\"entities\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"type\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"source\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"size\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"reference_count\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":1,\"default_kind\":2,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage_policy_entities\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"created_by\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"upload_session_id\",\"type\":{\"Type\":4,\"Ident\":\"uuid.UUID\",\"PkgPath\":\"github.com/gofrs/uuid\",\"PkgName\":\"uuid\",\"Nillable\":false,\"RType\":{\"Name\":\"UUID\",\"Ident\":\"uuid.UUID\",\"Kind\":17,\"PkgPath\":\"github.com/gofrs/uuid\",\"Methods\":{\"Bytes\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"Format\":{\"In\":[{\"Name\":\"State\",\"Ident\":\"fmt.State\",\"Kind\":20,\"PkgPath\":\"fmt\",\"Methods\":null},{\"Name\":\"int32\",\"Ident\":\"int32\",\"Kind\":5,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalText\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"SetVariant\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"SetVersion\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalText\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Variant\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]},\"Version\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.EntityProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"EntityProps\",\"Ident\":\"types.EntityProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"storage_key\":\"recycle_options\",\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"File\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"owner\",\"type\":\"User\",\"field\":\"owner_id\",\"ref_name\":\"files\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"storage_policies\",\"type\":\"StoragePolicy\",\"field\":\"storage_policy_files\",\"ref_name\":\"files\",\"unique\":true,\"inverse\":true},{\"name\":\"parent\",\"type\":\"File\",\"field\":\"file_children\",\"ref\":{\"name\":\"children\",\"type\":\"File\"},\"unique\":true,\"inverse\":true},{\"name\":\"metadata\",\"type\":\"Metadata\"},{\"name\":\"entities\",\"type\":\"Entity\"},{\"name\":\"shares\",\"type\":\"Share\"},{\"name\":\"direct_links\",\"type\":\"DirectLink\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"type\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"owner_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"size\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"primary_entity\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_children\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_symbolic\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.FileProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"FileProps\",\"Ident\":\"types.FileProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage_policy_files\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"file_children\",\"name\"]},{\"fields\":[\"file_children\",\"type\",\"updated_at\"]},{\"fields\":[\"file_children\",\"type\",\"created_at\"]},{\"fields\":[\"file_children\",\"type\",\"size\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}]},{\"name\":\"FsEvent\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_fsevent\",\"ref_name\":\"fsevents\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"event\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"subscriber\",\"type\":{\"Type\":4,\"Ident\":\"uuid.UUID\",\"PkgPath\":\"github.com/gofrs/uuid\",\"PkgName\":\"uuid\",\"Nillable\":false,\"RType\":{\"Name\":\"UUID\",\"Ident\":\"uuid.UUID\",\"Kind\":17,\"PkgPath\":\"github.com/gofrs/uuid\",\"Methods\":{\"Bytes\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"Format\":{\"In\":[{\"Name\":\"State\",\"Ident\":\"fmt.State\",\"Kind\":20,\"PkgPath\":\"fmt\",\"Methods\":null},{\"Name\":\"int32\",\"Ident\":\"int32\",\"Kind\":5,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalText\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"SetVariant\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"SetVersion\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalText\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Variant\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]},\"Version\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"user_fsevent\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Group\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"users\",\"type\":\"User\"},{\"name\":\"storage_policies\",\"type\":\"StoragePolicy\",\"field\":\"storage_policy_id\",\"ref_name\":\"groups\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_storage\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"speed_limit\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"permissions\",\"type\":{\"Type\":5,\"Ident\":\"*boolset.BooleanSet\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"PkgName\":\"boolset\",\"Nillable\":true,\"RType\":{\"Name\":\"BooleanSet\",\"Ident\":\"boolset.BooleanSet\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"Methods\":{\"Enabled\":{\"In\":[{\"Name\":\"int\",\"Ident\":\"int\",\"Kind\":2,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.GroupSetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"GroupSetting\",\"Ident\":\"types.GroupSetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"storage_policy_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Metadata\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"file\",\"type\":\"File\",\"field\":\"file_id\",\"ref_name\":\"metadata\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_public\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}}],\"indexes\":[{\"unique\":true,\"fields\":[\"file_id\",\"name\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Node\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"storage_policy\",\"type\":\"StoragePolicy\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"node.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"suspended\",\"V\":\"suspended\"}],\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":6,\"Ident\":\"node.Type\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"master\",\"V\":\"master\"},{\"N\":\"slave\",\"V\":\"slave\"}],\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"server\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"slave_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"capabilities\",\"type\":{\"Type\":5,\"Ident\":\"*boolset.BooleanSet\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"PkgName\":\"boolset\",\"Nillable\":true,\"RType\":{\"Name\":\"BooleanSet\",\"Ident\":\"boolset.BooleanSet\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/pkg/boolset\",\"Methods\":{\"Enabled\":{\"In\":[{\"Name\":\"int\",\"Ident\":\"int\",\"Kind\":2,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"bool\",\"Ident\":\"bool\",\"Kind\":1,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.NodeSetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"NodeSetting\",\"Ident\":\"types.NodeSetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"weight\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"OAuthClient\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"grants\",\"type\":\"OAuthGrant\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"guid\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":255,\"unique\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"secret\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":255,\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":255,\"validators\":1,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"homepage_url\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2048,\"optional\":true,\"validators\":1,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"redirect_uris\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.OAuthClientProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"OAuthClientProps\",\"Ident\":\"types.OAuthClientProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_enabled\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":true,\"default_kind\":1,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"OAuthGrant\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"oauth_grants\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"client\",\"type\":\"OAuthClient\",\"field\":\"client_id\",\"ref_name\":\"grants\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"client_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"scopes\",\"type\":{\"Type\":3,\"Ident\":\"[]string\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":true,\"RType\":{\"Name\":\"\",\"Ident\":\"[]string\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":{}}},\"default\":true,\"default_value\":[],\"default_kind\":23,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_used_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"client_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Passkey\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_id\",\"ref_name\":\"passkey\",\"unique\":true,\"inverse\":true,\"required\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"user_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"credential_id\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"credential\",\"type\":{\"Type\":3,\"Ident\":\"*webauthn.Credential\",\"PkgPath\":\"github.com/go-webauthn/webauthn/webauthn\",\"PkgName\":\"webauthn\",\"Nillable\":true,\"RType\":{\"Name\":\"Credential\",\"Ident\":\"webauthn.Credential\",\"Kind\":22,\"PkgPath\":\"github.com/go-webauthn/webauthn/webauthn\",\"Methods\":{\"Descriptor\":{\"In\":[],\"Out\":[{\"Name\":\"CredentialDescriptor\",\"Ident\":\"protocol.CredentialDescriptor\",\"Kind\":25,\"PkgPath\":\"github.com/go-webauthn/webauthn/protocol\",\"Methods\":null}]},\"Verify\":{\"In\":[{\"Name\":\"Provider\",\"Ident\":\"metadata.Provider\",\"Kind\":20,\"PkgPath\":\"github.com/go-webauthn/webauthn/metadata\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"used_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}}],\"indexes\":[{\"unique\":true,\"fields\":[\"user_id\",\"credential_id\"]}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Setting\",\"config\":{\"Table\":\"\"},\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"unique\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"value\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Share\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"ref_name\":\"shares\",\"unique\":true,\"inverse\":true},{\"name\":\"file\",\"type\":\"File\",\"ref_name\":\"shares\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"views\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"downloads\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":2,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"expires\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"remain_downloads\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"props\",\"type\":{\"Type\":3,\"Ident\":\"*types.ShareProps\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"ShareProps\",\"Ident\":\"types.ShareProps\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"StoragePolicy\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"groups\",\"type\":\"Group\"},{\"name\":\"files\",\"type\":\"File\"},{\"name\":\"entities\",\"type\":\"Entity\"},{\"name\":\"node\",\"type\":\"Node\",\"field\":\"node_id\",\"ref_name\":\"storage_policy\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"type\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"storagepolicy.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"suspended\",\"V\":\"suspended\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"server\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"bucket_name\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"is_private\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"access_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"secret_key\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"max_size\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"dir_name_rule\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"file_name_rule\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.PolicySetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"PolicySetting\",\"Ident\":\"types.PolicySetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{\"file_type\":null,\"native_media_processing\":false,\"s3_path_style\":false,\"token\":\"\"},\"default_kind\":22,\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"node_id\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":12,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"Task\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"user\",\"type\":\"User\",\"field\":\"user_tasks\",\"ref_name\":\"tasks\",\"unique\":true,\"inverse\":true}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"type\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"task.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"queued\",\"V\":\"queued\"},{\"N\":\"processing\",\"V\":\"processing\"},{\"N\":\"suspending\",\"V\":\"suspending\"},{\"N\":\"error\",\"V\":\"error\"},{\"N\":\"canceled\",\"V\":\"canceled\"},{\"N\":\"completed\",\"V\":\"completed\"}],\"default\":true,\"default_value\":\"queued\",\"default_kind\":24,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"public_state\",\"type\":{\"Type\":3,\"Ident\":\"*types.TaskPublicState\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"TaskPublicState\",\"Ident\":\"types.TaskPublicState\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"private_state\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"correlation_id\",\"type\":{\"Type\":4,\"Ident\":\"uuid.UUID\",\"PkgPath\":\"github.com/gofrs/uuid\",\"PkgName\":\"uuid\",\"Nillable\":false,\"RType\":{\"Name\":\"UUID\",\"Ident\":\"uuid.UUID\",\"Kind\":17,\"PkgPath\":\"github.com/gofrs/uuid\",\"Methods\":{\"Bytes\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}]},\"Format\":{\"In\":[{\"Name\":\"State\",\"Ident\":\"fmt.State\",\"Kind\":20,\"PkgPath\":\"fmt\",\"Methods\":null},{\"Name\":\"int32\",\"Ident\":\"int32\",\"Kind\":5,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"MarshalBinary\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"MarshalText\":{\"In\":[],\"Out\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Scan\":{\"In\":[{\"Name\":\"\",\"Ident\":\"interface {}\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"SetVariant\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"SetVersion\":{\"In\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[]},\"String\":{\"In\":[],\"Out\":[{\"Name\":\"string\",\"Ident\":\"string\",\"Kind\":24,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalBinary\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"UnmarshalText\":{\"In\":[{\"Name\":\"\",\"Ident\":\"[]uint8\",\"Kind\":23,\"PkgPath\":\"\",\"Methods\":null}],\"Out\":[{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Value\":{\"In\":[],\"Out\":[{\"Name\":\"Value\",\"Ident\":\"driver.Value\",\"Kind\":20,\"PkgPath\":\"database/sql/driver\",\"Methods\":null},{\"Name\":\"error\",\"Ident\":\"error\",\"Kind\":20,\"PkgPath\":\"\",\"Methods\":null}]},\"Variant\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]},\"Version\":{\"In\":[],\"Out\":[{\"Name\":\"uint8\",\"Ident\":\"uint8\",\"Kind\":8,\"PkgPath\":\"\",\"Methods\":null}]}}}},\"optional\":true,\"immutable\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"user_tasks\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"hidden\",\"type\":{\"Type\":1,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":false,\"default_kind\":1,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]},{\"name\":\"User\",\"config\":{\"Table\":\"\"},\"edges\":[{\"name\":\"group\",\"type\":\"Group\",\"field\":\"group_users\",\"ref_name\":\"users\",\"unique\":true,\"inverse\":true,\"required\":true},{\"name\":\"files\",\"type\":\"File\"},{\"name\":\"dav_accounts\",\"type\":\"DavAccount\"},{\"name\":\"shares\",\"type\":\"Share\"},{\"name\":\"passkey\",\"type\":\"Passkey\"},{\"name\":\"tasks\",\"type\":\"Task\"},{\"name\":\"fsevents\",\"type\":\"FsEvent\"},{\"name\":\"entities\",\"type\":\"Entity\"},{\"name\":\"oauth_grants\",\"type\":\"OAuthGrant\"}],\"fields\":[{\"name\":\"created_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"immutable\":true,\"position\":{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"updated_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_kind\":19,\"update_default\":true,\"position\":{\"Index\":1,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"deleted_at\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":true,\"MixinIndex\":0},\"schema_type\":{\"mysql\":\"datetime\"}},{\"name\":\"email\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":100,\"unique\":true,\"validators\":1,\"position\":{\"Index\":0,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"nick\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":100,\"validators\":1,\"position\":{\"Index\":1,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"password\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":2,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"status\",\"type\":{\"Type\":6,\"Ident\":\"user.Status\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"enums\":[{\"N\":\"active\",\"V\":\"active\"},{\"N\":\"inactive\",\"V\":\"inactive\"},{\"N\":\"manual_banned\",\"V\":\"manual_banned\"},{\"N\":\"sys_banned\",\"V\":\"sys_banned\"}],\"default\":true,\"default_value\":\"active\",\"default_kind\":24,\"position\":{\"Index\":3,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"ban_expires\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":4,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"ban_reason\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"size\":2147483647,\"optional\":true,\"position\":{\"Index\":5,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"last_login\",\"type\":{\"Type\":2,\"Ident\":\"\",\"PkgPath\":\"time\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"nillable\":true,\"optional\":true,\"position\":{\"Index\":6,\"MixedIn\":false,\"MixinIndex\":0},\"comment\":\"Time of the last successful sign-in\"},{\"name\":\"storage\",\"type\":{\"Type\":13,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"default\":true,\"default_value\":0,\"default_kind\":6,\"position\":{\"Index\":7,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"two_factor_secret\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":8,\"MixedIn\":false,\"MixinIndex\":0},\"sensitive\":true},{\"name\":\"avatar\",\"type\":{\"Type\":7,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"optional\":true,\"position\":{\"Index\":9,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"settings\",\"type\":{\"Type\":3,\"Ident\":\"*types.UserSetting\",\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"PkgName\":\"types\",\"Nillable\":true,\"RType\":{\"Name\":\"UserSetting\",\"Ident\":\"types.UserSetting\",\"Kind\":22,\"PkgPath\":\"github.com/cloudreve/Cloudreve/v4/inventory/types\",\"Methods\":{}}},\"optional\":true,\"default\":true,\"default_value\":{},\"default_kind\":22,\"position\":{\"Index\":10,\"MixedIn\":false,\"MixinIndex\":0}},{\"name\":\"group_users\",\"type\":{\"Type\":12,\"Ident\":\"\",\"PkgPath\":\"\",\"PkgName\":\"\",\"Nillable\":false,\"RType\":null},\"position\":{\"Index\":11,\"MixedIn\":false,\"MixinIndex\":0}}],\"hooks\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}],\"interceptors\":[{\"Index\":0,\"MixedIn\":true,\"MixinIndex\":0}]}],\"Features\":[\"intercept\",\"schema/snapshot\",\"sql/upsert\",\"sql/upsert\",\"sql/execquery\"]}" diff --git a/ent/migrate/schema.go b/ent/migrate/schema.go index f5c428de..6e008ea8 100644 --- a/ent/migrate/schema.go +++ b/ent/migrate/schema.go @@ -419,6 +419,7 @@ var ( {Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}}, {Name: "name", Type: field.TypeString}, {Name: "type", Type: field.TypeString}, + {Name: "status", Type: field.TypeEnum, Enums: []string{"active", "suspended"}, Default: "active"}, {Name: "server", Type: field.TypeString, Nullable: true}, {Name: "bucket_name", Type: field.TypeString, Nullable: true}, {Name: "is_private", Type: field.TypeBool, Nullable: true}, @@ -438,7 +439,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "storage_policies_nodes_storage_policy", - Columns: []*schema.Column{StoragePoliciesColumns[15]}, + Columns: []*schema.Column{StoragePoliciesColumns[16]}, RefColumns: []*schema.Column{NodesColumns[0]}, OnDelete: schema.SetNull, }, @@ -455,6 +456,7 @@ var ( {Name: "public_state", Type: field.TypeJSON}, {Name: "private_state", Type: field.TypeString, Nullable: true, Size: 2147483647}, {Name: "correlation_id", Type: field.TypeUUID, Nullable: true}, + {Name: "hidden", Type: field.TypeBool, Default: false}, {Name: "user_tasks", Type: field.TypeInt, Nullable: true}, } // TasksTable holds the schema information for the "tasks" table. @@ -465,7 +467,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "tasks_users_tasks", - Columns: []*schema.Column{TasksColumns[9]}, + Columns: []*schema.Column{TasksColumns[10]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.SetNull, }, @@ -481,6 +483,9 @@ var ( {Name: "nick", Type: field.TypeString, Size: 100}, {Name: "password", Type: field.TypeString, Nullable: true}, {Name: "status", Type: field.TypeEnum, Enums: []string{"active", "inactive", "manual_banned", "sys_banned"}, Default: "active"}, + {Name: "ban_expires", Type: field.TypeTime, Nullable: true}, + {Name: "ban_reason", Type: field.TypeString, Nullable: true, Size: 2147483647}, + {Name: "last_login", Type: field.TypeTime, Nullable: true}, {Name: "storage", Type: field.TypeInt64, Default: 0}, {Name: "two_factor_secret", Type: field.TypeString, Nullable: true}, {Name: "avatar", Type: field.TypeString, Nullable: true}, @@ -495,7 +500,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "users_groups_users", - Columns: []*schema.Column{UsersColumns[12]}, + Columns: []*schema.Column{UsersColumns[15]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.NoAction, }, diff --git a/ent/mutation.go b/ent/mutation.go index 0e1fed18..592bd028 100644 --- a/ent/mutation.go +++ b/ent/mutation.go @@ -12519,6 +12519,7 @@ type StoragePolicyMutation struct { deleted_at *time.Time name *string _type *string + status *storagepolicy.Status server *string bucket_name *string is_private *bool @@ -12837,6 +12838,42 @@ func (m *StoragePolicyMutation) ResetType() { m._type = nil } +// SetStatus sets the "status" field. +func (m *StoragePolicyMutation) SetStatus(s storagepolicy.Status) { + m.status = &s +} + +// Status returns the value of the "status" field in the mutation. +func (m *StoragePolicyMutation) Status() (r storagepolicy.Status, exists bool) { + v := m.status + if v == nil { + return + } + return *v, true +} + +// OldStatus returns the old "status" field's value of the StoragePolicy entity. +// If the StoragePolicy object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *StoragePolicyMutation) OldStatus(ctx context.Context) (v storagepolicy.Status, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStatus is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStatus requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStatus: %w", err) + } + return oldValue.Status, nil +} + +// ResetStatus resets all changes to the "status" field. +func (m *StoragePolicyMutation) ResetStatus() { + m.status = nil +} + // SetServer sets the "server" field. func (m *StoragePolicyMutation) SetServer(s string) { m.server = &s @@ -13571,7 +13608,7 @@ func (m *StoragePolicyMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *StoragePolicyMutation) Fields() []string { - fields := make([]string, 0, 15) + fields := make([]string, 0, 16) if m.created_at != nil { fields = append(fields, storagepolicy.FieldCreatedAt) } @@ -13587,6 +13624,9 @@ func (m *StoragePolicyMutation) Fields() []string { if m._type != nil { fields = append(fields, storagepolicy.FieldType) } + if m.status != nil { + fields = append(fields, storagepolicy.FieldStatus) + } if m.server != nil { fields = append(fields, storagepolicy.FieldServer) } @@ -13635,6 +13675,8 @@ func (m *StoragePolicyMutation) Field(name string) (ent.Value, bool) { return m.Name() case storagepolicy.FieldType: return m.GetType() + case storagepolicy.FieldStatus: + return m.Status() case storagepolicy.FieldServer: return m.Server() case storagepolicy.FieldBucketName: @@ -13674,6 +13716,8 @@ func (m *StoragePolicyMutation) OldField(ctx context.Context, name string) (ent. return m.OldName(ctx) case storagepolicy.FieldType: return m.OldType(ctx) + case storagepolicy.FieldStatus: + return m.OldStatus(ctx) case storagepolicy.FieldServer: return m.OldServer(ctx) case storagepolicy.FieldBucketName: @@ -13738,6 +13782,13 @@ func (m *StoragePolicyMutation) SetField(name string, value ent.Value) error { } m.SetType(v) return nil + case storagepolicy.FieldStatus: + v, ok := value.(storagepolicy.Status) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStatus(v) + return nil case storagepolicy.FieldServer: v, ok := value.(string) if !ok { @@ -13956,6 +14007,9 @@ func (m *StoragePolicyMutation) ResetField(name string) error { case storagepolicy.FieldType: m.ResetType() return nil + case storagepolicy.FieldStatus: + m.ResetStatus() + return nil case storagepolicy.FieldServer: m.ResetServer() return nil @@ -14158,6 +14212,7 @@ type TaskMutation struct { public_state **types.TaskPublicState private_state *string correlation_id *uuid.UUID + hidden *bool clearedFields map[string]struct{} user *int cleareduser bool @@ -14640,6 +14695,42 @@ func (m *TaskMutation) ResetUserTasks() { delete(m.clearedFields, task.FieldUserTasks) } +// SetHidden sets the "hidden" field. +func (m *TaskMutation) SetHidden(b bool) { + m.hidden = &b +} + +// Hidden returns the value of the "hidden" field in the mutation. +func (m *TaskMutation) Hidden() (r bool, exists bool) { + v := m.hidden + if v == nil { + return + } + return *v, true +} + +// OldHidden returns the old "hidden" field's value of the Task entity. +// If the Task object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *TaskMutation) OldHidden(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldHidden is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldHidden requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldHidden: %w", err) + } + return oldValue.Hidden, nil +} + +// ResetHidden resets all changes to the "hidden" field. +func (m *TaskMutation) ResetHidden() { + m.hidden = nil +} + // SetUserID sets the "user" edge to the User entity by id. func (m *TaskMutation) SetUserID(id int) { m.user = &id @@ -14714,7 +14805,7 @@ func (m *TaskMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *TaskMutation) Fields() []string { - fields := make([]string, 0, 9) + fields := make([]string, 0, 10) if m.created_at != nil { fields = append(fields, task.FieldCreatedAt) } @@ -14742,6 +14833,9 @@ func (m *TaskMutation) Fields() []string { if m.user != nil { fields = append(fields, task.FieldUserTasks) } + if m.hidden != nil { + fields = append(fields, task.FieldHidden) + } return fields } @@ -14768,6 +14862,8 @@ func (m *TaskMutation) Field(name string) (ent.Value, bool) { return m.CorrelationID() case task.FieldUserTasks: return m.UserTasks() + case task.FieldHidden: + return m.Hidden() } return nil, false } @@ -14795,6 +14891,8 @@ func (m *TaskMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldCorrelationID(ctx) case task.FieldUserTasks: return m.OldUserTasks(ctx) + case task.FieldHidden: + return m.OldHidden(ctx) } return nil, fmt.Errorf("unknown Task field %s", name) } @@ -14867,6 +14965,13 @@ func (m *TaskMutation) SetField(name string, value ent.Value) error { } m.SetUserTasks(v) return nil + case task.FieldHidden: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetHidden(v) + return nil } return fmt.Errorf("unknown Task field %s", name) } @@ -14973,6 +15078,9 @@ func (m *TaskMutation) ResetField(name string) error { case task.FieldUserTasks: m.ResetUserTasks() return nil + case task.FieldHidden: + m.ResetHidden() + return nil } return fmt.Errorf("unknown Task field %s", name) } @@ -15064,6 +15172,9 @@ type UserMutation struct { nick *string password *string status *user.Status + ban_expires *time.Time + ban_reason *string + last_login *time.Time storage *int64 addstorage *int64 two_factor_secret *string @@ -15477,6 +15588,153 @@ func (m *UserMutation) ResetStatus() { m.status = nil } +// SetBanExpires sets the "ban_expires" field. +func (m *UserMutation) SetBanExpires(t time.Time) { + m.ban_expires = &t +} + +// BanExpires returns the value of the "ban_expires" field in the mutation. +func (m *UserMutation) BanExpires() (r time.Time, exists bool) { + v := m.ban_expires + if v == nil { + return + } + return *v, true +} + +// OldBanExpires returns the old "ban_expires" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldBanExpires(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBanExpires is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBanExpires requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBanExpires: %w", err) + } + return oldValue.BanExpires, nil +} + +// ClearBanExpires clears the value of the "ban_expires" field. +func (m *UserMutation) ClearBanExpires() { + m.ban_expires = nil + m.clearedFields[user.FieldBanExpires] = struct{}{} +} + +// BanExpiresCleared returns if the "ban_expires" field was cleared in this mutation. +func (m *UserMutation) BanExpiresCleared() bool { + _, ok := m.clearedFields[user.FieldBanExpires] + return ok +} + +// ResetBanExpires resets all changes to the "ban_expires" field. +func (m *UserMutation) ResetBanExpires() { + m.ban_expires = nil + delete(m.clearedFields, user.FieldBanExpires) +} + +// SetBanReason sets the "ban_reason" field. +func (m *UserMutation) SetBanReason(s string) { + m.ban_reason = &s +} + +// BanReason returns the value of the "ban_reason" field in the mutation. +func (m *UserMutation) BanReason() (r string, exists bool) { + v := m.ban_reason + if v == nil { + return + } + return *v, true +} + +// OldBanReason returns the old "ban_reason" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldBanReason(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBanReason is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBanReason requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBanReason: %w", err) + } + return oldValue.BanReason, nil +} + +// ClearBanReason clears the value of the "ban_reason" field. +func (m *UserMutation) ClearBanReason() { + m.ban_reason = nil + m.clearedFields[user.FieldBanReason] = struct{}{} +} + +// BanReasonCleared returns if the "ban_reason" field was cleared in this mutation. +func (m *UserMutation) BanReasonCleared() bool { + _, ok := m.clearedFields[user.FieldBanReason] + return ok +} + +// ResetBanReason resets all changes to the "ban_reason" field. +func (m *UserMutation) ResetBanReason() { + m.ban_reason = nil + delete(m.clearedFields, user.FieldBanReason) +} + +// SetLastLogin sets the "last_login" field. +func (m *UserMutation) SetLastLogin(t time.Time) { + m.last_login = &t +} + +// LastLogin returns the value of the "last_login" field in the mutation. +func (m *UserMutation) LastLogin() (r time.Time, exists bool) { + v := m.last_login + if v == nil { + return + } + return *v, true +} + +// OldLastLogin returns the old "last_login" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldLastLogin(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLastLogin is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLastLogin requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLastLogin: %w", err) + } + return oldValue.LastLogin, nil +} + +// ClearLastLogin clears the value of the "last_login" field. +func (m *UserMutation) ClearLastLogin() { + m.last_login = nil + m.clearedFields[user.FieldLastLogin] = struct{}{} +} + +// LastLoginCleared returns if the "last_login" field was cleared in this mutation. +func (m *UserMutation) LastLoginCleared() bool { + _, ok := m.clearedFields[user.FieldLastLogin] + return ok +} + +// ResetLastLogin resets all changes to the "last_login" field. +func (m *UserMutation) ResetLastLogin() { + m.last_login = nil + delete(m.clearedFields, user.FieldLastLogin) +} + // SetStorage sets the "storage" field. func (m *UserMutation) SetStorage(i int64) { m.storage = &i @@ -16222,7 +16480,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 12) + fields := make([]string, 0, 15) if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -16244,6 +16502,15 @@ func (m *UserMutation) Fields() []string { if m.status != nil { fields = append(fields, user.FieldStatus) } + if m.ban_expires != nil { + fields = append(fields, user.FieldBanExpires) + } + if m.ban_reason != nil { + fields = append(fields, user.FieldBanReason) + } + if m.last_login != nil { + fields = append(fields, user.FieldLastLogin) + } if m.storage != nil { fields = append(fields, user.FieldStorage) } @@ -16281,6 +16548,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.Password() case user.FieldStatus: return m.Status() + case user.FieldBanExpires: + return m.BanExpires() + case user.FieldBanReason: + return m.BanReason() + case user.FieldLastLogin: + return m.LastLogin() case user.FieldStorage: return m.Storage() case user.FieldTwoFactorSecret: @@ -16314,6 +16587,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldPassword(ctx) case user.FieldStatus: return m.OldStatus(ctx) + case user.FieldBanExpires: + return m.OldBanExpires(ctx) + case user.FieldBanReason: + return m.OldBanReason(ctx) + case user.FieldLastLogin: + return m.OldLastLogin(ctx) case user.FieldStorage: return m.OldStorage(ctx) case user.FieldTwoFactorSecret: @@ -16382,6 +16661,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetStatus(v) return nil + case user.FieldBanExpires: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBanExpires(v) + return nil + case user.FieldBanReason: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBanReason(v) + return nil + case user.FieldLastLogin: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLastLogin(v) + return nil case user.FieldStorage: v, ok := value.(int64) if !ok { @@ -16468,6 +16768,15 @@ func (m *UserMutation) ClearedFields() []string { if m.FieldCleared(user.FieldPassword) { fields = append(fields, user.FieldPassword) } + if m.FieldCleared(user.FieldBanExpires) { + fields = append(fields, user.FieldBanExpires) + } + if m.FieldCleared(user.FieldBanReason) { + fields = append(fields, user.FieldBanReason) + } + if m.FieldCleared(user.FieldLastLogin) { + fields = append(fields, user.FieldLastLogin) + } if m.FieldCleared(user.FieldTwoFactorSecret) { fields = append(fields, user.FieldTwoFactorSecret) } @@ -16497,6 +16806,15 @@ func (m *UserMutation) ClearField(name string) error { case user.FieldPassword: m.ClearPassword() return nil + case user.FieldBanExpires: + m.ClearBanExpires() + return nil + case user.FieldBanReason: + m.ClearBanReason() + return nil + case user.FieldLastLogin: + m.ClearLastLogin() + return nil case user.FieldTwoFactorSecret: m.ClearTwoFactorSecret() return nil @@ -16535,6 +16853,15 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldStatus: m.ResetStatus() return nil + case user.FieldBanExpires: + m.ResetBanExpires() + return nil + case user.FieldBanReason: + m.ResetBanReason() + return nil + case user.FieldLastLogin: + m.ResetLastLogin() + return nil case user.FieldStorage: m.ResetStorage() return nil diff --git a/ent/runtime/runtime.go b/ent/runtime/runtime.go index 40303c99..1aeb17a4 100644 --- a/ent/runtime/runtime.go +++ b/ent/runtime/runtime.go @@ -361,7 +361,7 @@ func init() { // storagepolicy.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. storagepolicy.UpdateDefaultUpdatedAt = storagepolicyDescUpdatedAt.UpdateDefault.(func() time.Time) // storagepolicyDescSettings is the schema descriptor for settings field. - storagepolicyDescSettings := storagepolicyFields[10].Descriptor() + storagepolicyDescSettings := storagepolicyFields[11].Descriptor() // storagepolicy.DefaultSettings holds the default value on creation for the settings field. storagepolicy.DefaultSettings = storagepolicyDescSettings.Default.(*types.PolicySetting) taskMixin := schema.Task{}.Mixin() @@ -383,6 +383,10 @@ func init() { task.DefaultUpdatedAt = taskDescUpdatedAt.Default.(func() time.Time) // task.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. task.UpdateDefaultUpdatedAt = taskDescUpdatedAt.UpdateDefault.(func() time.Time) + // taskDescHidden is the schema descriptor for hidden field. + taskDescHidden := taskFields[6].Descriptor() + // task.DefaultHidden holds the default value on creation for the hidden field. + task.DefaultHidden = taskDescHidden.Default.(bool) userMixin := schema.User{}.Mixin() userMixinHooks0 := userMixin[0].Hooks() user.Hooks[0] = userMixinHooks0[0] @@ -411,11 +415,11 @@ func init() { // user.NickValidator is a validator for the "nick" field. It is called by the builders before save. user.NickValidator = userDescNick.Validators[0].(func(string) error) // userDescStorage is the schema descriptor for storage field. - userDescStorage := userFields[4].Descriptor() + userDescStorage := userFields[7].Descriptor() // user.DefaultStorage holds the default value on creation for the storage field. user.DefaultStorage = userDescStorage.Default.(int64) // userDescSettings is the schema descriptor for settings field. - userDescSettings := userFields[7].Descriptor() + userDescSettings := userFields[10].Descriptor() // user.DefaultSettings holds the default value on creation for the settings field. user.DefaultSettings = userDescSettings.Default.(*types.UserSetting) } diff --git a/ent/schema/policy.go b/ent/schema/policy.go index a3659193..6b24a261 100644 --- a/ent/schema/policy.go +++ b/ent/schema/policy.go @@ -16,6 +16,10 @@ func (StoragePolicy) Fields() []ent.Field { return []ent.Field{ field.String("name"), field.String("type"), + // Suspended policies keep serving reads but reject new uploads. + field.Enum("status"). + Values("active", "suspended"). + Default("active"), field.String("server"). Optional(), field.String("bucket_name"). diff --git a/ent/schema/task.go b/ent/schema/task.go index ee82f561..571f6036 100644 --- a/ent/schema/task.go +++ b/ent/schema/task.go @@ -26,6 +26,8 @@ func (Task) Fields() []ent.Field { Optional(). Immutable(), field.Int("user_tasks").Optional(), + // hidden is set when the owner deletes the task record; admin views keep it. + field.Bool("hidden").Default(false), } } diff --git a/ent/schema/user.go b/ent/schema/user.go index 28214350..b7bce672 100644 --- a/ent/schema/user.go +++ b/ent/schema/user.go @@ -25,6 +25,17 @@ func (User) Fields() []ent.Field { field.Enum("status"). Values("active", "inactive", "manual_banned", "sys_banned"). Default("active"), + // ban_expires lifts a banned status after this time; nil = permanent. + field.Time("ban_expires"). + Optional(). + Nillable(), + // ban_reason is shown to the user when a banned login is rejected. + field.Text("ban_reason"). + Optional(), + field.Time("last_login"). + Optional(). + Nillable(). + Comment("Time of the last successful sign-in"), field.Int64("storage"). Default(0), field.String("two_factor_secret"). diff --git a/ent/storagepolicy.go b/ent/storagepolicy.go index 1b244d8e..7209dcb7 100644 --- a/ent/storagepolicy.go +++ b/ent/storagepolicy.go @@ -30,6 +30,8 @@ type StoragePolicy struct { Name string `json:"name,omitempty"` // Type holds the value of the "type" field. Type string `json:"type,omitempty"` + // Status holds the value of the "status" field. + Status storagepolicy.Status `json:"status,omitempty"` // Server holds the value of the "server" field. Server string `json:"server,omitempty"` // BucketName holds the value of the "bucket_name" field. @@ -122,7 +124,7 @@ func (*StoragePolicy) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullBool) case storagepolicy.FieldID, storagepolicy.FieldMaxSize, storagepolicy.FieldNodeID: values[i] = new(sql.NullInt64) - case storagepolicy.FieldName, storagepolicy.FieldType, storagepolicy.FieldServer, storagepolicy.FieldBucketName, storagepolicy.FieldAccessKey, storagepolicy.FieldSecretKey, storagepolicy.FieldDirNameRule, storagepolicy.FieldFileNameRule: + case storagepolicy.FieldName, storagepolicy.FieldType, storagepolicy.FieldStatus, storagepolicy.FieldServer, storagepolicy.FieldBucketName, storagepolicy.FieldAccessKey, storagepolicy.FieldSecretKey, storagepolicy.FieldDirNameRule, storagepolicy.FieldFileNameRule: values[i] = new(sql.NullString) case storagepolicy.FieldCreatedAt, storagepolicy.FieldUpdatedAt, storagepolicy.FieldDeletedAt: values[i] = new(sql.NullTime) @@ -178,6 +180,12 @@ func (sp *StoragePolicy) assignValues(columns []string, values []any) error { } else if value.Valid { sp.Type = value.String } + case storagepolicy.FieldStatus: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field status", values[i]) + } else if value.Valid { + sp.Status = storagepolicy.Status(value.String) + } case storagepolicy.FieldServer: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field server", values[i]) @@ -313,6 +321,9 @@ func (sp *StoragePolicy) String() string { builder.WriteString("type=") builder.WriteString(sp.Type) builder.WriteString(", ") + builder.WriteString("status=") + builder.WriteString(fmt.Sprintf("%v", sp.Status)) + builder.WriteString(", ") builder.WriteString("server=") builder.WriteString(sp.Server) builder.WriteString(", ") diff --git a/ent/storagepolicy/storagepolicy.go b/ent/storagepolicy/storagepolicy.go index 78a28b17..91a4d021 100644 --- a/ent/storagepolicy/storagepolicy.go +++ b/ent/storagepolicy/storagepolicy.go @@ -3,6 +3,7 @@ package storagepolicy import ( + "fmt" "time" "entgo.io/ent" @@ -26,6 +27,8 @@ const ( FieldName = "name" // FieldType holds the string denoting the type field in the database. FieldType = "type" + // FieldStatus holds the string denoting the status field in the database. + FieldStatus = "status" // FieldServer holds the string denoting the server field in the database. FieldServer = "server" // FieldBucketName holds the string denoting the bucket_name field in the database. @@ -94,6 +97,7 @@ var Columns = []string{ FieldDeletedAt, FieldName, FieldType, + FieldStatus, FieldServer, FieldBucketName, FieldIsPrivate, @@ -134,6 +138,32 @@ var ( DefaultSettings *types.PolicySetting ) +// Status defines the type for the "status" enum field. +type Status string + +// StatusActive is the default value of the Status enum. +const DefaultStatus = StatusActive + +// Status values. +const ( + StatusActive Status = "active" + StatusSuspended Status = "suspended" +) + +func (s Status) String() string { + return string(s) +} + +// StatusValidator is a validator for the "status" field enum values. It is called by the builders before save. +func StatusValidator(s Status) error { + switch s { + case StatusActive, StatusSuspended: + return nil + default: + return fmt.Errorf("storagepolicy: invalid enum value for status field: %q", s) + } +} + // OrderOption defines the ordering options for the StoragePolicy queries. type OrderOption func(*sql.Selector) @@ -167,6 +197,11 @@ func ByType(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldType, opts...).ToFunc() } +// ByStatus orders the results by the status field. +func ByStatus(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStatus, opts...).ToFunc() +} + // ByServer orders the results by the server field. func ByServer(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldServer, opts...).ToFunc() diff --git a/ent/storagepolicy/where.go b/ent/storagepolicy/where.go index 490bd5e4..d73e63ee 100644 --- a/ent/storagepolicy/where.go +++ b/ent/storagepolicy/where.go @@ -385,6 +385,26 @@ func TypeContainsFold(v string) predicate.StoragePolicy { return predicate.StoragePolicy(sql.FieldContainsFold(FieldType, v)) } +// StatusEQ applies the EQ predicate on the "status" field. +func StatusEQ(v Status) predicate.StoragePolicy { + return predicate.StoragePolicy(sql.FieldEQ(FieldStatus, v)) +} + +// StatusNEQ applies the NEQ predicate on the "status" field. +func StatusNEQ(v Status) predicate.StoragePolicy { + return predicate.StoragePolicy(sql.FieldNEQ(FieldStatus, v)) +} + +// StatusIn applies the In predicate on the "status" field. +func StatusIn(vs ...Status) predicate.StoragePolicy { + return predicate.StoragePolicy(sql.FieldIn(FieldStatus, vs...)) +} + +// StatusNotIn applies the NotIn predicate on the "status" field. +func StatusNotIn(vs ...Status) predicate.StoragePolicy { + return predicate.StoragePolicy(sql.FieldNotIn(FieldStatus, vs...)) +} + // ServerEQ applies the EQ predicate on the "server" field. func ServerEQ(v string) predicate.StoragePolicy { return predicate.StoragePolicy(sql.FieldEQ(FieldServer, v)) diff --git a/ent/storagepolicy_create.go b/ent/storagepolicy_create.go index fd3dac35..9581c8b8 100644 --- a/ent/storagepolicy_create.go +++ b/ent/storagepolicy_create.go @@ -81,6 +81,20 @@ func (spc *StoragePolicyCreate) SetType(s string) *StoragePolicyCreate { return spc } +// SetStatus sets the "status" field. +func (spc *StoragePolicyCreate) SetStatus(s storagepolicy.Status) *StoragePolicyCreate { + spc.mutation.SetStatus(s) + return spc +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (spc *StoragePolicyCreate) SetNillableStatus(s *storagepolicy.Status) *StoragePolicyCreate { + if s != nil { + spc.SetStatus(*s) + } + return spc +} + // SetServer sets the "server" field. func (spc *StoragePolicyCreate) SetServer(s string) *StoragePolicyCreate { spc.mutation.SetServer(s) @@ -314,6 +328,10 @@ func (spc *StoragePolicyCreate) defaults() error { v := storagepolicy.DefaultUpdatedAt() spc.mutation.SetUpdatedAt(v) } + if _, ok := spc.mutation.Status(); !ok { + v := storagepolicy.DefaultStatus + spc.mutation.SetStatus(v) + } if _, ok := spc.mutation.Settings(); !ok { v := storagepolicy.DefaultSettings spc.mutation.SetSettings(v) @@ -335,6 +353,14 @@ func (spc *StoragePolicyCreate) check() error { if _, ok := spc.mutation.GetType(); !ok { return &ValidationError{Name: "type", err: errors.New(`ent: missing required field "StoragePolicy.type"`)} } + if _, ok := spc.mutation.Status(); !ok { + return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "StoragePolicy.status"`)} + } + if v, ok := spc.mutation.Status(); ok { + if err := storagepolicy.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "StoragePolicy.status": %w`, err)} + } + } return nil } @@ -389,6 +415,10 @@ func (spc *StoragePolicyCreate) createSpec() (*StoragePolicy, *sqlgraph.CreateSp _spec.SetField(storagepolicy.FieldType, field.TypeString, value) _node.Type = value } + if value, ok := spc.mutation.Status(); ok { + _spec.SetField(storagepolicy.FieldStatus, field.TypeEnum, value) + _node.Status = value + } if value, ok := spc.mutation.Server(); ok { _spec.SetField(storagepolicy.FieldServer, field.TypeString, value) _node.Server = value @@ -596,6 +626,18 @@ func (u *StoragePolicyUpsert) UpdateType() *StoragePolicyUpsert { return u } +// SetStatus sets the "status" field. +func (u *StoragePolicyUpsert) SetStatus(v storagepolicy.Status) *StoragePolicyUpsert { + u.Set(storagepolicy.FieldStatus, v) + return u +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *StoragePolicyUpsert) UpdateStatus() *StoragePolicyUpsert { + u.SetExcluded(storagepolicy.FieldStatus) + return u +} + // SetServer sets the "server" field. func (u *StoragePolicyUpsert) SetServer(v string) *StoragePolicyUpsert { u.Set(storagepolicy.FieldServer, v) @@ -890,6 +932,20 @@ func (u *StoragePolicyUpsertOne) UpdateType() *StoragePolicyUpsertOne { }) } +// SetStatus sets the "status" field. +func (u *StoragePolicyUpsertOne) SetStatus(v storagepolicy.Status) *StoragePolicyUpsertOne { + return u.Update(func(s *StoragePolicyUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *StoragePolicyUpsertOne) UpdateStatus() *StoragePolicyUpsertOne { + return u.Update(func(s *StoragePolicyUpsert) { + s.UpdateStatus() + }) +} + // SetServer sets the "server" field. func (u *StoragePolicyUpsertOne) SetServer(v string) *StoragePolicyUpsertOne { return u.Update(func(s *StoragePolicyUpsert) { @@ -1386,6 +1442,20 @@ func (u *StoragePolicyUpsertBulk) UpdateType() *StoragePolicyUpsertBulk { }) } +// SetStatus sets the "status" field. +func (u *StoragePolicyUpsertBulk) SetStatus(v storagepolicy.Status) *StoragePolicyUpsertBulk { + return u.Update(func(s *StoragePolicyUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *StoragePolicyUpsertBulk) UpdateStatus() *StoragePolicyUpsertBulk { + return u.Update(func(s *StoragePolicyUpsert) { + s.UpdateStatus() + }) +} + // SetServer sets the "server" field. func (u *StoragePolicyUpsertBulk) SetServer(v string) *StoragePolicyUpsertBulk { return u.Update(func(s *StoragePolicyUpsert) { diff --git a/ent/storagepolicy_update.go b/ent/storagepolicy_update.go index 7579c228..d0af7053 100644 --- a/ent/storagepolicy_update.go +++ b/ent/storagepolicy_update.go @@ -87,6 +87,20 @@ func (spu *StoragePolicyUpdate) SetNillableType(s *string) *StoragePolicyUpdate return spu } +// SetStatus sets the "status" field. +func (spu *StoragePolicyUpdate) SetStatus(s storagepolicy.Status) *StoragePolicyUpdate { + spu.mutation.SetStatus(s) + return spu +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (spu *StoragePolicyUpdate) SetNillableStatus(s *storagepolicy.Status) *StoragePolicyUpdate { + if s != nil { + spu.SetStatus(*s) + } + return spu +} + // SetServer sets the "server" field. func (spu *StoragePolicyUpdate) SetServer(s string) *StoragePolicyUpdate { spu.mutation.SetServer(s) @@ -452,7 +466,20 @@ func (spu *StoragePolicyUpdate) defaults() error { return nil } +// check runs all checks and user-defined validators on the builder. +func (spu *StoragePolicyUpdate) check() error { + if v, ok := spu.mutation.Status(); ok { + if err := storagepolicy.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "StoragePolicy.status": %w`, err)} + } + } + return nil +} + func (spu *StoragePolicyUpdate) sqlSave(ctx context.Context) (n int, err error) { + if err := spu.check(); err != nil { + return n, err + } _spec := sqlgraph.NewUpdateSpec(storagepolicy.Table, storagepolicy.Columns, sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt)) if ps := spu.mutation.predicates; len(ps) > 0 { _spec.Predicate = func(selector *sql.Selector) { @@ -476,6 +503,9 @@ func (spu *StoragePolicyUpdate) sqlSave(ctx context.Context) (n int, err error) if value, ok := spu.mutation.GetType(); ok { _spec.SetField(storagepolicy.FieldType, field.TypeString, value) } + if value, ok := spu.mutation.Status(); ok { + _spec.SetField(storagepolicy.FieldStatus, field.TypeEnum, value) + } if value, ok := spu.mutation.Server(); ok { _spec.SetField(storagepolicy.FieldServer, field.TypeString, value) } @@ -771,6 +801,20 @@ func (spuo *StoragePolicyUpdateOne) SetNillableType(s *string) *StoragePolicyUpd return spuo } +// SetStatus sets the "status" field. +func (spuo *StoragePolicyUpdateOne) SetStatus(s storagepolicy.Status) *StoragePolicyUpdateOne { + spuo.mutation.SetStatus(s) + return spuo +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (spuo *StoragePolicyUpdateOne) SetNillableStatus(s *storagepolicy.Status) *StoragePolicyUpdateOne { + if s != nil { + spuo.SetStatus(*s) + } + return spuo +} + // SetServer sets the "server" field. func (spuo *StoragePolicyUpdateOne) SetServer(s string) *StoragePolicyUpdateOne { spuo.mutation.SetServer(s) @@ -1149,7 +1193,20 @@ func (spuo *StoragePolicyUpdateOne) defaults() error { return nil } +// check runs all checks and user-defined validators on the builder. +func (spuo *StoragePolicyUpdateOne) check() error { + if v, ok := spuo.mutation.Status(); ok { + if err := storagepolicy.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "StoragePolicy.status": %w`, err)} + } + } + return nil +} + func (spuo *StoragePolicyUpdateOne) sqlSave(ctx context.Context) (_node *StoragePolicy, err error) { + if err := spuo.check(); err != nil { + return _node, err + } _spec := sqlgraph.NewUpdateSpec(storagepolicy.Table, storagepolicy.Columns, sqlgraph.NewFieldSpec(storagepolicy.FieldID, field.TypeInt)) id, ok := spuo.mutation.ID() if !ok { @@ -1190,6 +1247,9 @@ func (spuo *StoragePolicyUpdateOne) sqlSave(ctx context.Context) (_node *Storage if value, ok := spuo.mutation.GetType(); ok { _spec.SetField(storagepolicy.FieldType, field.TypeString, value) } + if value, ok := spuo.mutation.Status(); ok { + _spec.SetField(storagepolicy.FieldStatus, field.TypeEnum, value) + } if value, ok := spuo.mutation.Server(); ok { _spec.SetField(storagepolicy.FieldServer, field.TypeString, value) } diff --git a/ent/task.go b/ent/task.go index 67a14b99..ed4f048a 100644 --- a/ent/task.go +++ b/ent/task.go @@ -39,6 +39,8 @@ type Task struct { CorrelationID uuid.UUID `json:"correlation_id,omitempty"` // UserTasks holds the value of the "user_tasks" field. UserTasks int `json:"user_tasks,omitempty"` + // Hidden holds the value of the "hidden" field. + Hidden bool `json:"hidden,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the TaskQuery when eager-loading is set. Edges TaskEdges `json:"edges"` @@ -74,6 +76,8 @@ func (*Task) scanValues(columns []string) ([]any, error) { switch columns[i] { case task.FieldPublicState: values[i] = new([]byte) + case task.FieldHidden: + values[i] = new(sql.NullBool) case task.FieldID, task.FieldUserTasks: values[i] = new(sql.NullInt64) case task.FieldType, task.FieldStatus, task.FieldPrivateState: @@ -160,6 +164,12 @@ func (t *Task) assignValues(columns []string, values []any) error { } else if value.Valid { t.UserTasks = int(value.Int64) } + case task.FieldHidden: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field hidden", values[i]) + } else if value.Valid { + t.Hidden = value.Bool + } default: t.selectValues.Set(columns[i], values[i]) } @@ -229,6 +239,9 @@ func (t *Task) String() string { builder.WriteString(", ") builder.WriteString("user_tasks=") builder.WriteString(fmt.Sprintf("%v", t.UserTasks)) + builder.WriteString(", ") + builder.WriteString("hidden=") + builder.WriteString(fmt.Sprintf("%v", t.Hidden)) builder.WriteByte(')') return builder.String() } diff --git a/ent/task/task.go b/ent/task/task.go index 7f96f1c9..a9a783dd 100644 --- a/ent/task/task.go +++ b/ent/task/task.go @@ -34,6 +34,8 @@ const ( FieldCorrelationID = "correlation_id" // FieldUserTasks holds the string denoting the user_tasks field in the database. FieldUserTasks = "user_tasks" + // FieldHidden holds the string denoting the hidden field in the database. + FieldHidden = "hidden" // EdgeUser holds the string denoting the user edge name in mutations. EdgeUser = "user" // Table holds the table name of the task in the database. @@ -59,6 +61,7 @@ var Columns = []string{ FieldPrivateState, FieldCorrelationID, FieldUserTasks, + FieldHidden, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -85,6 +88,8 @@ var ( DefaultUpdatedAt func() time.Time // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. UpdateDefaultUpdatedAt func() time.Time + // DefaultHidden holds the default value on creation for the "hidden" field. + DefaultHidden bool ) // Status defines the type for the "status" enum field. @@ -165,6 +170,11 @@ func ByUserTasks(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUserTasks, opts...).ToFunc() } +// ByHidden orders the results by the hidden field. +func ByHidden(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldHidden, opts...).ToFunc() +} + // ByUserField orders the results by user field. func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/ent/task/where.go b/ent/task/where.go index 59a7ced6..7b9f6684 100644 --- a/ent/task/where.go +++ b/ent/task/where.go @@ -91,6 +91,11 @@ func UserTasks(v int) predicate.Task { return predicate.Task(sql.FieldEQ(FieldUserTasks, v)) } +// Hidden applies equality check predicate on the "hidden" field. It's identical to HiddenEQ. +func Hidden(v bool) predicate.Task { + return predicate.Task(sql.FieldEQ(FieldHidden, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Task { return predicate.Task(sql.FieldEQ(FieldCreatedAt, v)) @@ -461,6 +466,16 @@ func UserTasksNotNil() predicate.Task { return predicate.Task(sql.FieldNotNull(FieldUserTasks)) } +// HiddenEQ applies the EQ predicate on the "hidden" field. +func HiddenEQ(v bool) predicate.Task { + return predicate.Task(sql.FieldEQ(FieldHidden, v)) +} + +// HiddenNEQ applies the NEQ predicate on the "hidden" field. +func HiddenNEQ(v bool) predicate.Task { + return predicate.Task(sql.FieldNEQ(FieldHidden, v)) +} + // HasUser applies the HasEdge predicate on the "user" edge. func HasUser() predicate.Task { return predicate.Task(func(s *sql.Selector) { diff --git a/ent/task_create.go b/ent/task_create.go index e1d40b27..b123295c 100644 --- a/ent/task_create.go +++ b/ent/task_create.go @@ -135,6 +135,20 @@ func (tc *TaskCreate) SetNillableUserTasks(i *int) *TaskCreate { return tc } +// SetHidden sets the "hidden" field. +func (tc *TaskCreate) SetHidden(b bool) *TaskCreate { + tc.mutation.SetHidden(b) + return tc +} + +// SetNillableHidden sets the "hidden" field if the given value is not nil. +func (tc *TaskCreate) SetNillableHidden(b *bool) *TaskCreate { + if b != nil { + tc.SetHidden(*b) + } + return tc +} + // SetUserID sets the "user" edge to the User entity by ID. func (tc *TaskCreate) SetUserID(id int) *TaskCreate { tc.mutation.SetUserID(id) @@ -209,6 +223,10 @@ func (tc *TaskCreate) defaults() error { v := task.DefaultStatus tc.mutation.SetStatus(v) } + if _, ok := tc.mutation.Hidden(); !ok { + v := task.DefaultHidden + tc.mutation.SetHidden(v) + } return nil } @@ -234,6 +252,9 @@ func (tc *TaskCreate) check() error { if _, ok := tc.mutation.PublicState(); !ok { return &ValidationError{Name: "public_state", err: errors.New(`ent: missing required field "Task.public_state"`)} } + if _, ok := tc.mutation.Hidden(); !ok { + return &ValidationError{Name: "hidden", err: errors.New(`ent: missing required field "Task.hidden"`)} + } return nil } @@ -300,6 +321,10 @@ func (tc *TaskCreate) createSpec() (*Task, *sqlgraph.CreateSpec) { _spec.SetField(task.FieldCorrelationID, field.TypeUUID, value) _node.CorrelationID = value } + if value, ok := tc.mutation.Hidden(); ok { + _spec.SetField(task.FieldHidden, field.TypeBool, value) + _node.Hidden = value + } if nodes := tc.mutation.UserIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -471,6 +496,18 @@ func (u *TaskUpsert) ClearUserTasks() *TaskUpsert { return u } +// SetHidden sets the "hidden" field. +func (u *TaskUpsert) SetHidden(v bool) *TaskUpsert { + u.Set(task.FieldHidden, v) + return u +} + +// UpdateHidden sets the "hidden" field to the value that was provided on create. +func (u *TaskUpsert) UpdateHidden() *TaskUpsert { + u.SetExcluded(task.FieldHidden) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -638,6 +675,20 @@ func (u *TaskUpsertOne) ClearUserTasks() *TaskUpsertOne { }) } +// SetHidden sets the "hidden" field. +func (u *TaskUpsertOne) SetHidden(v bool) *TaskUpsertOne { + return u.Update(func(s *TaskUpsert) { + s.SetHidden(v) + }) +} + +// UpdateHidden sets the "hidden" field to the value that was provided on create. +func (u *TaskUpsertOne) UpdateHidden() *TaskUpsertOne { + return u.Update(func(s *TaskUpsert) { + s.UpdateHidden() + }) +} + // Exec executes the query. func (u *TaskUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -976,6 +1027,20 @@ func (u *TaskUpsertBulk) ClearUserTasks() *TaskUpsertBulk { }) } +// SetHidden sets the "hidden" field. +func (u *TaskUpsertBulk) SetHidden(v bool) *TaskUpsertBulk { + return u.Update(func(s *TaskUpsert) { + s.SetHidden(v) + }) +} + +// UpdateHidden sets the "hidden" field to the value that was provided on create. +func (u *TaskUpsertBulk) UpdateHidden() *TaskUpsertBulk { + return u.Update(func(s *TaskUpsert) { + s.UpdateHidden() + }) +} + // Exec executes the query. func (u *TaskUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/ent/task_update.go b/ent/task_update.go index 9bf5452f..17484a71 100644 --- a/ent/task_update.go +++ b/ent/task_update.go @@ -130,6 +130,20 @@ func (tu *TaskUpdate) ClearUserTasks() *TaskUpdate { return tu } +// SetHidden sets the "hidden" field. +func (tu *TaskUpdate) SetHidden(b bool) *TaskUpdate { + tu.mutation.SetHidden(b) + return tu +} + +// SetNillableHidden sets the "hidden" field if the given value is not nil. +func (tu *TaskUpdate) SetNillableHidden(b *bool) *TaskUpdate { + if b != nil { + tu.SetHidden(*b) + } + return tu +} + // SetUserID sets the "user" edge to the User entity by ID. func (tu *TaskUpdate) SetUserID(id int) *TaskUpdate { tu.mutation.SetUserID(id) @@ -251,6 +265,9 @@ func (tu *TaskUpdate) sqlSave(ctx context.Context) (n int, err error) { if tu.mutation.CorrelationIDCleared() { _spec.ClearField(task.FieldCorrelationID, field.TypeUUID) } + if value, ok := tu.mutation.Hidden(); ok { + _spec.SetField(task.FieldHidden, field.TypeBool, value) + } if tu.mutation.UserCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -400,6 +417,20 @@ func (tuo *TaskUpdateOne) ClearUserTasks() *TaskUpdateOne { return tuo } +// SetHidden sets the "hidden" field. +func (tuo *TaskUpdateOne) SetHidden(b bool) *TaskUpdateOne { + tuo.mutation.SetHidden(b) + return tuo +} + +// SetNillableHidden sets the "hidden" field if the given value is not nil. +func (tuo *TaskUpdateOne) SetNillableHidden(b *bool) *TaskUpdateOne { + if b != nil { + tuo.SetHidden(*b) + } + return tuo +} + // SetUserID sets the "user" edge to the User entity by ID. func (tuo *TaskUpdateOne) SetUserID(id int) *TaskUpdateOne { tuo.mutation.SetUserID(id) @@ -551,6 +582,9 @@ func (tuo *TaskUpdateOne) sqlSave(ctx context.Context) (_node *Task, err error) if tuo.mutation.CorrelationIDCleared() { _spec.ClearField(task.FieldCorrelationID, field.TypeUUID) } + if value, ok := tuo.mutation.Hidden(); ok { + _spec.SetField(task.FieldHidden, field.TypeBool, value) + } if tuo.mutation.UserCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/ent/user.go b/ent/user.go index a7775e9f..be43c1d6 100644 --- a/ent/user.go +++ b/ent/user.go @@ -34,6 +34,12 @@ type User struct { Password string `json:"-"` // Status holds the value of the "status" field. Status user.Status `json:"status,omitempty"` + // BanExpires holds the value of the "ban_expires" field. + BanExpires *time.Time `json:"ban_expires,omitempty"` + // BanReason holds the value of the "ban_reason" field. + BanReason string `json:"ban_reason,omitempty"` + // Time of the last successful sign-in + LastLogin *time.Time `json:"last_login,omitempty"` // Storage holds the value of the "storage" field. Storage int64 `json:"storage,omitempty"` // TwoFactorSecret holds the value of the "two_factor_secret" field. @@ -169,9 +175,9 @@ func (*User) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case user.FieldID, user.FieldStorage, user.FieldGroupUsers: values[i] = new(sql.NullInt64) - case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldTwoFactorSecret, user.FieldAvatar: + case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldAvatar: values[i] = new(sql.NullString) - case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt: + case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires, user.FieldLastLogin: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -237,6 +243,26 @@ func (u *User) assignValues(columns []string, values []any) error { } else if value.Valid { u.Status = user.Status(value.String) } + case user.FieldBanExpires: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field ban_expires", values[i]) + } else if value.Valid { + u.BanExpires = new(time.Time) + *u.BanExpires = value.Time + } + case user.FieldBanReason: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field ban_reason", values[i]) + } else if value.Valid { + u.BanReason = value.String + } + case user.FieldLastLogin: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field last_login", values[i]) + } else if value.Valid { + u.LastLogin = new(time.Time) + *u.LastLogin = value.Time + } case user.FieldStorage: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field storage", values[i]) @@ -372,6 +398,19 @@ func (u *User) String() string { builder.WriteString("status=") builder.WriteString(fmt.Sprintf("%v", u.Status)) builder.WriteString(", ") + if v := u.BanExpires; v != nil { + builder.WriteString("ban_expires=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + builder.WriteString("ban_reason=") + builder.WriteString(u.BanReason) + builder.WriteString(", ") + if v := u.LastLogin; v != nil { + builder.WriteString("last_login=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") builder.WriteString("storage=") builder.WriteString(fmt.Sprintf("%v", u.Storage)) builder.WriteString(", ") diff --git a/ent/user/user.go b/ent/user/user.go index 99fad933..2f3a05c6 100644 --- a/ent/user/user.go +++ b/ent/user/user.go @@ -31,6 +31,12 @@ const ( FieldPassword = "password" // FieldStatus holds the string denoting the status field in the database. FieldStatus = "status" + // FieldBanExpires holds the string denoting the ban_expires field in the database. + FieldBanExpires = "ban_expires" + // FieldBanReason holds the string denoting the ban_reason field in the database. + FieldBanReason = "ban_reason" + // FieldLastLogin holds the string denoting the last_login field in the database. + FieldLastLogin = "last_login" // FieldStorage holds the string denoting the storage field in the database. FieldStorage = "storage" // FieldTwoFactorSecret holds the string denoting the two_factor_secret field in the database. @@ -136,6 +142,9 @@ var Columns = []string{ FieldNick, FieldPassword, FieldStatus, + FieldBanExpires, + FieldBanReason, + FieldLastLogin, FieldStorage, FieldTwoFactorSecret, FieldAvatar, @@ -248,6 +257,21 @@ func ByStatus(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldStatus, opts...).ToFunc() } +// ByBanExpires orders the results by the ban_expires field. +func ByBanExpires(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBanExpires, opts...).ToFunc() +} + +// ByBanReason orders the results by the ban_reason field. +func ByBanReason(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBanReason, opts...).ToFunc() +} + +// ByLastLogin orders the results by the last_login field. +func ByLastLogin(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLastLogin, opts...).ToFunc() +} + // ByStorage orders the results by the storage field. func ByStorage(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldStorage, opts...).ToFunc() diff --git a/ent/user/where.go b/ent/user/where.go index 1bb867ef..42180888 100644 --- a/ent/user/where.go +++ b/ent/user/where.go @@ -85,6 +85,21 @@ func Password(v string) predicate.User { return predicate.User(sql.FieldEQ(FieldPassword, v)) } +// BanExpires applies equality check predicate on the "ban_expires" field. It's identical to BanExpiresEQ. +func BanExpires(v time.Time) predicate.User { + return predicate.User(sql.FieldEQ(FieldBanExpires, v)) +} + +// BanReason applies equality check predicate on the "ban_reason" field. It's identical to BanReasonEQ. +func BanReason(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldBanReason, v)) +} + +// LastLogin applies equality check predicate on the "last_login" field. It's identical to LastLoginEQ. +func LastLogin(v time.Time) predicate.User { + return predicate.User(sql.FieldEQ(FieldLastLogin, v)) +} + // Storage applies equality check predicate on the "storage" field. It's identical to StorageEQ. func Storage(v int64) predicate.User { return predicate.User(sql.FieldEQ(FieldStorage, v)) @@ -460,6 +475,181 @@ func StatusNotIn(vs ...Status) predicate.User { return predicate.User(sql.FieldNotIn(FieldStatus, vs...)) } +// BanExpiresEQ applies the EQ predicate on the "ban_expires" field. +func BanExpiresEQ(v time.Time) predicate.User { + return predicate.User(sql.FieldEQ(FieldBanExpires, v)) +} + +// BanExpiresNEQ applies the NEQ predicate on the "ban_expires" field. +func BanExpiresNEQ(v time.Time) predicate.User { + return predicate.User(sql.FieldNEQ(FieldBanExpires, v)) +} + +// BanExpiresIn applies the In predicate on the "ban_expires" field. +func BanExpiresIn(vs ...time.Time) predicate.User { + return predicate.User(sql.FieldIn(FieldBanExpires, vs...)) +} + +// BanExpiresNotIn applies the NotIn predicate on the "ban_expires" field. +func BanExpiresNotIn(vs ...time.Time) predicate.User { + return predicate.User(sql.FieldNotIn(FieldBanExpires, vs...)) +} + +// BanExpiresGT applies the GT predicate on the "ban_expires" field. +func BanExpiresGT(v time.Time) predicate.User { + return predicate.User(sql.FieldGT(FieldBanExpires, v)) +} + +// BanExpiresGTE applies the GTE predicate on the "ban_expires" field. +func BanExpiresGTE(v time.Time) predicate.User { + return predicate.User(sql.FieldGTE(FieldBanExpires, v)) +} + +// BanExpiresLT applies the LT predicate on the "ban_expires" field. +func BanExpiresLT(v time.Time) predicate.User { + return predicate.User(sql.FieldLT(FieldBanExpires, v)) +} + +// BanExpiresLTE applies the LTE predicate on the "ban_expires" field. +func BanExpiresLTE(v time.Time) predicate.User { + return predicate.User(sql.FieldLTE(FieldBanExpires, v)) +} + +// BanExpiresIsNil applies the IsNil predicate on the "ban_expires" field. +func BanExpiresIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldBanExpires)) +} + +// BanExpiresNotNil applies the NotNil predicate on the "ban_expires" field. +func BanExpiresNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldBanExpires)) +} + +// BanReasonEQ applies the EQ predicate on the "ban_reason" field. +func BanReasonEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldBanReason, v)) +} + +// BanReasonNEQ applies the NEQ predicate on the "ban_reason" field. +func BanReasonNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldBanReason, v)) +} + +// BanReasonIn applies the In predicate on the "ban_reason" field. +func BanReasonIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldBanReason, vs...)) +} + +// BanReasonNotIn applies the NotIn predicate on the "ban_reason" field. +func BanReasonNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldBanReason, vs...)) +} + +// BanReasonGT applies the GT predicate on the "ban_reason" field. +func BanReasonGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldBanReason, v)) +} + +// BanReasonGTE applies the GTE predicate on the "ban_reason" field. +func BanReasonGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldBanReason, v)) +} + +// BanReasonLT applies the LT predicate on the "ban_reason" field. +func BanReasonLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldBanReason, v)) +} + +// BanReasonLTE applies the LTE predicate on the "ban_reason" field. +func BanReasonLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldBanReason, v)) +} + +// BanReasonContains applies the Contains predicate on the "ban_reason" field. +func BanReasonContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldBanReason, v)) +} + +// BanReasonHasPrefix applies the HasPrefix predicate on the "ban_reason" field. +func BanReasonHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldBanReason, v)) +} + +// BanReasonHasSuffix applies the HasSuffix predicate on the "ban_reason" field. +func BanReasonHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldBanReason, v)) +} + +// BanReasonIsNil applies the IsNil predicate on the "ban_reason" field. +func BanReasonIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldBanReason)) +} + +// BanReasonNotNil applies the NotNil predicate on the "ban_reason" field. +func BanReasonNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldBanReason)) +} + +// BanReasonEqualFold applies the EqualFold predicate on the "ban_reason" field. +func BanReasonEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldBanReason, v)) +} + +// BanReasonContainsFold applies the ContainsFold predicate on the "ban_reason" field. +func BanReasonContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldBanReason, v)) +} + +// LastLoginEQ applies the EQ predicate on the "last_login" field. +func LastLoginEQ(v time.Time) predicate.User { + return predicate.User(sql.FieldEQ(FieldLastLogin, v)) +} + +// LastLoginNEQ applies the NEQ predicate on the "last_login" field. +func LastLoginNEQ(v time.Time) predicate.User { + return predicate.User(sql.FieldNEQ(FieldLastLogin, v)) +} + +// LastLoginIn applies the In predicate on the "last_login" field. +func LastLoginIn(vs ...time.Time) predicate.User { + return predicate.User(sql.FieldIn(FieldLastLogin, vs...)) +} + +// LastLoginNotIn applies the NotIn predicate on the "last_login" field. +func LastLoginNotIn(vs ...time.Time) predicate.User { + return predicate.User(sql.FieldNotIn(FieldLastLogin, vs...)) +} + +// LastLoginGT applies the GT predicate on the "last_login" field. +func LastLoginGT(v time.Time) predicate.User { + return predicate.User(sql.FieldGT(FieldLastLogin, v)) +} + +// LastLoginGTE applies the GTE predicate on the "last_login" field. +func LastLoginGTE(v time.Time) predicate.User { + return predicate.User(sql.FieldGTE(FieldLastLogin, v)) +} + +// LastLoginLT applies the LT predicate on the "last_login" field. +func LastLoginLT(v time.Time) predicate.User { + return predicate.User(sql.FieldLT(FieldLastLogin, v)) +} + +// LastLoginLTE applies the LTE predicate on the "last_login" field. +func LastLoginLTE(v time.Time) predicate.User { + return predicate.User(sql.FieldLTE(FieldLastLogin, v)) +} + +// LastLoginIsNil applies the IsNil predicate on the "last_login" field. +func LastLoginIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldLastLogin)) +} + +// LastLoginNotNil applies the NotNil predicate on the "last_login" field. +func LastLoginNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldLastLogin)) +} + // StorageEQ applies the EQ predicate on the "storage" field. func StorageEQ(v int64) predicate.User { return predicate.User(sql.FieldEQ(FieldStorage, v)) diff --git a/ent/user_create.go b/ent/user_create.go index 0ee53ef8..0e964e08 100644 --- a/ent/user_create.go +++ b/ent/user_create.go @@ -114,6 +114,48 @@ func (uc *UserCreate) SetNillableStatus(u *user.Status) *UserCreate { return uc } +// SetBanExpires sets the "ban_expires" field. +func (uc *UserCreate) SetBanExpires(t time.Time) *UserCreate { + uc.mutation.SetBanExpires(t) + return uc +} + +// SetNillableBanExpires sets the "ban_expires" field if the given value is not nil. +func (uc *UserCreate) SetNillableBanExpires(t *time.Time) *UserCreate { + if t != nil { + uc.SetBanExpires(*t) + } + return uc +} + +// SetBanReason sets the "ban_reason" field. +func (uc *UserCreate) SetBanReason(s string) *UserCreate { + uc.mutation.SetBanReason(s) + return uc +} + +// SetNillableBanReason sets the "ban_reason" field if the given value is not nil. +func (uc *UserCreate) SetNillableBanReason(s *string) *UserCreate { + if s != nil { + uc.SetBanReason(*s) + } + return uc +} + +// SetLastLogin sets the "last_login" field. +func (uc *UserCreate) SetLastLogin(t time.Time) *UserCreate { + uc.mutation.SetLastLogin(t) + return uc +} + +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (uc *UserCreate) SetNillableLastLogin(t *time.Time) *UserCreate { + if t != nil { + uc.SetLastLogin(*t) + } + return uc +} + // SetStorage sets the "storage" field. func (uc *UserCreate) SetStorage(i int64) *UserCreate { uc.mutation.SetStorage(i) @@ -468,6 +510,18 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldStatus, field.TypeEnum, value) _node.Status = value } + if value, ok := uc.mutation.BanExpires(); ok { + _spec.SetField(user.FieldBanExpires, field.TypeTime, value) + _node.BanExpires = &value + } + if value, ok := uc.mutation.BanReason(); ok { + _spec.SetField(user.FieldBanReason, field.TypeString, value) + _node.BanReason = value + } + if value, ok := uc.mutation.LastLogin(); ok { + _spec.SetField(user.FieldLastLogin, field.TypeTime, value) + _node.LastLogin = &value + } if value, ok := uc.mutation.Storage(); ok { _spec.SetField(user.FieldStorage, field.TypeInt64, value) _node.Storage = value @@ -765,6 +819,60 @@ func (u *UserUpsert) UpdateStatus() *UserUpsert { return u } +// SetBanExpires sets the "ban_expires" field. +func (u *UserUpsert) SetBanExpires(v time.Time) *UserUpsert { + u.Set(user.FieldBanExpires, v) + return u +} + +// UpdateBanExpires sets the "ban_expires" field to the value that was provided on create. +func (u *UserUpsert) UpdateBanExpires() *UserUpsert { + u.SetExcluded(user.FieldBanExpires) + return u +} + +// ClearBanExpires clears the value of the "ban_expires" field. +func (u *UserUpsert) ClearBanExpires() *UserUpsert { + u.SetNull(user.FieldBanExpires) + return u +} + +// SetBanReason sets the "ban_reason" field. +func (u *UserUpsert) SetBanReason(v string) *UserUpsert { + u.Set(user.FieldBanReason, v) + return u +} + +// UpdateBanReason sets the "ban_reason" field to the value that was provided on create. +func (u *UserUpsert) UpdateBanReason() *UserUpsert { + u.SetExcluded(user.FieldBanReason) + return u +} + +// ClearBanReason clears the value of the "ban_reason" field. +func (u *UserUpsert) ClearBanReason() *UserUpsert { + u.SetNull(user.FieldBanReason) + return u +} + +// SetLastLogin sets the "last_login" field. +func (u *UserUpsert) SetLastLogin(v time.Time) *UserUpsert { + u.Set(user.FieldLastLogin, v) + return u +} + +// UpdateLastLogin sets the "last_login" field to the value that was provided on create. +func (u *UserUpsert) UpdateLastLogin() *UserUpsert { + u.SetExcluded(user.FieldLastLogin) + return u +} + +// ClearLastLogin clears the value of the "last_login" field. +func (u *UserUpsert) ClearLastLogin() *UserUpsert { + u.SetNull(user.FieldLastLogin) + return u +} + // SetStorage sets the "storage" field. func (u *UserUpsert) SetStorage(v int64) *UserUpsert { u.Set(user.FieldStorage, v) @@ -992,6 +1100,69 @@ func (u *UserUpsertOne) UpdateStatus() *UserUpsertOne { }) } +// SetBanExpires sets the "ban_expires" field. +func (u *UserUpsertOne) SetBanExpires(v time.Time) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetBanExpires(v) + }) +} + +// UpdateBanExpires sets the "ban_expires" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateBanExpires() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateBanExpires() + }) +} + +// ClearBanExpires clears the value of the "ban_expires" field. +func (u *UserUpsertOne) ClearBanExpires() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearBanExpires() + }) +} + +// SetBanReason sets the "ban_reason" field. +func (u *UserUpsertOne) SetBanReason(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetBanReason(v) + }) +} + +// UpdateBanReason sets the "ban_reason" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateBanReason() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateBanReason() + }) +} + +// ClearBanReason clears the value of the "ban_reason" field. +func (u *UserUpsertOne) ClearBanReason() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearBanReason() + }) +} + +// SetLastLogin sets the "last_login" field. +func (u *UserUpsertOne) SetLastLogin(v time.Time) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetLastLogin(v) + }) +} + +// UpdateLastLogin sets the "last_login" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateLastLogin() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateLastLogin() + }) +} + +// ClearLastLogin clears the value of the "last_login" field. +func (u *UserUpsertOne) ClearLastLogin() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearLastLogin() + }) +} + // SetStorage sets the "storage" field. func (u *UserUpsertOne) SetStorage(v int64) *UserUpsertOne { return u.Update(func(s *UserUpsert) { @@ -1404,6 +1575,69 @@ func (u *UserUpsertBulk) UpdateStatus() *UserUpsertBulk { }) } +// SetBanExpires sets the "ban_expires" field. +func (u *UserUpsertBulk) SetBanExpires(v time.Time) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetBanExpires(v) + }) +} + +// UpdateBanExpires sets the "ban_expires" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateBanExpires() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateBanExpires() + }) +} + +// ClearBanExpires clears the value of the "ban_expires" field. +func (u *UserUpsertBulk) ClearBanExpires() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearBanExpires() + }) +} + +// SetBanReason sets the "ban_reason" field. +func (u *UserUpsertBulk) SetBanReason(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetBanReason(v) + }) +} + +// UpdateBanReason sets the "ban_reason" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateBanReason() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateBanReason() + }) +} + +// ClearBanReason clears the value of the "ban_reason" field. +func (u *UserUpsertBulk) ClearBanReason() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearBanReason() + }) +} + +// SetLastLogin sets the "last_login" field. +func (u *UserUpsertBulk) SetLastLogin(v time.Time) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetLastLogin(v) + }) +} + +// UpdateLastLogin sets the "last_login" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateLastLogin() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateLastLogin() + }) +} + +// ClearLastLogin clears the value of the "last_login" field. +func (u *UserUpsertBulk) ClearLastLogin() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearLastLogin() + }) +} + // SetStorage sets the "storage" field. func (u *UserUpsertBulk) SetStorage(v int64) *UserUpsertBulk { return u.Update(func(s *UserUpsert) { diff --git a/ent/user_update.go b/ent/user_update.go index 84c01b77..391a62a0 100644 --- a/ent/user_update.go +++ b/ent/user_update.go @@ -126,6 +126,66 @@ func (uu *UserUpdate) SetNillableStatus(u *user.Status) *UserUpdate { return uu } +// SetBanExpires sets the "ban_expires" field. +func (uu *UserUpdate) SetBanExpires(t time.Time) *UserUpdate { + uu.mutation.SetBanExpires(t) + return uu +} + +// SetNillableBanExpires sets the "ban_expires" field if the given value is not nil. +func (uu *UserUpdate) SetNillableBanExpires(t *time.Time) *UserUpdate { + if t != nil { + uu.SetBanExpires(*t) + } + return uu +} + +// ClearBanExpires clears the value of the "ban_expires" field. +func (uu *UserUpdate) ClearBanExpires() *UserUpdate { + uu.mutation.ClearBanExpires() + return uu +} + +// SetBanReason sets the "ban_reason" field. +func (uu *UserUpdate) SetBanReason(s string) *UserUpdate { + uu.mutation.SetBanReason(s) + return uu +} + +// SetNillableBanReason sets the "ban_reason" field if the given value is not nil. +func (uu *UserUpdate) SetNillableBanReason(s *string) *UserUpdate { + if s != nil { + uu.SetBanReason(*s) + } + return uu +} + +// ClearBanReason clears the value of the "ban_reason" field. +func (uu *UserUpdate) ClearBanReason() *UserUpdate { + uu.mutation.ClearBanReason() + return uu +} + +// SetLastLogin sets the "last_login" field. +func (uu *UserUpdate) SetLastLogin(t time.Time) *UserUpdate { + uu.mutation.SetLastLogin(t) + return uu +} + +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (uu *UserUpdate) SetNillableLastLogin(t *time.Time) *UserUpdate { + if t != nil { + uu.SetLastLogin(*t) + } + return uu +} + +// ClearLastLogin clears the value of the "last_login" field. +func (uu *UserUpdate) ClearLastLogin() *UserUpdate { + uu.mutation.ClearLastLogin() + return uu +} + // SetStorage sets the "storage" field. func (uu *UserUpdate) SetStorage(i int64) *UserUpdate { uu.mutation.ResetStorage() @@ -624,6 +684,24 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) { if value, ok := uu.mutation.Status(); ok { _spec.SetField(user.FieldStatus, field.TypeEnum, value) } + if value, ok := uu.mutation.BanExpires(); ok { + _spec.SetField(user.FieldBanExpires, field.TypeTime, value) + } + if uu.mutation.BanExpiresCleared() { + _spec.ClearField(user.FieldBanExpires, field.TypeTime) + } + if value, ok := uu.mutation.BanReason(); ok { + _spec.SetField(user.FieldBanReason, field.TypeString, value) + } + if uu.mutation.BanReasonCleared() { + _spec.ClearField(user.FieldBanReason, field.TypeString) + } + if value, ok := uu.mutation.LastLogin(); ok { + _spec.SetField(user.FieldLastLogin, field.TypeTime, value) + } + if uu.mutation.LastLoginCleared() { + _spec.ClearField(user.FieldLastLogin, field.TypeTime) + } if value, ok := uu.mutation.Storage(); ok { _spec.SetField(user.FieldStorage, field.TypeInt64, value) } @@ -1145,6 +1223,66 @@ func (uuo *UserUpdateOne) SetNillableStatus(u *user.Status) *UserUpdateOne { return uuo } +// SetBanExpires sets the "ban_expires" field. +func (uuo *UserUpdateOne) SetBanExpires(t time.Time) *UserUpdateOne { + uuo.mutation.SetBanExpires(t) + return uuo +} + +// SetNillableBanExpires sets the "ban_expires" field if the given value is not nil. +func (uuo *UserUpdateOne) SetNillableBanExpires(t *time.Time) *UserUpdateOne { + if t != nil { + uuo.SetBanExpires(*t) + } + return uuo +} + +// ClearBanExpires clears the value of the "ban_expires" field. +func (uuo *UserUpdateOne) ClearBanExpires() *UserUpdateOne { + uuo.mutation.ClearBanExpires() + return uuo +} + +// SetBanReason sets the "ban_reason" field. +func (uuo *UserUpdateOne) SetBanReason(s string) *UserUpdateOne { + uuo.mutation.SetBanReason(s) + return uuo +} + +// SetNillableBanReason sets the "ban_reason" field if the given value is not nil. +func (uuo *UserUpdateOne) SetNillableBanReason(s *string) *UserUpdateOne { + if s != nil { + uuo.SetBanReason(*s) + } + return uuo +} + +// ClearBanReason clears the value of the "ban_reason" field. +func (uuo *UserUpdateOne) ClearBanReason() *UserUpdateOne { + uuo.mutation.ClearBanReason() + return uuo +} + +// SetLastLogin sets the "last_login" field. +func (uuo *UserUpdateOne) SetLastLogin(t time.Time) *UserUpdateOne { + uuo.mutation.SetLastLogin(t) + return uuo +} + +// SetNillableLastLogin sets the "last_login" field if the given value is not nil. +func (uuo *UserUpdateOne) SetNillableLastLogin(t *time.Time) *UserUpdateOne { + if t != nil { + uuo.SetLastLogin(*t) + } + return uuo +} + +// ClearLastLogin clears the value of the "last_login" field. +func (uuo *UserUpdateOne) ClearLastLogin() *UserUpdateOne { + uuo.mutation.ClearLastLogin() + return uuo +} + // SetStorage sets the "storage" field. func (uuo *UserUpdateOne) SetStorage(i int64) *UserUpdateOne { uuo.mutation.ResetStorage() @@ -1673,6 +1811,24 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) if value, ok := uuo.mutation.Status(); ok { _spec.SetField(user.FieldStatus, field.TypeEnum, value) } + if value, ok := uuo.mutation.BanExpires(); ok { + _spec.SetField(user.FieldBanExpires, field.TypeTime, value) + } + if uuo.mutation.BanExpiresCleared() { + _spec.ClearField(user.FieldBanExpires, field.TypeTime) + } + if value, ok := uuo.mutation.BanReason(); ok { + _spec.SetField(user.FieldBanReason, field.TypeString, value) + } + if uuo.mutation.BanReasonCleared() { + _spec.ClearField(user.FieldBanReason, field.TypeString) + } + if value, ok := uuo.mutation.LastLogin(); ok { + _spec.SetField(user.FieldLastLogin, field.TypeTime, value) + } + if uuo.mutation.LastLoginCleared() { + _spec.ClearField(user.FieldLastLogin, field.TypeTime) + } if value, ok := uuo.mutation.Storage(); ok { _spec.SetField(user.FieldStorage, field.TypeInt64, value) } diff --git a/frontend/package.json b/frontend/package.json index 79bff799..ad8fbb9f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,6 +82,7 @@ "redux": "^5.0.0", "streamsaver": "^2.0.6", "styled-components": "^6.1.11", + "three": "^0.186.0", "timeago-react": "^3.0.6" }, "devDependencies": { @@ -90,6 +91,7 @@ "@types/node": "^20.10.5", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/three": "^0.186.0", "@types/webappsec-credential-management": "^0.6.9", "@types/wicg-file-system-access": "^2023.10.5", "@typescript-eslint/eslint-plugin": "^6.14.0", diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index e70152db..144447ff 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -42,6 +42,8 @@ "signUpSuccess": "Sign up successful", "activateSuccess": "Sign up completed", "accountActivated": "Your account has been successfully activated.", + "emailChangedTitle": "Email changed", + "emailChanged": "Your email address has been changed successfully.", "title": "Sign in to {{title}}", "sinUpTitle": "Sign up to {{title}}", "activateTitle": "Activate your account", @@ -230,10 +232,13 @@ "color": "Color", "fontSize": "Font size", "disableSubtitle": "Disable subtitle", + "loop": "Loop", "noSubtitle": "No ASS/SRT/VTT subtitle files found under current folder.", "subtitleStyles": "Subtitle styles", "subtitles": "Subtitles", "markdownEditor": "Markdown Editor", + "modelViewer": "3D Model Viewer", + "modelLoadFailed": "Failed to load 3D model", "saveSuccess": "Saved successfully at {{time}}", "drawioLng": "en", "charset": "Charset", @@ -431,6 +436,9 @@ "serverBatchDownload": "Server-side archiving", "serverBatchDownloadDescription": "Archive by the server to a Zip file and sent to the client for download on-the-fly, share link shortcut is not supported.", "selectArchiveMethod": "Select archive method", + "selectDownloadRoute": "Select download route", + "downloadRouteDirect": "Direct", + "downloadRouteDirectDescription": "Download directly from this site", "batchDownloadStarted": "Batch download has started, please do not close this tab...", "downloadProgress": "{{completed}} / {{total}} files downloaded ({{percent}}%)", "downloadComplete": "Download complete!", @@ -468,7 +476,9 @@ "noEncryption": "Not encrypted", "ftsStatus": "Full-text index", "ftsIndexed": "Indexed", - "ftsNotIndexed": "Not indexed" + "ftsNotIndexed": "Not indexed", + "lineEnding": "Line ending", + "downloadAll": "Download all" }, "modals": { "includePasswordInShareLink": "Include password in share link", @@ -486,6 +496,10 @@ "saveAs": "Save as", "versionConflict": "Version conflict", "discardUnsavedConfirm": "You have unsaved changes. Still discard?", + "nameConflict": "Name conflict", + "conflictOverwriteDes": "Replace the existing items in the destination.", + "conflictSkipDes": "Keep the existing items; conflicting items will not be moved or copied.", + "skip": "Skip", "overwrite": "Overwrite", "editShareLink": "Edit share link", "clearPermissions": "Clear permission settings", @@ -554,6 +568,14 @@ "remoteDownloadURL": "Download target URL", "remoteDownloadURLDescription": "Paste the download URL, one URL per line", "remoteDownloadDst": "Download to", + "remoteDownloadFileName": "File name (optional)", + "remoteDownloadFileNameDescription": "Custom output file name", + "remoteDownloadHttpUser": "HTTP username (optional)", + "remoteDownloadHttpPassword": "HTTP password (optional)", + "remoteDownloadHeaders": "Request headers (optional)", + "remoteDownloadProvider": "Downloader", + "remoteDownloadProviderAuto": "Auto (any node)", + "remoteDownloadHeadersDescription": "One header per line, e.g. Cookie: sid=...", "processNode": "Target node", "remoteDownloadNodeAuto": "Auto dispatch", "createTask": "Create task", @@ -629,6 +651,8 @@ "uploadOnlyDes": "Visitors can upload files but cannot see or download existing content in this shared folder.", "previewOnly": "Preview only", "previewOnlyDes": "Visitors can preview files inline but cannot download them.", + "shareNote": "Note", + "shareNoteDes": "A private note to identify this share. Only visible to you in My Shares.", "showReadme": "Show README file", "showReadmeDes": "If selected, the <0>README.md file (case-sensitive) in the directory will be automatically displayed for visitors.", "viewSetting": "View setting", @@ -657,7 +681,7 @@ "preparing": "Preparing...", "processing": "Processing...", "progressDescription": "{{uploaded}} uploaded, {{total}} total - {{percentage}}%", - "progressDescriptionFull": "{{uploaded}} uploaded, {{total}} total - {{percentage}}% ({{speed}})", + "progressDescriptionFull": "{{uploaded}} uploaded, {{total}} total - {{percentage}}% ({{speed}}){{eta}}", "progressDescriptionPlaceHolder": " - uploaded", "uploaded": "Uploaded", "rootFolder": "Root folder", @@ -716,7 +740,8 @@ "upyunUploadFailed": "Upload failed: {{msg}}", "parseResponseError": "Unable to parse response: {{msg}} ({{content}})", "concurrentTaskNumber": "Concurrent task limit", - "dropFileHere": "Drop file to upload" + "dropFileHere": "Drop file to upload", + "etaSuffix": ", ETA {{eta}}" }, "share": { "statistics": "Statistics", @@ -815,6 +840,9 @@ "syncViewOff": "Do not sync", "folderClickAction": "Folder click action", "folderClickActionDes": "Choose what happens when you click a folder. \"Open\" navigates into the folder, \"Select\" selects it (double-click to open).", + "preferredViewers": "Default apps", + "preferredViewersDes": "Apps chosen with \"Always\" in the open-with dialog. They apply on all your devices; remove one to be asked again.", + "preferredViewersEmpty": "No default apps saved yet. Pick \"Always\" in the open-with dialog on a file.", "folderClickOpen": "Open", "folderClickSelect": "Select", "noAuthenticator": "Add a passkey to sign in using fingerprint, face or USB key.", @@ -827,6 +855,8 @@ "enableVersionRetention": "Enable version retention", "enableVersionRetentionDes": "If enabled, historical versions of files that meet the conditions will be retained.", "versionRetention": "Version retention", + "trashRetention": "Trash retention (days)", + "trashRetentionDes": "Days a file stays in the trash before automatic deletion. 0 inherits the group default. Applies to files moved to trash after the change.", "languageDes": "Select the display language and preferred email language.", "timezoneDes": "Set the display timezone, default is system timezone", "externalApps": "External Apps", @@ -834,6 +864,11 @@ "revokeOAuthGrant": "Revoke Authorization", "revokeOAuthGrantConfirm": "Are you sure you want to revoke this app's authorization? Once revoked, this app will no longer be able to access your account.", "nickNameDes": "This is your public display name. It can be your real name or a pseudonym.", + "changeEmail": "Change", + "changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.", + "newEmail": "New email address", + "changeEmailPasswordDes": "Enter your current password to confirm.", + "emailChangeSent": "Confirmation email sent to the new address.", "cropAvatar": "Crop avatar", "preference": "Preference", "accountCreatedAt": "Created at <0>", @@ -940,6 +975,8 @@ "disable2FA": "Disable 2FA authentication", "2faDescription": "Please use any 2FA mobile app or password management software that supports 2FA to scan the QR code to add this site. After scanning, please fill in the 6-digit verification code given by the 2FA app to enable 2FA.", "inputCurrent2FACode": "Enter current 2FA verification code.", + "copySecret": "Can't scan? Click to copy the setup key", + "secretCopied": "Setup key copied", "timeZoneCode": "IANA timezone code", "authenticatorRemoved": "Authenticator removed.", "authenticatorAdded": "Authenticator added.", @@ -1003,7 +1040,13 @@ "relocateFileTo": "Relocate storage policy to {{policy}} for <0>{{more}}", "extractFileTo": "Extract <0>{{more}} to <1>", "createArchiveTo": "Create archive file to <1> for <0>{{more}}", - "importFileTo": "Import files from {{policy}} to <0>" + "importFileTo": "Import files from {{policy}} to <0>", + "shareSiteDefault": "Site default", + "shareSiteDefaultDes": "Follow the site-wide default configured by the administrator.", + "shareDefaultPrivate": "Default share privacy", + "shareDefaultPrivateDes": "New shares default to private (password protected).", + "sharePrivateOn": "Private", + "sharePrivateOff": "Public" }, "vas": { "points": "Points", diff --git a/frontend/public/locales/en-US/common.json b/frontend/public/locales/en-US/common.json index 3d12da6b..b72bb7de 100644 --- a/frontend/public/locales/en-US/common.json +++ b/frontend/public/locales/en-US/common.json @@ -54,7 +54,7 @@ "40014": "Exceed batch size limit of getting source link.", "40015": "Exceed aria2 batch size limit.", "40016": "Path not found.", - "40017": "This account has been blocked.", + "40017": "This account has been blocked. {{message}}", "40018": "This account is not activated.", "40019": "This feature is not enabled.", "40020": "Invalid or expired credential.", @@ -102,5 +102,6 @@ "50005": "Internal error.", "50010": "Desired node is unavailable.", "50011": "Failed to query file metadata." - } + }, + "delete": "Delete" } diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 5d8aa001..07eca696 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -170,6 +170,9 @@ "customNavItems": "Custom sidebar items", "customNavItemsDes": "You can add custom items to the sidebar, and users will be redirected to the corresponding link when clicked.", "navItemUrl": "Link", + "navItemVisibility": "Visibility", + "navScopePublic": "Everyone", + "navScopeUser": "Logged-in users", "iconifyNamePlaceholder": "Iconify icon identifier, e.g. fluent:home-24-regular", "imageUrl": "Image URL", "iconifyName": "Iconify icon name", @@ -837,8 +840,20 @@ "ssoIssuerDes": "Base URL of the OIDC issuer, e.g. <0>https://keycloak.example.com/realms/master. The provider metadata is fetched from <1>.well-known/openid-configuration under this URL.", "ssoRegisterEnabled": "Allow automatic registration", "ssoRegisterEnabledDes": "Automatically create a local account when a user signs in via SSO for the first time. The sign-up email filter below also applies.", + "downloadCdnRoutes": "Download CDN routes", + "downloadCdnRoutesDes": "Alternative download endpoints offered to users, one per line in name=url format (e.g. Line 1=https://cdn1.example.com). Routes must proxy the full request path and query back to this site so signed URLs stay valid; CORS headers are required for in-browser downloads.", + "ssoAutoRedirect": "Auto redirect to SSO", + "ssoAutoRedirectDes": "Skip the login form and send visitors straight to the identity provider. Append <0>?nosso=1 to the login URL to reach the password form (e.g. for admin recovery).", "ssoCallbackUrl": "Callback URL", - "ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}" + "ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}", + "themeVisible": "Visible", + "shareDefaultPrivate": "Private share by default", + "shareDefaultPrivateDes": "New shares default to private (password protected). Users can override this in their personal settings.", + "defaultShareLinksInProfile": "Default profile share visibility", + "defaultShareLinksInProfileDes": "Controls which shares appear on a user's public profile page when the user has not chosen one themselves.", + "profileSharePublicOnly": "Shares without password only", + "profileShareAll": "All shares", + "profileShareHide": "Hide all shares" }, "giftCodes": { "giftCodesSettings": "Gift Codes", @@ -977,6 +992,12 @@ "addXStoragePolicy": "Add {{type}} storage policy", "loadSummary": "Load summary", "policySummary": "{{count}} file Blobs ({{size}})", + "blobAudit": "Audit blobs: {{name}}", + "blobAuditDes": "Scans the storage backend and lists physical objects not referenced by any file record. Useful for finding ghost blobs left by failed transfers.", + "blobAuditDelete": "Delete orphaned blobs", + "blobAuditDeleteWarning": "Orphaned objects will be permanently removed from the storage backend. Files still being uploaded may appear as orphans — only enable this when you are sure no uploads are in progress.", + "blobAuditStart": "Start audit", + "blobAuditSubmitted": "Blob audit task queued.", "sharp": "#", "name": "Name", "type": "Type", @@ -1019,6 +1040,8 @@ "blacklist": "Deny", "fileNameRegex": "File name regex rules", "fileNameRegexDes": "Regular expression to match file names, leave blank for no restriction.", + "allowNativeName": "Allow native file name characters", + "allowNativeNameDes": "Permit characters only illegal on Windows filesystems (: * ? \" < > |) in file names. May break clients mounting on Windows. Path separators remain forbidden.", "chunkSizeDes": "Specify the chunk size for chunked uploads. A value of 0 means no chunked uploads are used, but the maximum upload size may be limited by the web server.", "chunkSizeDesSuffix": "{{prefix}} With chunked upload, the files uploaded by users will be sliced into chunks and uploaded to the storage side one by one. After the upload is interrupted, users can choose to continue uploading from the last uploaded chunk.", "chunkSize": "Chunk size", @@ -1163,7 +1186,9 @@ "fileEncryption": "File encryption", "enableFileEncryption": "Enable file encryption", "enableFileEncryptionDes": "When enabled, file blobs will be stored encrypted. Changing this setting only affects newly added files.", - "encryptedFile": "Encrypted files" + "encryptedFile": "Encrypted files", + "status": "Status", + "statusDes": "Suspended policies keep serving existing files but reject new uploads." }, "node": { "slave": "slave", @@ -1254,6 +1279,20 @@ "initialStorageQuotaDes": "Max storage can used by single user under this group.", "isAdmin": "Admin group", "isAdminDes": "When enabled, users under this group will have admin permissions.", + "loginIPWhitelist": "Login IP whitelist", + "loginIPWhitelistDes": "Restrict sign-in to these IPs or CIDR ranges, one per line. Leave empty to allow all.", + "delegatedAdmin": "Delegated admin sections", + "delegatedAdminDes": "Grant access to selected dashboard sections only. Users in this group can open the admin panel, but only the enabled sections work — all other admin API calls are rejected.", + "sectionUsers": "Users", + "sectionGroups": "Groups", + "sectionFiles": "Files & blobs", + "sectionShares": "Shares", + "sectionStorage": "Storage policies & nodes", + "sectionQueue": "Task queue", + "sectionSettings": "Site settings & OAuth", + "sectionPayment": "Payments", + "sectionEvents": "Events", + "sectionReports": "Abuse reports", "share": "Share", "allowCreateShareLink": "Create share link", "allowCreateShareLinkDes": "If disabled, users cannot create sharing links.", @@ -1264,6 +1303,8 @@ "allowWabDAVDes": "If disabled, users cannot connect to the storage via the WebDAV protocol", "allowWabDAVProxy": "WebDAV Proxy", "allowWabDAVProxyDes": "If enabled, users can configure the WebDAV to be proxied by Cloudreve when downloading files.", + "wabDAVReadOnly": "WebDAV read-only", + "wabDAVReadOnlyDes": "If enabled, WebDAV mounts for users in this group become read-only — uploads, deletes, moves and other write methods are rejected.", "compressTask": "Compression/Decompression tasks", "compressTaskDes": "If enabled, users can do compression/decompression for files online.", "compressSize": "Maximum file size to be compressed", @@ -1369,7 +1410,17 @@ "deleteXUsers": "Delete {{num}} users", "confirmBatchDelete": "Are you sure you want to delete {{num}} users?", "calibrateStorage": "Calibrate storage", - "calibrateStorageSuccess": "Storage calibrated successfully." + "calibrateStorageSuccess": "Storage calibrated successfully.", + "banExpires": "Ban expires", + "banExpiresDes": "Optional. The ban lifts automatically after this time; empty means permanent.", + "banReason": "Ban reason", + "banReasonDes": "Optional. Shown to the user when a banned login is rejected.", + "lastLogin": "Last login", + "uids": "User IDs", + "uidsPlaceholder": "Comma-separated IDs, e.g. 2,3,5", + "editXUsers": "Edit {{num}} users", + "batchEditXUsers": "Batch edit {{num}} users", + "noChange": "No change" }, "file": { "deleteXFiles": "Delete {{num}} files", @@ -1385,6 +1436,11 @@ "shareLink": "Share links", "shareLinkNum": "{{num}} (<0>View)", "blobType": "Type", + "referenceCount": "Reference count", + "awaitingRecycle": "Awaiting recycle", + "refCountGt": "Greater than", + "refCountLt": "Less than", + "refCountEq": "Equal to", "noEntities": "No Blobs", "blobs": "Blobs", "creator": "Creator", @@ -1440,7 +1496,10 @@ "otherConditions": "Other conditions", "shareLinkExisted": "Has share link", "directLinkExisted": "Has direct link", - "isUploading": "Is uploading" + "isUploading": "Is uploading", + "deletedState": "Deleted state", + "liveOnly": "Live files only", + "trashOnly": "Trash only" }, "entity": { "refenenceCount": "Reference count", @@ -1554,6 +1613,7 @@ "fullTextCopy": "Copy full-text index for file <0>#{{fileID}}", "fullTextChangeOwner": "Change index owner for file <0>#{{fileID}}", "fullTextDelete": "Delete full-text index for {{count}} files", + "blobAudit": "Audit blobs on storage policy #{{policyID}} ({{count}} orphans)", "type": "Type", "node": "Distributed node", "createdBy": "Created by", @@ -1744,7 +1804,9 @@ "customPaymentEndpointDes": "URL to be requested when creating a payment order.", "appFeedback": "Feedback URL", "appForum": "User forum URL", - "appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item. This setting will take effect only if VOL license is valid." + "appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item. This setting will take effect only if VOL license is valid.", + "subAddressChars": "Sub-address characters", + "subAddressCharsDes": "Characters treated as sub-address separators in the local part of the email (e.g. +-. for Gmail/iCloud-style aliases). Empty defaults to +." }, "pro": { "title": "Pro edition exclusive features", @@ -1776,4 +1838,4 @@ "shareID": "Shared ID", "reason": "Reason" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 1e8d13ab..1f0a4fc7 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -42,6 +42,8 @@ "signUpSuccess": "注册成功", "activateSuccess": "激活成功", "accountActivated": "您的账号已被成功激活", + "emailChangedTitle": "邮箱已更换", + "emailChanged": "您的邮箱地址已成功更换。", "title": "登录 {{title}}", "sinUpTitle": "注册 {{title}}", "activateTitle": "邮件激活", @@ -230,10 +232,13 @@ "color": "颜色", "fontSize": "字体大小", "disableSubtitle": "禁用字幕", + "loop": "循环播放", "noSubtitle": "没有在当前目录下找到 ASS/SRT/VTT 字幕文件。", "subtitleStyles": "字幕样式", "subtitles": "字幕", "markdownEditor": "Markdown 编辑器", + "modelViewer": "3D 模型查看器", + "modelLoadFailed": "3D 模型加载失败", "saveSuccess": "在 {{time}} 保存成功", "drawioLng": "zh", "charset": "编码", @@ -431,6 +436,9 @@ "serverBatchDownload": "服务端中转打包", "serverBatchDownloadDescription": "由服务端中转打包为 Zip 文件并实时发送到客户端下载,不支持分享快捷方式。", "selectArchiveMethod": "选择批量下载方式", + "selectDownloadRoute": "选择下载线路", + "downloadRouteDirect": "直连", + "downloadRouteDirectDescription": "直接从本站下载", "batchDownloadStarted": "打包下载已开始,请不要关闭此页面...", "downloadProgress": "已下载 {{completed}} / {{total}} 个文件 ({{percent}}%)", "downloadComplete": "下载完成!", @@ -468,7 +476,9 @@ "noEncryption": "未加密", "ftsStatus": "全文索引", "ftsIndexed": "已索引", - "ftsNotIndexed": "未索引" + "ftsNotIndexed": "未索引", + "lineEnding": "换行符", + "downloadAll": "全部下载" }, "modals": { "includePasswordInShareLink": "在链接中包含密码", @@ -486,6 +496,10 @@ "saveAs": "另存为", "versionConflict": "版本冲突", "discardUnsavedConfirm": "有未保存的更改,确定要关闭吗?", + "nameConflict": "名称冲突", + "conflictOverwriteDes": "替换目标位置中的同名项目。", + "conflictSkipDes": "保留目标位置的同名项目,冲突的项目不会被移动或复制。", + "skip": "跳过", "overwrite": "覆盖", "editShareLink": "编辑分享链接", "clearPermissions": "清除权限设置", @@ -554,6 +568,14 @@ "remoteDownloadURL": "下载链接", "remoteDownloadURLDescription": "输入文件下载地址,一行一个", "remoteDownloadDst": "下载至", + "remoteDownloadFileName": "文件名(可选)", + "remoteDownloadFileNameDescription": "自定义保存文件名", + "remoteDownloadHttpUser": "HTTP 用户名(可选)", + "remoteDownloadHttpPassword": "HTTP 密码(可选)", + "remoteDownloadHeaders": "请求头(可选)", + "remoteDownloadProvider": "下载器", + "remoteDownloadProviderAuto": "自动(任意节点)", + "remoteDownloadHeadersDescription": "每行一个请求头,例如 Cookie: sid=...", "processNode": "处理节点", "remoteDownloadNodeAuto": "自动分配", "createTask": "创建任务", @@ -629,6 +651,8 @@ "uploadOnlyDes": "访客可以上传文件,但无法查看或下载此共享文件夹中的现有内容。", "previewOnly": "仅预览", "previewOnlyDes": "访客可以在线预览文件,但不能下载。", + "shareNote": "备注", + "shareNoteDes": "用于识别此分享的私有备注,仅在“我的分享”中对你可见。", "showReadme": "显示 README 文件", "showReadmeDes": "勾选后,会自动为访问者展示目录下的 <0>README.md (区分大小写) 文件。", "viewSetting": "视图设置", @@ -657,7 +681,7 @@ "preparing": "准备中...", "processing": "处理中...", "progressDescription": "已上传 {{uploaded}},共 {{total}} - {{percentage}}%", - "progressDescriptionFull": "{{speed}} 已上传 {{uploaded}},共 {{total}} - {{percentage}}%", + "progressDescriptionFull": "{{speed}} 已上传 {{uploaded}},共 {{total}} - {{percentage}}%{{eta}}", "progressDescriptionPlaceHolder": "已上传 - ", "uploaded": "已上传", "rootFolder": "根目录", @@ -716,7 +740,8 @@ "upyunUploadFailed": "上传失败:{{msg}}", "parseResponseError": "无法解析响应:{{msg}} ({{content}})", "concurrentTaskNumber": "同时上传的任务数量", - "dropFileHere": "松开鼠标开始上传" + "dropFileHere": "松开鼠标开始上传", + "etaSuffix": ",预计剩余 {{eta}}" }, "share": { "statistics": "统计", @@ -815,6 +840,9 @@ "syncViewOff": "不同步", "folderClickAction": "文件夹点击操作", "folderClickActionDes": "选择点击文件夹时的行为。「打开」将进入文件夹,「选中」将选中文件夹(双击打开)。", + "preferredViewers": "默认打开方式", + "preferredViewersDes": "在「打开方式」对话框中选择「始终」后保存的应用,会在所有设备上生效。删除后下次打开时将重新询问。", + "preferredViewersEmpty": "尚未保存默认应用。在文件的「打开方式」对话框中选择「始终」即可添加。", "folderClickOpen": "打开", "folderClickSelect": "选中", "noAuthenticator": "添加通行密钥以使用人脸、指纹或 USB 密钥登录账号", @@ -827,6 +855,8 @@ "enableVersionRetention": "启用版本保留", "enableVersionRetentionDes": "启用后,对于符合条件的文件,系统会保留其历史版本", "versionRetention": "版本保留", + "trashRetention": "回收站保留时间(天)", + "trashRetentionDes": "文件移入回收站后自动删除前保留的天数。0 表示继承用户组默认值,仅影响修改后移入回收站的文件。", "languageDes": "设置应用展示语言和首选邮件语言", "timezoneDes": "设置展示时区,默认跟随系统时区", "externalApps": "外部应用", @@ -834,6 +864,11 @@ "revokeOAuthGrant": "撤销授权", "revokeOAuthGrantConfirm": "确定要撤销此应用的授权吗?撤销后,该应用将无法再访问你的账户。", "nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称", + "changeEmail": "更换", + "changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。", + "newEmail": "新邮箱地址", + "changeEmailPasswordDes": "请输入当前密码以确认操作。", + "emailChangeSent": "确认邮件已发送至新邮箱。", "cropAvatar": "裁剪头像", "preference": "偏好", "accountCreatedAt": "创建于 <0>", @@ -940,6 +975,8 @@ "disable2FA": "关闭二步验证", "2faDescription": "请使用任意二步验证 APP 或者支持二步验证的密码管理软件扫描二维码添加本站。扫描完成后请填写二步验证 APP 给出的 6 位验证码以开启二步验证。", "inputCurrent2FACode": "输入当前二步验证 APP 给出的 6 位验证码:", + "copySecret": "无法扫码?点击复制密钥", + "secretCopied": "已复制密钥", "timeZoneCode": "IANA 时区名称标识", "authenticatorRemoved": "凭证已删除", "authenticatorAdded": "验证器已添加", @@ -1003,7 +1040,13 @@ "relocateFileTo": "将 <0>{{more}} 的存储策略转移至 {{policy}}", "extractFileTo": "将 <0>{{more}} 解压缩至 <1>", "createArchiveTo": "将 <0>{{more}} 打包至 <1>", - "importFileTo": "将 {{policy}} 中的文件导入至 <0>" + "importFileTo": "将 {{policy}} 中的文件导入至 <0>", + "shareSiteDefault": "跟随站点默认", + "shareSiteDefaultDes": "使用管理员配置的站点级默认值。", + "shareDefaultPrivate": "默认分享隐私", + "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。", + "sharePrivateOn": "私密", + "sharePrivateOff": "公开" }, "vas": { "points": "积分", diff --git a/frontend/public/locales/zh-CN/common.json b/frontend/public/locales/zh-CN/common.json index 68a7ba40..3166c66b 100644 --- a/frontend/public/locales/zh-CN/common.json +++ b/frontend/public/locales/zh-CN/common.json @@ -54,7 +54,7 @@ "40014": "超出批量获取外链限制", "40015": "超出最大离线下载任务数量限制", "40016": "路径不存在", - "40017": "该账号已被封禁", + "40017": "该账户已被封禁。{{message}}", "40018": "该账号未激活", "40019": "此功能未启用", "40020": "凭证无效或过期", @@ -102,5 +102,7 @@ "50005": "内部错误 ({{message}})", "50010": "目标节点不可用", "50011": "文件元信息查询失败" - } + }, + "delete": "删除", + "40017": "该账号已被封禁。{{message}}" } diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index c0d0f2ba..45e78430 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -170,6 +170,9 @@ "customNavItems": "自定义侧边导航栏", "customNavItemsDes": "你可以在左侧导航栏中添加自定义的条目,用户点击后会跳转到对应的链接。", "navItemUrl": "链接", + "navItemVisibility": "可见范围", + "navScopePublic": "所有人", + "navScopeUser": "登录用户", "iconifyNamePlaceholder": "Iconify 图标标识,比如:fluent:home-24-regular", "imageUrl": "图像 URL", "iconifyName": "Iconify 图标名", @@ -837,8 +840,20 @@ "ssoIssuerDes": "OIDC Issuer 的基础 URL,例如 <0>https://keycloak.example.com/realms/master。将从该地址下的 <1>.well-known/openid-configuration 获取提供方元数据。", "ssoRegisterEnabled": "允许自动注册", "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", + "downloadCdnRoutes": "下载 CDN 线路", + "downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。", + "ssoAutoRedirect": "自动跳转至 SSO", + "ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1 可进入密码登录表单(例如管理员账户恢复)。", "ssoCallbackUrl": "回调地址", - "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}" + "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}", + "themeVisible": "可见", + "shareDefaultPrivate": "默认私密分享", + "shareDefaultPrivateDes": "新建分享默认启用私密分享(密码保护)。用户仍可在个人设置中覆盖此默认值。", + "defaultShareLinksInProfile": "默认个人主页分享可见性", + "defaultShareLinksInProfileDes": "当用户未自行选择时,控制其公开个人主页上显示哪些分享。", + "profileSharePublicOnly": "仅无密码分享", + "profileShareAll": "所有分享", + "profileShareHide": "隐藏所有分享" }, "giftCodes": { "giftCodesSettings": "礼品码", @@ -977,6 +992,12 @@ "addXStoragePolicy": "添加 {{type}} 存储策略", "loadSummary": "加载统计数据", "policySummary": "{{count}} 个文件 Blob ({{size}})", + "blobAudit": "Blob 审计:{{name}}", + "blobAuditDes": "扫描存储后端,列出没有任何文件记录引用的物理对象,用于清理传输失败遗留的幽灵 Blob。", + "blobAuditDelete": "删除孤儿 Blob", + "blobAuditDeleteWarning": "孤儿对象将被从存储后端永久删除。正在上传中的文件可能会被误判为孤儿——请仅在确认没有上传任务进行时启用。", + "blobAuditStart": "开始审计", + "blobAuditSubmitted": "Blob 审计任务已加入队列。", "sharp": "#", "name": "名称", "type": "类型", @@ -1019,6 +1040,8 @@ "blacklist": "拒绝", "fileNameRegex": "文件名正则规则", "fileNameRegexDes": "用于匹配文件名的正则表达式,留空表示无限制。", + "allowNativeName": "允许原生文件名字符", + "allowNativeNameDes": "允许文件名包含仅在 Windows 文件系统中非法的字符(: * ? \" < > |)。可能影响 Windows 客户端挂载使用。路径分隔符仍然禁止。", "chunkSizeDes": "请指定分片上传时的分片大小,填写为 0 表示不使用分片上传,但最大上传大小可能受限于 Web 服务器。", "chunkSizeDesSuffix": "{{prefix}}通过分片上传,用户上传的文件将会被切分成分片逐个上传到存储端,当上传中断后,用户可以选择从上次上传的分片后继续开始上传。", "chunkSize": "上传分片大小", @@ -1163,7 +1186,9 @@ "fileEncryption": "文件加密", "enableFileEncryption": "启用文件加密", "enableFileEncryptionDes": "开启后,文件 Blob 会被加密存储。更改此设置只对新增的文件有效。", - "encryptedFile": "加密的文件" + "encryptedFile": "加密的文件", + "status": "状态", + "statusDes": "已暂停的存储策略仍可读取已有文件,但会拒绝新的上传。" }, "node": { "slave": "从机", @@ -1254,6 +1279,20 @@ "initialStorageQuotaDes": "用户组下的用户初始可用最大容量。", "isAdmin": "管理员用户组", "isAdminDes": "开启后,用户组下的用户将拥有管理员权限。", + "loginIPWhitelist": "登录 IP 白名单", + "loginIPWhitelistDes": "仅允许以下 IP 或 CIDR 网段登录,每行一条。留空则不限制。", + "delegatedAdmin": "委派管理区块", + "delegatedAdminDes": "仅授予所选管理后台区块的访问权限。该组用户可打开管理面板,但仅启用的区块可用,其余管理 API 请求会被拒绝。", + "sectionUsers": "用户", + "sectionGroups": "用户组", + "sectionFiles": "文件与存储块", + "sectionShares": "分享", + "sectionStorage": "存储策略与节点", + "sectionQueue": "任务队列", + "sectionSettings": "站点设置与 OAuth", + "sectionPayment": "支付", + "sectionEvents": "事件", + "sectionReports": "滥用举报", "share": "分享", "allowCreateShareLink": "创建分享链接", "allowCreateShareLinkDes": "关闭后,用户无法创建分享链接。", @@ -1264,6 +1303,8 @@ "allowWabDAVDes": "关闭后,用户无法通过 WebDAV 协议连接至网盘。", "allowWabDAVProxy": "WebDAV 代理", "allowWabDAVProxyDes": "启用后,用户可以配置 WebDAV 下载经由 Cloudreve 中转。", + "wabDAVReadOnly": "WebDAV 只读", + "wabDAVReadOnlyDes": "启用后,该用户组的 WebDAV 挂载变为只读——上传、删除、移动及其他写操作将被拒绝。", "compressTask": "压缩/解压缩任务", "compressTaskDes": "开启后,用户可以在线压缩/解压缩文件。", "compressSize": "待压缩文件最大大小", @@ -1369,7 +1410,17 @@ "deleteXUsers": "删除 {{num}} 个用户", "confirmBatchDelete": "确认要删除 {{num}} 个用户?", "calibrateStorage": "校准存储空间", - "calibrateStorageSuccess": "存储空间校准成功" + "calibrateStorageSuccess": "存储空间校准成功", + "banExpires": "封禁截止时间", + "banExpiresDes": "可选。到期后自动解除封禁;留空表示永久封禁。", + "banReason": "封禁原因", + "banReasonDes": "可选。封禁用户登录被拒绝时向其展示。", + "lastLogin": "上次登录", + "uids": "用户 ID", + "uidsPlaceholder": "逗号分隔的 ID,如 2,3,5", + "editXUsers": "编辑 {{num}} 个用户", + "batchEditXUsers": "批量编辑 {{num}} 个用户", + "noChange": "不修改" }, "file": { "deleteXFiles": "删除 {{num}} 个文件", @@ -1385,6 +1436,11 @@ "shareLink": "分享链接", "shareLinkNum": "{{num}} 个 (<0>查看)", "blobType": "类型", + "referenceCount": "引用次数", + "awaitingRecycle": "等待回收", + "refCountGt": "大于", + "refCountLt": "小于", + "refCountEq": "等于", "noEntities": "没有 Blob", "blobs": "Blobs", "creator": "创建者", @@ -1440,7 +1496,10 @@ "otherConditions": "其他条件", "shareLinkExisted": "存在分享链接", "directLinkExisted": "存在中转直链", - "isUploading": "上传中" + "isUploading": "上传中", + "deletedState": "删除状态", + "liveOnly": "仅未删除", + "trashOnly": "仅回收站" }, "entity": { "refenenceCount": "引用次数", @@ -1553,6 +1612,7 @@ "fullTextCopy": "复制文件 <0>#{{fileID}} 的全文索引", "fullTextChangeOwner": "变更文件 <0>#{{fileID}} 的索引所有者", "fullTextDelete": "删除 {{count}} 个文件的全文索引", + "blobAudit": "审计存储策略 #{{policyID}} 的 Blob({{count}} 个孤儿对象)", "type": "类型", "node": "处理节点", "createdBy": "创建者", @@ -1743,7 +1803,9 @@ "customPaymentEndpointDes": "创建支付订单时请求的接口 URL。", "appFeedback": "反馈页面 URL", "appForum": "用户论坛 URL", - "appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。" + "appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。", + "subAddressChars": "子地址字符", + "subAddressCharsDes": "邮箱用户名部分中视为子地址分隔符的字符(如 +-.,覆盖 Gmail/iCloud 风格别名)。留空默认为 +。" }, "pro": { "title": "Pro 版本专属功能", @@ -1775,4 +1837,4 @@ "shareID": "分享 ID", "reason": "原因" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77f7dd27..16ad94bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -242,6 +242,7 @@ interface themeOptions { interface singleThemeOption { light: ThemeOptions; dark?: ThemeOptions; + hidden?: boolean; } const getPreferredTheme = ( @@ -250,16 +251,21 @@ const getPreferredTheme = ( preferredTheme?: string, defaultTheme?: string, ): ThemeOptions => { + // Hidden themes are not user-selectable; fall back to the default when the + // preferred (or the last remaining) theme is hidden. + const visible = Object.values(opts).filter((o) => !o.hidden); + const pool = visible.length > 0 ? visible : Object.values(opts); + let themeConfig = {} as singleThemeOption; - if (defaultTheme && opts[defaultTheme]) { + if (defaultTheme && opts[defaultTheme] && !opts[defaultTheme].hidden) { themeConfig = opts[defaultTheme]; } - if (preferredTheme && opts[preferredTheme]) { + if (preferredTheme && opts[preferredTheme] && !opts[preferredTheme].hidden) { themeConfig = opts[preferredTheme]; } if (!themeConfig?.light) { - themeConfig = Object.values(opts)[0]; + themeConfig = pool[0]; } if (mode === "dark" && themeConfig.dark) { diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 3332518a..8ab5f525 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -106,6 +106,7 @@ import { DownloadWorkflowService, ImportWorkflowService, ListTaskService, + BlobAuditWorkflowService, RebuildFTSIndexWorkflowService, SetDownloadFilesService, TaskListResponse, @@ -468,12 +469,23 @@ export function sendMoveFile(req: MoveFileService): ThunkResponse { { ...defaultOpts, skipBatchError: req.uris.length == 1, + // Leave conflict failures silent: the caller prompts for + // overwrite/skip first (#3159). + bypassSnackbar: isNameConflictBatchError, }, ), ); }; } +export function isNameConflictBatchError(e: Error): boolean { + return ( + e instanceof AppError && + e.code == Code.BatchOperationNotFullyCompleted && + Object.values(e.aggregatedError ?? {}).some((r) => r.code == Code.ObjectExist) + ); +} + export function sendRestoreFile(req: DeleteFileService): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( @@ -576,6 +588,23 @@ export function sendDeleteShare(id: string): ThunkResponse { }; } +export function sendDeleteShares(ids: string[]): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/share", + { + method: "DELETE", + data: { ids }, + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + export function getShareInfo( id: string, password?: string, @@ -891,6 +920,54 @@ export function getTasks(req: ListTaskService): ThunkResponse }; } +export function sendCancelTask(id: string): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/workflow/${id}/cancel`, + { + method: "POST", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + +export function sendDeleteTask(id: string): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/workflow/${id}`, + { + method: "DELETE", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + +export function sendRetryTask(id: string): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/workflow/${id}/retry`, + { + method: "POST", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + export function getTasksPhaseProgress(id: string): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( @@ -1275,6 +1352,40 @@ export function sendEmailActivate(id: string, sign: string): ThunkResponse }; } +export function sendRequestEmailChange(newEmail: string, password: string): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/user/setting/email`, + { + method: "POST", + data: { new_email: newEmail, password }, + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + +export function sendEmailChangeActivate(id: string, sign: string): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/user/activate_email/${id}?sign=${encodeURIComponent(sign)}`, + { + method: "GET", + }, + { + ...defaultOpts, + noCredential: true, + }, + ), + ); + }; +} + export function sendResetEmail(req: SendResetEmailService): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( @@ -1786,6 +1897,26 @@ export function batchDeleteUser(args: BatchIDService): ThunkResponse { }; } +export interface BatchUserUpdateService { + ids: number[]; + status?: "active" | "inactive" | "manual_banned"; + group_id?: number; +} + +export function batchUpdateUser(args: BatchUserUpdateService): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + `/admin/user/batch/update`, + { method: "POST", data: args }, + { + ...defaultOpts, + }, + ), + ); + }; +} + export function getFlattenFileList(args: AdminListService): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( @@ -2191,6 +2322,23 @@ export function sendFullTextSearch(query: string, offset?: number): ThunkRespons }; } +export function sendBlobAuditTask(req: BlobAuditWorkflowService): ThunkResponse { + return async (dispatch, _getState) => { + return await dispatch( + send( + "/workflow/blobAudit", + { + data: req, + method: "POST", + }, + { + ...defaultOpts, + }, + ), + ); + }; +} + export function sendRebuildFTSIndex(req: RebuildFTSIndexWorkflowService): ThunkResponse { return async (dispatch, _getState) => { return await dispatch( diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index e90303e7..0cb8cabe 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -61,6 +61,7 @@ export interface GroupSetting { max_walked_files?: number; trash_retention?: number; redirected_source?: boolean; + login_ip_whitelist?: string[]; } export interface AdminListGroupResponse { @@ -123,9 +124,15 @@ export interface CommonMixin { deleted_at?: string; } +export enum PolicyStatus { + active = "active", + suspended = "suspended", +} + export interface StoragePolicy extends CommonMixin { name: string; type: PolicyType; + status?: PolicyStatus; server?: string; bucket_name?: string; is_private?: boolean; @@ -217,6 +224,7 @@ export interface PolicySetting { is_file_type_deny_list?: boolean; file_regexp?: string; is_name_regexp_deny_list?: boolean; + allow_native_name?: boolean; od_redirect?: string; custom_proxy?: boolean; proxy_server?: string; @@ -256,6 +264,9 @@ export interface User extends CommonMixin { avatar?: string; credit?: number; group_expires?: string; + ban_expires?: string; + ban_reason?: string; + last_login?: string; notify_date?: string; group_users?: number; previous_group?: number; diff --git a/frontend/src/api/explorer.ts b/frontend/src/api/explorer.ts index 5bf62ebc..bf75c67f 100644 --- a/frontend/src/api/explorer.ts +++ b/frontend/src/api/explorer.ts @@ -94,6 +94,7 @@ export interface Share { allow_edit?: boolean; preview_only?: boolean; upload_only?: boolean; + note?: string; } export enum PolicyType { @@ -264,6 +265,7 @@ export interface UnlockFileService { export interface RenameFileService { uri: string; new_name: string; + expect_id?: string; } export const NavigatorCapability = { @@ -292,6 +294,8 @@ export interface PinFileService { export interface MoveFileService extends MultipleUriService { dst: string; copy?: boolean; + expect_ids?: string[]; + on_conflict?: "skip" | "overwrite"; } export interface MetadataPatch { @@ -316,6 +320,7 @@ export interface ShareCreateService { allow_edit?: boolean; preview_only?: boolean; upload_only?: boolean; + note?: string; } export interface CreateFileService { diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index bbb91d91..86a7afc9 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -137,6 +137,7 @@ export const Code = { IncorrectPassword: 40069, LockConflict: 40073, StaleVersion: 40076, + ObjectExist: 40004, BatchOperationNotFullyCompleted: 40081, DomainNotLicensed: 40087, AnonymouseAccessDenied: 40088, diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index 0b1497e4..70ce8214 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -30,6 +30,8 @@ export interface SiteConfig { register_enabled?: boolean; sso_enabled?: boolean; sso_display_name?: string; + sso_auto_redirect?: boolean; + download_cdn_routes?: { name: string; url: string }[]; logo?: string; logo_light?: string; tos_url?: string; @@ -54,6 +56,9 @@ export interface SiteConfig { thumb_exts?: string[]; show_encryption_status?: boolean; full_text_search?: boolean; + remote_download_providers?: string[]; + share_default_private?: boolean; + default_share_links_in_profile?: string; } export interface CaptchaResponse { @@ -65,6 +70,7 @@ export interface CustomNavItem { name: string; url: string; icon: string; + scope?: "public" | "user"; } export interface CustomHTML { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index ab337f45..8dfc65e3 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,3 +1,5 @@ +import Boolset from "../util/boolset"; + /** * UserLoginService 管理用户登录的服务 */ @@ -24,6 +26,8 @@ export interface User { language?: string; disable_view_sync?: boolean; share_links_in_profile?: ShareLinksInProfileLevel; + share_default_private?: boolean; + preferred_viewers?: Record; } export interface Group { id: string; @@ -93,6 +97,40 @@ export const GroupPermission = { redirected_source: 11, advance_delete: 12, unique_direct_link: 17, + webdav_read_only: 18, + admin_users: 19, + admin_groups: 20, + admin_files: 21, + admin_shares: 22, + admin_storage: 23, + admin_queue: 24, + admin_settings: 25, + admin_payment: 26, + admin_events: 27, + admin_reports: 28, +}; + +// Delegated admin section bits — is_admin implies all of them. +export const DelegatedAdminPermissions = [ + GroupPermission.admin_users, + GroupPermission.admin_groups, + GroupPermission.admin_files, + GroupPermission.admin_shares, + GroupPermission.admin_storage, + GroupPermission.admin_queue, + GroupPermission.admin_settings, + GroupPermission.admin_payment, + GroupPermission.admin_events, + GroupPermission.admin_reports, +]; + +// isAnyAdmin reports whether the permission set grants full or delegated admin +// access to at least one section. +export const isAnyAdmin = (permission: Boolset): boolean => { + return ( + permission.enabled(GroupPermission.is_admin) || + DelegatedAdminPermissions.some((p) => permission.enabled(p)) + ); }; export interface UserSettings { @@ -104,6 +142,9 @@ export interface UserSettings { passkeys?: Passkey[]; disable_view_sync: boolean; share_links_in_profile: ShareLinksInProfileLevel; + share_default_private?: boolean; + preferred_viewers?: Record; + trash_retention?: number; oauth_grants?: OAuthGrant[]; } @@ -128,6 +169,11 @@ export interface PatchUserSetting { two_fa_code?: string; disable_view_sync?: boolean; share_links_in_profile?: ShareLinksInProfileLevel; + // Tri-state: "true" / "false" / "" (inherit the site default). + share_default_private?: string; + preferred_viewers?: Record; + // Trash retention override in seconds; 0 inherits the group setting. + trash_retention?: number; } export interface PasskeyCredentialOption { @@ -205,7 +251,8 @@ export interface ResetPasswordService { } export enum ShareLinksInProfileLevel { - public_share_only = "", + site_default = "", + public_share_only = "public_share", all_share = "all_share", hide_share = "hide_share", } diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index 351cb925..5fd805d9 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -101,6 +101,11 @@ export interface DownloadWorkflowService { src?: string[]; src_file?: string; dst: string; + file_name?: string; + username?: string; + password?: string; + headers?: string[]; + provider?: string; } export interface ImportWorkflowService { @@ -138,6 +143,7 @@ export enum TaskType { full_text_change_owner = "full_text_change_owner", full_text_delete = "full_text_delete", full_text_rebuild = "full_text_rebuild", + blob_audit = "blob_audit", } export enum TaskStatus { @@ -171,3 +177,8 @@ export interface SetDownloadFilesService { export interface RebuildFTSIndexWorkflowService { filtered_storage_policy?: number[]; } + +export interface BlobAuditWorkflowService { + policy_id: number; + delete?: boolean; +} diff --git a/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx b/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx index cfadd437..0f9891ca 100644 --- a/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx +++ b/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx @@ -14,6 +14,8 @@ export interface EntityFilterPopoverProps extends PopoverProps { setOwner: (owner: string) => void; type?: EntityType; setType: (type?: EntityType) => void; + refCount: string; + setRefCount: (refCount: string) => void; clearFilters: () => void; } @@ -24,6 +26,8 @@ const EntityFilterPopover = ({ setOwner, type, setType, + refCount, + setRefCount, clearFilters, onClose, open, @@ -35,6 +39,8 @@ const EntityFilterPopover = ({ const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy); const [localOwner, setLocalOwner] = useState(owner); const [localType, setLocalType] = useState(type); + const [localRefCountOp, setLocalRefCountOp] = useState(""); + const [localRefCountValue, setLocalRefCountValue] = useState(""); // Initialize local state when popup opens useEffect(() => { @@ -42,6 +48,9 @@ const EntityFilterPopover = ({ setLocalStoragePolicy(storagePolicy); setLocalOwner(owner); setLocalType(type); + const [op, value] = refCount.split(":"); + setLocalRefCountOp(refCount ? op : ""); + setLocalRefCountValue(value ?? ""); } }, [open]); @@ -50,6 +59,9 @@ const EntityFilterPopover = ({ setStoragePolicy(localStoragePolicy); setOwner(localOwner); setType(localType); + setRefCount( + localRefCountOp == "" ? "" : localRefCountOp == "stale" ? "stale" : `${localRefCountOp}:${localRefCountValue || "0"}`, + ); onClose?.({}, "backdropClick"); }; @@ -58,6 +70,8 @@ const EntityFilterPopover = ({ setLocalStoragePolicy(""); setLocalOwner(""); setLocalType(undefined); + setLocalRefCountOp(""); + setLocalRefCountValue(""); clearFilters(); onClose?.({}, "backdropClick"); }; @@ -128,6 +142,43 @@ const EntityFilterPopover = ({ + + + setLocalRefCountOp(e.target.value)} + sx={{ minWidth: 140 }} + > + + {t("user.all")}} slotProps={{ primary: { variant: "body2" } }} /> + + + + + + + + + + + + + + + {localRefCountOp != "" && localRefCountOp != "stale" && ( + setLocalRefCountValue(e.target.value.replace(/\D/g, ""))} + placeholder="0" + size="small" + inputProps={{ inputMode: "numeric" }} + sx={{ width: 80 }} + /> + )} + + + { const { t } = useTranslation("dashboard"); @@ -58,6 +59,7 @@ const EntitySetting = () => { const [storagePolicy, setStoragePolicy] = useQueryState(StoragePolicyQuery, { defaultValue: "" }); const [user, setUser] = useQueryState(UserQuery, { defaultValue: "" }); const [type, setType] = useQueryState(TypeQuery, { defaultValue: "" }); + const [refCount, setRefCount] = useQueryState(RefCountQuery, { defaultValue: "" }); const [count, setCount] = useState(0); const [selected, setSelected] = useState([]); const filterPopupState = usePopupState({ @@ -79,11 +81,12 @@ const EntitySetting = () => { setStoragePolicy(""); setUser(""); setType(""); - }, [setStoragePolicy, setUser, setType]); + setRefCount(""); + }, [setStoragePolicy, setUser, setType, setRefCount]); useEffect(() => { fetchEntities(); - }, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type]); + }, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type, refCount]); const fetchEntities = () => { setLoading(true); @@ -109,6 +112,10 @@ const EntitySetting = () => { params.conditions!.entity_type = type; } + if (refCount) { + params.conditions!.entity_ref_count = refCount; + } + dispatch(getEntityList(params)) .then((res) => { setEntities(res.entities); @@ -202,6 +209,8 @@ const EntitySetting = () => { setOwner={setUser} type={type !== "" ? parseInt(type) : undefined} setType={(type) => setType(type !== undefined ? type.toString() : "")} + refCount={refCount} + setRefCount={setRefCount} clearFilters={clearFilters} /> diff --git a/frontend/src/component/Admin/File/FileFilterPopover.tsx b/frontend/src/component/Admin/File/FileFilterPopover.tsx index f932bbad..d4948720 100644 --- a/frontend/src/component/Admin/File/FileFilterPopover.tsx +++ b/frontend/src/component/Admin/File/FileFilterPopover.tsx @@ -1,7 +1,7 @@ -import { Box, Button, Checkbox, Popover, PopoverProps, Stack, styled } from "@mui/material"; +import { Box, Button, Checkbox, MenuItem, Popover, PopoverProps, Stack, styled } from "@mui/material"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { DenseFilledTextField, SmallFormControlLabel } from "../../Common/StyledComponents"; +import { DenseFilledTextField, DenseSelect, SmallFormControlLabel } from "../../Common/StyledComponents"; import SettingForm from "../../Pages/Setting/SettingForm"; import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput"; @@ -18,6 +18,8 @@ export interface FileFilterPopoverProps extends PopoverProps { setHasDirectLink: (hasDirectLink: boolean) => void; isUploading: boolean; setIsUploading: (isUploading: boolean) => void; + deleted: string; + setDeleted: (deleted: string) => void; clearFilters: () => void; } @@ -40,6 +42,8 @@ const FileFilterPopover = ({ setHasDirectLink, isUploading, setIsUploading, + deleted, + setDeleted, clearFilters, onClose, open, @@ -54,6 +58,7 @@ const FileFilterPopover = ({ const [localHasShareLink, setLocalHasShareLink] = useState(hasShareLink); const [localHasDirectLink, setLocalHasDirectLink] = useState(hasDirectLink); const [localIsUploading, setLocalIsUploading] = useState(isUploading); + const [localDeleted, setLocalDeleted] = useState(deleted); // Initialize local state when popup opens useEffect(() => { @@ -64,6 +69,7 @@ const FileFilterPopover = ({ setLocalHasShareLink(hasShareLink); setLocalHasDirectLink(hasDirectLink); setLocalIsUploading(isUploading); + setLocalDeleted(deleted); } }, [open]); @@ -75,6 +81,7 @@ const FileFilterPopover = ({ setHasShareLink(localHasShareLink); setHasDirectLink(localHasDirectLink); setIsUploading(localIsUploading); + setDeleted(localDeleted); onClose?.({}, "backdropClick"); }; @@ -86,6 +93,7 @@ const FileFilterPopover = ({ setLocalHasShareLink(false); setLocalHasDirectLink(false); setLocalIsUploading(false); + setLocalDeleted(""); clearFilters(); onClose?.({}, "backdropClick"); }; @@ -143,6 +151,19 @@ const FileFilterPopover = ({ /> + + setLocalDeleted(e.target.value as string)} + > + {t("user.all")} + {t("file.liveOnly")} + {t("file.trashOnly")} + + + { const { t } = useTranslation("dashboard"); @@ -68,6 +69,7 @@ const FileSetting = () => { const [hasDirectLink, setHasDirectLink] = useQueryState(HasDirectLinkQuery, { defaultValue: "" }); const [shared, setShared] = useQueryState(SharedQuery, { defaultValue: "" }); const [uploading, setUploading] = useQueryState(UploadingQuery, { defaultValue: "" }); + const [deleted, setDeleted] = useQueryState(DeletedQuery, { defaultValue: "" }); const [count, setCount] = useState(0); const [selected, setSelected] = useState([]); const [createNewOpen, setCreateNewOpen] = useState(false); @@ -93,11 +95,12 @@ const FileSetting = () => { setHasDirectLink(""); setShared(""); setUploading(""); - }, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading]); + setDeleted(""); + }, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading, setDeleted]); useEffect(() => { fetchFiles(); - }, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading]); + }, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading, deleted]); const fetchFiles = () => { setLoading(true); @@ -115,6 +118,7 @@ const FileSetting = () => { file_direct_link: hasDirectLink === "true" ? "true" : "", file_shared: shared === "true" ? "true" : "", file_metadata: uploading === "true" ? Metadata.upload_session_id : "", + file_deleted: deleted, }, }), ) @@ -183,8 +187,8 @@ const FileSetting = () => { }; const hasActiveFilters = useMemo(() => { - return !!(storagePolicy || owner || name || hasDirectLink || shared || uploading); - }, [storagePolicy, owner, name, hasDirectLink, shared, uploading]); + return !!(storagePolicy || owner || name || hasDirectLink || shared || uploading || deleted); + }, [storagePolicy, owner, name, hasDirectLink, shared, uploading, deleted]); const handleFileDialogOpen = (id: number) => { setFileDialogID(id); @@ -239,6 +243,8 @@ const FileSetting = () => { setHasShareLink={(value: boolean) => setShared(value ? "true" : "")} isUploading={uploading === "true"} setIsUploading={(value: boolean) => setUploading(value ? "true" : "")} + deleted={deleted} + setDeleted={setDeleted} /> }> diff --git a/frontend/src/component/Admin/FileSystem/ViewerSetting/FileViewerEditDialog.tsx b/frontend/src/component/Admin/FileSystem/ViewerSetting/FileViewerEditDialog.tsx index beab4f48..7d396591 100644 --- a/frontend/src/component/Admin/FileSystem/ViewerSetting/FileViewerEditDialog.tsx +++ b/frontend/src/component/Admin/FileSystem/ViewerSetting/FileViewerEditDialog.tsx @@ -170,7 +170,7 @@ function DraggableTemplateRow({ hover > - + {extList.map((ext) => ( {ext} diff --git a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx index 27be3323..8ef1a904 100644 --- a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx +++ b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx @@ -57,6 +57,30 @@ const BasicInfoSection = () => { [setGroup], ); + const onSectionChange = useCallback( + (bit: number) => (e: React.ChangeEvent) => { + setGroup((p: GroupEnt) => ({ + ...p, + permissions: new Boolset(p.permissions).set(bit, e.target.checked).toString(), + })); + }, + [setGroup], + ); + + const onWhitelistChange = useCallback( + (e: React.ChangeEvent) => { + const list = e.target.value + .split("\n") + .map((l) => l.trim()) + .filter((l) => l != ""); + setGroup((p: GroupEnt) => ({ + ...p, + settings: { ...p.settings, login_ip_whitelist: list.length > 0 ? list : undefined }, + })); + }, + [setGroup], + ); + return ( @@ -105,6 +129,45 @@ const BasicInfoSection = () => { {t("group.isAdminDes")} + {!permission.enabled(GroupPermission.is_admin) && ( + + + {t("group.delegatedAdminDes")} + {( + [ + [GroupPermission.admin_users, "group.sectionUsers"], + [GroupPermission.admin_groups, "group.sectionGroups"], + [GroupPermission.admin_files, "group.sectionFiles"], + [GroupPermission.admin_shares, "group.sectionShares"], + [GroupPermission.admin_storage, "group.sectionStorage"], + [GroupPermission.admin_queue, "group.sectionQueue"], + [GroupPermission.admin_settings, "group.sectionSettings"], + [GroupPermission.admin_payment, "group.sectionPayment"], + [GroupPermission.admin_events, "group.sectionEvents"], + [GroupPermission.admin_reports, "group.sectionReports"], + ] as [number, string][] + ).map(([bit, label]) => ( + } + label={t(label)} + /> + ))} + + + )} + + + + {t("group.loginIPWhitelistDes")} + + )} diff --git a/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx b/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx index 4e3f8914..5c6664bc 100644 --- a/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx +++ b/frontend/src/component/Admin/Group/EditGroup/FileManagementSection.tsx @@ -63,6 +63,16 @@ const FileManagementSection = () => { [setGroup], ); + const onWabDAVReadOnlyChange = useCallback( + (e: React.ChangeEvent) => { + setGroup((p: GroupEnt) => ({ + ...p, + permissions: new Boolset(p.permissions).set(GroupPermission.webdav_read_only, e.target.checked).toString(), + })); + }, + [setGroup], + ); + const onAllowCompressTaskChange = useCallback( (e: React.ChangeEvent) => { setGroup((p: GroupEnt) => ({ @@ -186,6 +196,20 @@ const FileManagementSection = () => { {t("group.allowWabDAVProxyDes")} + + + + } + label={t("group.wabDAVReadOnly")} + /> + {t("group.wabDAVReadOnlyDes")} + + diff --git a/frontend/src/component/Admin/Settings/Appearance/CustomNavItems.tsx b/frontend/src/component/Admin/Settings/Appearance/CustomNavItems.tsx index 6aa863aa..bb146200 100644 --- a/frontend/src/component/Admin/Settings/Appearance/CustomNavItems.tsx +++ b/frontend/src/component/Admin/Settings/Appearance/CustomNavItems.tsx @@ -2,6 +2,7 @@ import { Icon } from "@iconify/react/dist/iconify.js"; import { Box, IconButton, + MenuItem, Table, TableBody, TableCell, @@ -18,6 +19,7 @@ import { useTranslation } from "react-i18next"; import { CustomNavItem } from "../../../../api/site"; import { DenseFilledTextField, + DenseSelect, NoWrapCell, NoWrapTableCell, SecondaryButton, @@ -49,6 +51,7 @@ interface DraggableNavItemRowProps { inputCache: { [key: number]: { [field: string]: string | undefined } }; onInputChange: (index: number, field: string, value: string) => void; onInputBlur: (index: number, field: keyof CustomNavItem) => void; + onFieldChange: (index: number, field: keyof CustomNavItem, value: string) => void; IconPreview: React.ComponentType<{ iconName: string }>; t: any; style?: React.CSSProperties; @@ -69,6 +72,7 @@ const DraggableNavItemRow = React.memo( inputCache, onInputChange, onInputBlur, + onFieldChange, IconPreview, t, style, @@ -144,6 +148,17 @@ const DraggableNavItemRow = React.memo( placeholder="https://example.com" /> + + onFieldChange(index, "scope", e.target.value as string)} + > + {t("settings.navScopePublic")} + {t("settings.navScopeUser")} + + onDelete(index)}> @@ -327,6 +342,7 @@ const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => { {t("settings.iconifyName")} {t("settings.displayName")} {t("settings.navItemUrl")} + {t("settings.navItemVisibility")} @@ -349,6 +365,7 @@ const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => { inputCache={inputCache} onInputChange={handleInputChange} onInputBlur={handleInputBlur} + onFieldChange={handleFieldChange} IconPreview={IconPreview} t={t} /> @@ -356,7 +373,7 @@ const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => { })} {items.length === 0 && ( - + {t("application:setting.listEmpty")} diff --git a/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx b/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx index e61c1665..a4911837 100644 --- a/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx +++ b/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx @@ -36,6 +36,7 @@ export interface ThemeOptionsProps { interface ThemeOption { id: string; config: { + hidden?: boolean; light: { palette: { primary: { @@ -234,6 +235,15 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T [onDefaultThemeChange], ); + const handleVisibilityChange = useCallback( + (id: string, visible: boolean) => { + const newOptions = { ...options }; + newOptions[id] = { ...newOptions[id], hidden: !visible }; + handleSave(newOptions); + }, + [options, handleSave], + ); + const optionsArray = useMemo(() => { return Object.entries(options).map(([id, config]) => ({ id, @@ -256,6 +266,7 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T {t("settings.defaultTheme")} + {t("settings.themeVisible")} {t("settings.primaryColor")} {t("settings.secondaryColor")} {t("settings.primaryColorDark")} @@ -273,6 +284,13 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T onChange={() => handleDefaultThemeChange(option.id)} /> + + handleVisibilityChange(option.id, e.target.checked)} + /> + { "siteName", "siteDes", "siteURL", + "download_cdn_routes", "siteScript", "pwa_small_icon", "pwa_medium_icon", @@ -198,9 +199,11 @@ const Settings = () => { "sso_client_secret", "sso_scopes", "sso_register_enabled", + "sso_auto_redirect", "email_filter_mode", "email_filter_list", "email_disable_subaddress", + "email_subaddress_chars", "siteURL", ]} > diff --git a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx index d28eefa0..90b248af 100644 --- a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx +++ b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx @@ -52,6 +52,19 @@ const SiteInformation = () => { setSettings({ siteURL: v })} /> + + + setSettings({ download_cdn_routes: e.target.value })} + value={values.download_cdn_routes} + multiline + rows={3} + placeholder={"Line 1=https://cdn1.example.com\nLine 2=https://cdn2.example.com"} + /> + {t("settings.downloadCdnRoutesDes")} + + { /> {t("settings.ssoRegisterEnabledDes")} + + + setSettings({ + sso_auto_redirect: e.target.checked ? "1" : "0", + }) + } + /> + } + label={{t("settings.ssoAutoRedirect")}} + /> + + ]} + /> + + diff --git a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx index fb177d9a..7013663e 100644 --- a/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx +++ b/frontend/src/component/Admin/Settings/UserSession/UserSession.tsx @@ -139,6 +139,49 @@ const UserSession = () => { + + + + setSettings({ + share_default_private: e.target.checked ? "1" : "0", + }) + } + /> + } + label={t("settings.shareDefaultPrivate")} + /> + {t("settings.shareDefaultPrivateDes")} + + + + + + setSettings({ + default_share_links_in_profile: e.target.value as string, + }) + } + > + {["profileSharePublicOnly", "profileShareAll", "profileShareHide"].map((v, i) => ( + + + {t(`settings.${v}`)} + + + ))} + + {t("settings.defaultShareLinksInProfileDes")} + + { + {isTrueVal(values.email_disable_subaddress) && ( + + + setSettings({ email_subaddress_chars: e.target.value })} + /> + + ]} /> + + + + )} diff --git a/frontend/src/component/Admin/StoragePolicy/BlobAuditDialog.tsx b/frontend/src/component/Admin/StoragePolicy/BlobAuditDialog.tsx new file mode 100644 index 00000000..a6c1bb21 --- /dev/null +++ b/frontend/src/component/Admin/StoragePolicy/BlobAuditDialog.tsx @@ -0,0 +1,84 @@ +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControlLabel, +} from "@mui/material"; +import { useSnackbar } from "notistack"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { sendBlobAuditTask } from "../../../api/api"; +import { StoragePolicy } from "../../../api/dashboard"; +import { useAppDispatch } from "../../../redux/hooks"; +import { DefaultCloseAction } from "../../Common/Snackbar/snackbar"; + +export interface BlobAuditDialogProps { + open: boolean; + onClose: () => void; + policy?: StoragePolicy; +} + +const BlobAuditDialog = ({ open, onClose, policy }: BlobAuditDialogProps) => { + const { t } = useTranslation("dashboard"); + const dispatch = useAppDispatch(); + const { enqueueSnackbar } = useSnackbar(); + const [deleteOrphans, setDeleteOrphans] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setDeleteOrphans(false); + } + }, [open]); + + const onSubmit = () => { + if (!policy?.id) { + return; + } + setLoading(true); + dispatch(sendBlobAuditTask({ policy_id: policy.id, delete: deleteOrphans })) + .then(() => { + enqueueSnackbar(t("policy.blobAuditSubmitted"), { variant: "success", action: DefaultCloseAction }); + onClose(); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + + {t("policy.blobAudit", { name: policy?.name ?? "" })} + + {t("policy.blobAuditDes")} + setDeleteOrphans(e.target.checked)} + /> + } + label={t("policy.blobAuditDelete")} + /> + {deleteOrphans && ( + + {t("policy.blobAuditDeleteWarning")} + + )} + + + + + + + ); +}; + +export default BlobAuditDialog; diff --git a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/BasicInfoSection.tsx b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/BasicInfoSection.tsx index 33675fed..2fb3aed0 100644 --- a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/BasicInfoSection.tsx +++ b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/BasicInfoSection.tsx @@ -14,7 +14,7 @@ import { useCallback, useContext, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Link as RouterLink } from "react-router-dom"; import { createStoragePolicyCors, getOneDriveDriverRoot } from "../../../../../api/api"; -import { StoragePolicy } from "../../../../../api/dashboard"; +import { PolicyStatus, StoragePolicy } from "../../../../../api/dashboard"; import { PolicyType } from "../../../../../api/explorer"; import { useAppDispatch } from "../../../../../redux/hooks"; import { DefaultCloseAction } from "../../../../Common/Snackbar/snackbar"; @@ -291,6 +291,21 @@ const BasicInfoSection = () => { {t("policy.policyName")} + + + setPolicy((prev) => ({ ...prev, status: e.target.value as PolicyStatus }))} + > + {Object.values(PolicyStatus).map((value) => ( + + {t(`node.${value}`)} + + ))} + + {t("policy.statusDes")} + + {showBucket && ( <> diff --git a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx index 67068711..bcbc0e81 100644 --- a/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx +++ b/frontend/src/component/Admin/StoragePolicy/EditStoragePolicy/FormSections/StorageAndUploadSection.tsx @@ -170,6 +170,16 @@ const StorageAndUploadSection = () => { [setPolicy], ); + const onNativeNameChange = useCallback( + (e: React.ChangeEvent) => { + setPolicy((p: StoragePolicy) => ({ + ...p, + settings: { ...p.settings, allow_native_name: e.target.checked ? true : undefined }, + })); + }, + [setPolicy], + ); + const onChunkSizeChange = useCallback( (size: number) => { setPolicy((p: StoragePolicy) => ({ @@ -330,6 +340,17 @@ const StorageAndUploadSection = () => { {t("policy.fileNameRegexDes")} + + + + } + label={t("policy.allowNativeName")} + /> + {t("policy.allowNativeNameDes")} + + {values.type !== PolicyType.upyun && ( diff --git a/frontend/src/component/Admin/StoragePolicy/StoragePolicyCard.tsx b/frontend/src/component/Admin/StoragePolicy/StoragePolicyCard.tsx index 63e9cab5..7f03a4f8 100644 --- a/frontend/src/component/Admin/StoragePolicy/StoragePolicyCard.tsx +++ b/frontend/src/component/Admin/StoragePolicy/StoragePolicyCard.tsx @@ -1,10 +1,11 @@ -import { Box, Divider, IconButton, Link, Skeleton, Typography } from "@mui/material"; +import { FindInPage } from "@mui/icons-material"; +import { Box, Divider, IconButton, Link, Skeleton, Tooltip, Typography } from "@mui/material"; import Grid from "@mui/material/Grid2"; import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { deleteStoragePolicy, getStoragePolicyDetail } from "../../../api/api"; -import { StoragePolicy } from "../../../api/dashboard"; +import { PolicyStatus, StoragePolicy } from "../../../api/dashboard"; import { useAppDispatch } from "../../../redux/hooks"; import { confirmOperation } from "../../../redux/thunks/dialog"; import { sizeToString } from "../../../util"; @@ -12,6 +13,7 @@ import { NoWrapBox, SquareChip } from "../../Common/StyledComponents"; import Delete from "../../Icons/Delete"; import Info from "../../Icons/Info"; import { BorderedCardClickableBaImg } from "../Common/AdminCard"; +import BlobAuditDialog from "./BlobAuditDialog"; import { PolicyPropsMap } from "./StoragePolicySetting"; export interface StoragePolicyCardProps { @@ -26,8 +28,14 @@ const StoragePolicyCard = ({ policy, onRefresh, loading }: StoragePolicyCardProp const [detail, setDetail] = useState(undefined); const [deleteLoading, setDeleteLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false); + const [auditOpen, setAuditOpen] = useState(false); const navigate = useNavigate(); + const handleAuditOpen = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setAuditOpen(true); + }, []); + const loadDetail = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -125,6 +133,9 @@ const StoragePolicyCard = ({ policy, onRefresh, loading }: StoragePolicyCardProp > {policy?.name} + {policy?.status == PolicyStatus.suspended && ( + + )} {policy && ( @@ -166,11 +177,19 @@ const StoragePolicyCard = ({ policy, onRefresh, loading }: StoragePolicyCardProp )} - - - + + + + + + + + + + + setAuditOpen(false)} policy={policy} /> ); }; diff --git a/frontend/src/component/Admin/Task/TaskContent.tsx b/frontend/src/component/Admin/Task/TaskContent.tsx index 5819b1cf..7612ccd5 100644 --- a/frontend/src/component/Admin/Task/TaskContent.tsx +++ b/frontend/src/component/Admin/Task/TaskContent.tsx @@ -133,6 +133,11 @@ export const TaskContent = memo(({ task, openEntity, openFile }: TaskContentProp return t("task.fullTextDelete", { count: privateState?.file_ids?.length ?? 0, }); + case TaskType.blob_audit: + return t("task.blobAudit", { + policyID: privateState?.policy_id ?? 0, + count: privateState?.orphan_count ?? 0, + }); default: return ""; } diff --git a/frontend/src/component/Admin/User/BatchUserDialog.tsx b/frontend/src/component/Admin/User/BatchUserDialog.tsx new file mode 100644 index 00000000..41275095 --- /dev/null +++ b/frontend/src/component/Admin/User/BatchUserDialog.tsx @@ -0,0 +1,101 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, ListItemText, Stack } from "@mui/material"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { batchUpdateUser } from "../../../api/api"; +import { UserStatus } from "../../../api/dashboard"; +import { useAppDispatch } from "../../../redux/hooks"; +import { DenseSelect } from "../../Common/StyledComponents"; +import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu"; +import SettingForm from "../../Pages/Setting/SettingForm"; +import GroupSelectionInput from "../Common/GroupSelectionInput"; + +export interface BatchUserDialogProps { + open: boolean; + onClose: () => void; + ids: number[]; + onUpdated?: () => void; +} + +const NoChange = " "; + +const BatchUserDialog = ({ open, onClose, ids, onUpdated }: BatchUserDialogProps) => { + const { t } = useTranslation("dashboard"); + const dispatch = useAppDispatch(); + const [status, setStatus] = useState(NoChange); + const [group, setGroup] = useState(NoChange); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setStatus(NoChange); + setGroup(NoChange); + } + }, [open]); + + const onSubmit = () => { + setLoading(true); + dispatch( + batchUpdateUser({ + ids, + status: status === NoChange ? undefined : (status as "active" | "inactive" | "manual_banned"), + group_id: group === NoChange ? undefined : parseInt(group), + }), + ) + .then(() => { + onUpdated?.(); + onClose(); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + + {t("user.batchEditXUsers", { num: ids.length })} + + + + + setStatus(e.target.value as string)}> + + + {t("user.noChange")} + + + {Object.values(UserStatus) + .filter((value) => value !== UserStatus.sys_banned) + .map((value) => ( + + {t(`user.status_${value}`)} + + ))} + + + + + + + + + + + + + + ); +}; + +export default BatchUserDialog; diff --git a/frontend/src/component/Admin/User/UserDialog/UserForm.tsx b/frontend/src/component/Admin/User/UserDialog/UserForm.tsx index abcdbcdc..f7ae5fcb 100644 --- a/frontend/src/component/Admin/User/UserDialog/UserForm.tsx +++ b/frontend/src/component/Admin/User/UserDialog/UserForm.tsx @@ -65,6 +65,30 @@ const UserForm = ({ reload, setLoading }: { reload: () => void; setLoading: (loa [setUser], ); + const banned = values.status == UserStatus.manual_banned || values.status == UserStatus.sys_banned; + + // datetime-local inputs want "YYYY-MM-DDTHH:mm" in local time; the API + // stores RFC3339. + const toLocalInput = (iso?: string) => { + if (!iso) { + return ""; + } + const d = new Date(iso); + if (isNaN(d.getTime())) { + return ""; + } + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + }; + + const onBanExpiresChange = useCallback( + (e: React.ChangeEvent) => { + const v = e.target.value; + setUser((prev) => ({ ...prev, ban_expires: v ? new Date(v).toISOString() : undefined })); + }, + [setUser], + ); + const onGroupChange = useCallback( (value: string) => { setUser((prev) => ({ ...prev, group_users: parseInt(value) })); @@ -179,6 +203,28 @@ const UserForm = ({ reload, setLoading }: { reload: () => void; setLoading: (loa + {banned && ( + <> + + + {t("user.banExpiresDes")} + + + setUser((prev) => ({ ...prev, ban_reason: e.target.value }))} + /> + {t("user.banReasonDes")} + + + )} diff --git a/frontend/src/component/Admin/User/UserFilterPopover.tsx b/frontend/src/component/Admin/User/UserFilterPopover.tsx index 9eef54ea..f2323fdb 100644 --- a/frontend/src/component/Admin/User/UserFilterPopover.tsx +++ b/frontend/src/component/Admin/User/UserFilterPopover.tsx @@ -16,6 +16,8 @@ export interface UserFilterPopoverProps extends PopoverProps { setGroup: (group: string) => void; status: string; setStatus: (status: string) => void; + uids: string; + setUids: (uids: string) => void; clearFilters: () => void; } @@ -28,6 +30,8 @@ const UserFilterPopover = ({ setGroup, status, setStatus, + uids, + setUids, clearFilters, onClose, open, @@ -40,6 +44,7 @@ const UserFilterPopover = ({ const [localNick, setLocalNick] = useState(nick); const [localGroup, setLocalGroup] = useState(group); const [localStatus, setLocalStatus] = useState(status); + const [localUids, setLocalUids] = useState(uids); // Initialize local state when popup opens useEffect(() => { @@ -48,6 +53,7 @@ const UserFilterPopover = ({ setLocalNick(nick); setLocalGroup(group); setLocalStatus(status); + setLocalUids(uids); } }, [open]); @@ -57,6 +63,7 @@ const UserFilterPopover = ({ setNick(localNick); setGroup(localGroup == " " ? "" : localGroup); setStatus(localStatus == " " ? "" : localStatus); + setUids(localUids); onClose?.({}, "backdropClick"); }; @@ -66,6 +73,7 @@ const UserFilterPopover = ({ setLocalNick(""); setLocalGroup(""); setLocalStatus(""); + setLocalUids(""); clearFilters(); onClose?.({}, "backdropClick"); }; @@ -114,6 +122,16 @@ const UserFilterPopover = ({ /> + + setLocalUids(e.target.value)} + placeholder={t("user.uidsPlaceholder")} + size="small" + /> + + + + + ); } @@ -164,6 +168,15 @@ const UserRow = ({ user, loading, deleting, selected, onDelete, onDetails, onSel {sizeToString(user?.storage ?? 0)} + + {user?.last_login ? ( + + ) : ( + + - + + )} + diff --git a/frontend/src/component/Admin/User/UserSetting.tsx b/frontend/src/component/Admin/User/UserSetting.tsx index 7f3a14da..4360aab5 100644 --- a/frontend/src/component/Admin/User/UserSetting.tsx +++ b/frontend/src/component/Admin/User/UserSetting.tsx @@ -1,4 +1,4 @@ -import { Delete } from "@mui/icons-material"; +import { Delete, Edit } from "@mui/icons-material"; import { Badge, Box, @@ -33,6 +33,7 @@ import PageContainer from "../../Pages/PageContainer"; import PageHeader from "../../Pages/PageHeader"; import TablePagination from "../Common/TablePagination"; import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting"; +import BatchUserDialog from "./BatchUserDialog"; import NewUserDialog from "./NewUserDialog"; import UserDialog from "./UserDialog/UserDialog"; import UserFilterPopover from "./UserFilterPopover"; @@ -41,6 +42,7 @@ export const EmailQuery = "email"; export const NickQuery = "nick"; export const GroupQuery = "group"; export const StatusQuery = "status"; +export const UidsQuery = "uids"; const UserSetting = () => { const { t } = useTranslation("dashboard"); @@ -61,9 +63,11 @@ const UserSetting = () => { const [nick, setNick] = useQueryState(NickQuery, { defaultValue: "" }); const [group, setGroup] = useQueryState(GroupQuery, { defaultValue: "" }); const [status, setStatus] = useQueryState(StatusQuery, { defaultValue: "" }); + const [uids, setUids] = useQueryState(UidsQuery, { defaultValue: "" }); const [count, setCount] = useState(0); const [selected, setSelected] = useState([]); const [createNewOpen, setCreateNewOpen] = useState(false); + const [batchEditOpen, setBatchEditOpen] = useState(false); const filterPopupState = usePopupState({ variant: "popover", popupId: "userFilterPopover", @@ -81,11 +85,12 @@ const UserSetting = () => { setNick(""); setGroup(""); setStatus(""); - }, [setEmail, setNick, setGroup, setStatus]); + setUids(""); + }, [setEmail, setNick, setGroup, setStatus, setUids]); useEffect(() => { fetchUsers(); - }, [page, pageSize, orderBy, orderDirection, email, nick, group, status]); + }, [page, pageSize, orderBy, orderDirection, email, nick, group, status, uids]); const fetchUsers = () => { setLoading(true); @@ -101,6 +106,7 @@ const UserSetting = () => { user_nick: nick, user_group: group, user_status: status, + user_ids: uids, }, }), ) @@ -169,8 +175,8 @@ const UserSetting = () => { }; const hasActiveFilters = useMemo(() => { - return !!(email || nick || group || status); - }, [email, nick, group, status]); + return !!(email || nick || group || status || uids); + }, [email, nick, group, status, uids]); const handleUserDialogOpen = (id: number) => { setUserDialogID(id); @@ -193,6 +199,12 @@ const UserSetting = () => { userID={userDialogID} onUpdated={() => fetchUsers()} /> + setBatchEditOpen(false)} + ids={Array.from(selected)} + onUpdated={() => fetchUsers()} + /> @@ -210,6 +222,8 @@ const UserSetting = () => { setGroup={setGroup} status={status} setStatus={setStatus} + uids={uids} + setUids={setUids} clearFilters={clearFilters} /> @@ -226,6 +240,9 @@ const UserSetting = () => { {selected.length > 0 && !isMobile && ( <> + @@ -234,6 +251,9 @@ const UserSetting = () => { {isMobile && selected.length > 0 && ( + @@ -274,6 +294,15 @@ const UserSetting = () => { {t("user.usedStorage")} + + + {t("user.lastLogin")} + + diff --git a/frontend/src/component/Common/User/UserAvatar.tsx b/frontend/src/component/Common/User/UserAvatar.tsx index 11bc501e..fc16ee2d 100644 --- a/frontend/src/component/Common/User/UserAvatar.tsx +++ b/frontend/src/component/Common/User/UserAvatar.tsx @@ -119,6 +119,7 @@ const UserAvatar = memo( img: { loading: "lazy", alt: "", + draggable: false, }, }} {...rest} diff --git a/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx b/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx index d7e74c9b..263ecb67 100644 --- a/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx +++ b/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx @@ -1,5 +1,5 @@ import { Box, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled, Typography, useTheme } from "@mui/material"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { closeContextMenu } from "../../../redux/fileManagerSlice.ts"; import { CreateNewDialogType } from "../../../redux/globalStateSlice.ts"; @@ -107,6 +107,23 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => { dispatch(closeContextMenu({ index: fmIndex, value: undefined })); }, [dispatch]); + // The menu overlay lets pointer events fall through to the file list, so + // dismiss it manually on any click outside the menu paper — the same event + // still reaches the file underneath (upstream #3278). + useEffect(() => { + if (!contextMenuOpen) { + return; + } + const onPointerDown = (e: PointerEvent) => { + if ((e.target as HTMLElement | null)?.closest?.(".MuiMenu-paper")) { + return; + } + dispatch(closeContextMenu({ index: fmIndex, value: undefined })); + }; + document.addEventListener("pointerdown", onPointerDown, true); + return () => document.removeEventListener("pointerdown", onPointerDown, true); + }, [contextMenuOpen, dispatch, fmIndex]); + const showOpenWithCascading = displayOpt.showOpenWithCascading && displayOpt.showOpenWithCascading(); const showOpenWith = displayOpt.showOpenWith && displayOpt.showOpenWith(); let part1 = @@ -379,6 +396,14 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => { onClose={onClose} disableAutoFocusItem open={Boolean(contextMenuOpen)} + style={{ pointerEvents: "none" }} + slotProps={{ + paper: { + style: { + pointerEvents: "auto", + }, + }, + }} anchorReference="anchorPosition" anchorPosition={{ top: contextMenuPos?.y ?? 0, diff --git a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx index a8a9c10b..00845748 100644 --- a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx +++ b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx @@ -1,4 +1,4 @@ -import { DialogContent, Stack, useMediaQuery, useTheme } from "@mui/material"; +import { DialogContent, FormControl, InputLabel, MenuItem, Select, Stack, useMediaQuery, useTheme } from "@mui/material"; import { useSnackbar } from "notistack"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -13,9 +13,19 @@ import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx"; import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx"; import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; +import Edit from "../../Icons/Edit.tsx"; import Link from "../../Icons/Link.tsx"; +import LockClosedKey from "../../Icons/LockClosedKey.tsx"; +import PersonOutlined from "../../Icons/PersonOutlined.tsx"; import { FileManagerIndex } from "../FileManager.tsx"; +const providerNames: Record = { + aria2: "Aria2", + qbittorrent: "qBittorrent", +}; + +const providerDisplayName = (p: string) => providerNames[p] ?? p; + const CreateRemoteDownload = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -26,7 +36,13 @@ const CreateRemoteDownload = () => { const [loading, setLoading] = useState(false); const [path, setPath] = useState(""); const [url, setUrl] = useState(""); + const [fileName, setFileName] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [headers, setHeaders] = useState(""); + const [provider, setProvider] = useState(""); + const providers = useAppSelector((state) => state.siteConfig.explorer?.config?.remote_download_providers); const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen); const target = useAppSelector((state) => state.globalState.remoteDownloadDialogFile); const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path); @@ -37,6 +53,11 @@ const CreateRemoteDownload = () => { const fs = initialPath.fs(); setPath(fs == Filesystem.shared_with_me || fs == Filesystem.trash ? defaultPath : initialPath.toString()); setUrl(""); + setFileName(""); + setUsername(""); + setPassword(""); + setHeaders(""); + setProvider(""); } }, [open]); @@ -55,6 +76,11 @@ const CreateRemoteDownload = () => { src_file: target ? getFileLinkedUri(target) : undefined, dst: path, src: url ? url.split("\n") : undefined, + file_name: fileName || undefined, + username: username || undefined, + password: password || undefined, + headers: headers ? headers.split("\n").filter((h) => h.trim()) : undefined, + provider: provider || undefined, }), ) .then(() => { @@ -68,7 +94,7 @@ const CreateRemoteDownload = () => { .finally(() => { setLoading(false); }); - }, [target, url, path]); + }, [target, url, path, fileName, username, password, headers, provider]); return ( { label={t("modals.remoteDownloadDst")} /> + + } + variant="outlined" + value={fileName} + onChange={(e) => setFileName(e.target.value)} + label={t("application:modals.remoteDownloadFileName")} + placeholder={t("modals.remoteDownloadFileNameDescription")} + fullWidth + /> + + {providers && providers.length > 1 && ( + + + {t("application:modals.remoteDownloadProvider")} + + + + )} + {!target && ( + + } + variant="outlined" + value={username} + onChange={(e) => setUsername(e.target.value)} + label={t("application:modals.remoteDownloadHttpUser")} + fullWidth + /> + } + variant="outlined" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + label={t("application:modals.remoteDownloadHttpPassword")} + fullWidth + /> + + )} + {!target && ( + + } + variant="outlined" + value={headers} + multiline + minRows={2} + onChange={(e) => setHeaders(e.target.value)} + placeholder={t("modals.remoteDownloadHeadersDescription")} + label={t("application:modals.remoteDownloadHeaders")} + fullWidth + /> + + )} diff --git a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx index 054e6a53..a194b8f3 100644 --- a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx +++ b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx @@ -22,6 +22,7 @@ import VideoViewer from "../../Viewers/Video/VideoViewer.tsx"; import PdfViewer from "../../Viewers/PdfViewer.tsx"; import CustomViewer from "../../Viewers/CustomViewer.tsx"; import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx"; +import ModelViewer from "../../Viewers/ModelViewer/ModelViewer.tsx"; import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx"; import CreateNew from "./CreateNew.tsx"; import { useAppSelector } from "../../../redux/hooks.ts"; @@ -71,6 +72,7 @@ const Dialogs = () => { + {showCreateArchive != undefined && } {showExtractArchive != undefined && } {showRemoteDownload != undefined && } diff --git a/frontend/src/component/FileManager/Dialogs/OpenWith.tsx b/frontend/src/component/FileManager/Dialogs/OpenWith.tsx index add4c67b..367b68e5 100644 --- a/frontend/src/component/FileManager/Dialogs/OpenWith.tsx +++ b/frontend/src/component/FileManager/Dialogs/OpenWith.tsx @@ -13,6 +13,7 @@ import { } from "@mui/material"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { sendUpdateUserSetting } from "../../../api/api.ts"; import { Viewer, ViewerType } from "../../../api/explorer.ts"; import { closeViewerSelector } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; @@ -129,7 +130,15 @@ const OpenWith = () => { } if (always) { - SessionManager.set(UserSettings.OpenWithPrefix + ext, viewer?.id ?? selectedViewer?.id); + const viewerId = viewer?.id ?? selectedViewer?.id; + SessionManager.set(UserSettings.OpenWithPrefix + ext, viewerId); + const user = SessionManager.currentUser(); + if (user && viewerId) { + const merged = { ...(user.preferred_viewers ?? {}), [ext]: viewerId }; + dispatch(sendUpdateUserSetting({ preferred_viewers: merged })) + .then(() => SessionManager.updateUserIfExist({ ...user, preferred_viewers: merged })) + .catch(() => {}); + } } dispatch( diff --git a/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx b/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx index 43676403..8a457921 100644 --- a/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx +++ b/frontend/src/component/FileManager/Dialogs/Share/ShareDialog.tsx @@ -8,6 +8,7 @@ import { Share as ShareModel } from "../../../../api/explorer.ts"; import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import { createOrUpdateShareLink } from "../../../../redux/thunks/share.ts"; +import SessionManager from "../../../../session"; import { copyToClipboard, sendLink } from "../../../../util"; import AutoHeight from "../../../Common/AutoHeight.tsx"; import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx"; @@ -17,9 +18,35 @@ import Share from "../../../Icons/Share.tsx"; import { FileManagerIndex } from "../../FileManager.tsx"; import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx"; -const initialSetting: ShareSetting = { +const shareSettingStorageKey = "cloudreve.share_setting"; + +const initialSetting = (privateByDefault: boolean): ShareSetting => ({ + is_private: privateByDefault || undefined, expires_val: expireOptions[2], downloads_val: downloadOptions[0], +}); + +// lastUsedSetting hydrates the dialog from the previously saved share +// settings (#2519). Password fields are never persisted. +const lastUsedSetting = (privateByDefault: boolean): ShareSetting => { + try { + const raw = localStorage.getItem(shareSettingStorageKey); + if (raw) { + return { ...initialSetting(privateByDefault), ...JSON.parse(raw) }; + } + } catch { + // corrupted or unavailable storage falls back to defaults + } + return initialSetting(privateByDefault); +}; + +const rememberSetting = (setting: ShareSetting) => { + try { + const { password, use_custom_password, ...rest } = setting; + localStorage.setItem(shareSettingStorageKey, JSON.stringify(rest)); + } catch { + // storage unavailable; remembering is best-effort + } }; interface ShareLinkPassword { @@ -38,6 +65,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => { allow_edit: share.allow_edit, preview_only: share.preview_only, upload_only: share.upload_only, + note: share.note, downloads: share.remain_downloads != undefined && share.remain_downloads > 0, expires_val: expireOptions[2], @@ -74,8 +102,12 @@ const ShareDialog = () => { const dispatch = useAppDispatch(); const theme = useTheme(); + const sitePrivateDefault = useAppSelector((state) => state.siteConfig.basic.config.share_default_private); + const userPrivateDefault = SessionManager.currentLoginOrNull()?.user.share_default_private; + const privateByDefault = userPrivateDefault ?? !!sitePrivateDefault; + const [loading, setLoading] = useState(false); - const [setting, setSetting] = useState(initialSetting); + const [setting, setSetting] = useState(() => initialSetting(privateByDefault)); const [shareLink, setShareLink] = useState(""); const [includePassword, setIncludePassword] = useState(true); const shareLinkPassword = useMemo(() => { @@ -97,7 +129,7 @@ const ShareDialog = () => { if (editTarget) { setSetting(shareToSetting(editTarget, t)); } else { - setSetting(initialSetting); + setSetting(lastUsedSetting(privateByDefault)); } setShareLink(""); setIncludePassword(true); @@ -128,6 +160,7 @@ const ShareDialog = () => { const shareLink = await dispatch( createOrUpdateShareLink(FileManagerIndex.main, target, setting, editTarget?.id), ); + rememberSetting(setting); setShareLink(shareLink); } catch (e) { } finally { diff --git a/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx b/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx index bd5837e7..1c99e2d7 100644 --- a/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx +++ b/frontend/src/component/FileManager/Dialogs/Share/ShareSetting.tsx @@ -28,6 +28,7 @@ import Edit from "../../../Icons/Edit.tsx"; import Eye from "../../../Icons/Eye.tsx"; import EyeOff from "../../../Icons/EyeOff.tsx"; import FolderAdd from "../../../Icons/FolderAdd.tsx"; +import RenameOutlined from "../../../Icons/RenameOutlined.tsx"; import TableSettingsOutlined from "../../../Icons/TableSettings.tsx"; import Timer from "../../../Icons/Timer.tsx"; import Upload from "../../../Icons/Upload.tsx"; @@ -87,6 +88,7 @@ export interface ShareSetting { allow_edit?: boolean; preview_only?: boolean; upload_only?: boolean; + note?: string; downloads?: boolean; expires?: boolean; @@ -210,6 +212,37 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS )} + + + + + + + + + + + + {t("application:modals.shareNoteDes")} + + + onSettingChange({ ...setting, note: e.target.value })} + /> + + + diff --git a/frontend/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx b/frontend/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx index 7a87a4f7..964a75ff 100644 --- a/frontend/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx +++ b/frontend/src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx @@ -173,6 +173,16 @@ const GalleryImage = memo((props: FileBlockProps) => { )} + {file.type == FileType.folder && !isLoadingIndicator && !file.placeholder && ( + + )} { e.stopPropagation(); @@ -407,6 +409,18 @@ const CustomPropsCell = memo(({ file, customProp, readOnly }: CustomPropsCellPro [dispatch, fmIndex, file, customProp.id], ); + const onDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (loading || readOnly) return; + setLoading(true); + dispatch(patchCustomProp(fmIndex, file, customProp.id, "", true)).finally(() => { + setLoading(false); + }); + }, + [dispatch, fmIndex, file, customProp.id, loading, readOnly], + ); + return ( setMouseOver(true)} + onMouseLeave={() => setMouseOver(false)} + sx={{ width: "100%", display: "flex", alignItems: "center" }} > - {getPropsContent(customProp, onChange, loading, readOnly)} + {getPropsContent(customProp, onChange, loading, readOnly)} + {!readOnly && ( + + + + + + )} ); }); diff --git a/frontend/src/component/FileManager/Explorer/ListView/ListBody.tsx b/frontend/src/component/FileManager/Explorer/ListView/ListBody.tsx index 09382fbb..fa0f1911 100644 --- a/frontend/src/component/FileManager/Explorer/ListView/ListBody.tsx +++ b/frontend/src/component/FileManager/Explorer/ListView/ListBody.tsx @@ -1,6 +1,8 @@ -import { useContext, useMemo } from "react"; +import { Box } from "@mui/material"; +import { useCallback, useContext, useMemo } from "react"; import { Virtuoso } from "react-virtuoso"; -import { useAppSelector } from "../../../../redux/hooks.ts"; +import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; +import { setSelected } from "../../../../redux/fileManagerSlice.ts"; import DndWrappedFile from "../../Dnd/DndWrappedFile.tsx"; import { FmIndexContext } from "../../FmIndexContext.tsx"; import { FmFile, loadingPlaceHolderNumb } from "../GridView/GridView.tsx"; @@ -14,6 +16,7 @@ export interface ListBodyProps { const ListBody = ({ columns }: ListBodyProps) => { const fmIndex = useContext(FmIndexContext); + const dispatch = useAppDispatch(); const files = useAppSelector((state) => state.fileManager[fmIndex].list?.files); const mixedType = useAppSelector((state) => state.fileManager[fmIndex].list?.mixed_type); const pagination = useAppSelector((state) => state.fileManager[fmIndex].list?.pagination); @@ -46,25 +49,38 @@ const ListBody = ({ columns }: ListBodyProps) => { return list; }, [files, mixedType, pagination, search_params]); + // Clicking empty space below/around rows clears the selection (#3223); + // row clicks are excluded via the data-fm-row marker. + const onBackgroundClick = useCallback( + (e: React.MouseEvent) => { + if (!(e.target as HTMLElement).closest("[data-fm-row]")) { + dispatch(setSelected({ index: fmIndex, value: [] })); + } + }, + [dispatch, fmIndex], + ); + return ( - ( - - )} - /> + + ( + + )} + /> + ); }; diff --git a/frontend/src/component/FileManager/Explorer/ListView/Row.tsx b/frontend/src/component/FileManager/Explorer/ListView/Row.tsx index 4a1b9668..0f658fc9 100644 --- a/frontend/src/component/FileManager/Explorer/ListView/Row.tsx +++ b/frontend/src/component/FileManager/Explorer/ListView/Row.tsx @@ -91,6 +91,7 @@ const Row = memo((props: FileBlockProps) => { return ( state.siteConfig.explorer?.config?.custom_props); + // `file` is a snapshot taken when the sidebar opened; prefer live metadata + // from the file list so props patched from list view appear immediately. + const liveMetadata = useAppSelector( + (state) => state.fileManager[FileManagerIndex.main]?.list?.files.find((f) => f.path === file.path)?.metadata, + ); + const metadata = liveMetadata ?? file.metadata; const existingProps = useMemo(() => { - if (!file.metadata) { + if (!metadata) { return []; } - return Object.keys(file.metadata) + return Object.keys(metadata) .filter((key) => key.startsWith(customPropsMetadataPrefix)) .map((key) => { const propId = key.slice(customPropsMetadataPrefix.length); return { id: propId, props: custom_props?.find((prop) => prop.id === propId), - value: file.metadata?.[key] ?? "", + value: metadata?.[key] ?? "", } as CustomPropsItem; }); - }, [file.metadata]); + }, [metadata, custom_props]); const existingPropIds = useMemo(() => { return existingProps?.map((prop) => prop.id) ?? []; @@ -62,21 +70,22 @@ const CustomProps = ({ file, setTarget, targetDisplayOptions }: CustomPropsProps }), ) .then(() => { - if (remove) { - const newMetadata = { ...file.metadata }; - props.forEach((prop) => { + const newMetadata = { ...metadata }; + props.forEach((prop) => { + if (remove) { delete newMetadata[customPropsMetadataPrefix + prop.id]; - }); - setTarget({ ...file, metadata: newMetadata }); - } else { - setTarget({ - ...file, - metadata: { - ...file.metadata, - ...Object.assign({}, ...props.map((prop) => ({ [customPropsMetadataPrefix + prop.id]: prop.value }))), - }, - }); - } + } else { + newMetadata[customPropsMetadataPrefix + prop.id] = prop.value; + } + }); + const updated = { ...file, metadata: newMetadata }; + setTarget(updated); + dispatch( + fileUpdated({ + index: FileManagerIndex.main, + value: [{ file: updated, oldPath: file.path, includeMetadata: true }], + }), + ); }) .finally(() => { setLoading(false); diff --git a/frontend/src/component/FileManager/Sidebar/Details.tsx b/frontend/src/component/FileManager/Sidebar/Details.tsx index 1d1df2dc..bb945b0a 100644 --- a/frontend/src/component/FileManager/Sidebar/Details.tsx +++ b/frontend/src/component/FileManager/Sidebar/Details.tsx @@ -50,6 +50,7 @@ const Details = ({ target, inPhotoViewer, setTarget, targetDisplayOptions }: Det setThumbSrc(null); }} src={thumbSrc} + draggable={false} sx={{ borderRadius: "8px", }} diff --git a/frontend/src/component/FileManager/TopBar/Breadcrumb.tsx b/frontend/src/component/FileManager/TopBar/Breadcrumb.tsx index 50e9663b..842584ef 100644 --- a/frontend/src/component/FileManager/TopBar/Breadcrumb.tsx +++ b/frontend/src/component/FileManager/TopBar/Breadcrumb.tsx @@ -69,7 +69,7 @@ const Breadcrumb = (props: BreadcrumbProps) => { const [anchorEl, setAnchorEl] = useState(null); const hiddenOpen = Boolean(anchorEl); const hiddenExpandButtonRef = useRef(); - const chainRef = useRef(null); + const chainRef = useRef(null); const openHiddenMenu = (e: React.MouseEvent) => { e.stopPropagation(); @@ -129,16 +129,39 @@ const Breadcrumb = (props: BreadcrumbProps) => { const isOverflow = useIsOverflow(chainRef, (_isOverflow) => {}); + // Width needed to render the full chain, captured before collapsing. + const requiredWidthRef = useRef(0); + useEffect(() => { if (isOverflow && !isMobile) { + requiredWidthRef.current = chainRef.current?.scrollWidth ?? requiredWidthRef.current; setMaxHiding(buttons.length - 1); } }, [isOverflow, isMobile]); + // Restore the full chain once the container can fit it again. A dedicated + // observer is needed: isOverflow stays false once the collapsed chain fits, + // so it cannot drive the restore itself (#3288). + useEffect(() => { + const el = chainRef.current; + if (!el || props.displayOnly) { + return; + } + const observer = new ResizeObserver(() => { + if (requiredWidthRef.current > 0 && el.clientWidth >= requiredWidthRef.current) { + requiredWidthRef.current = 0; + setMaxHiding(0); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + // Cancel collapse when elements are less than previous useEffect(() => { const current = elements?.length ?? 0; if (previousElements.current > current) { + requiredWidthRef.current = 0; setTimeout(() => { setMaxHiding(0); }, theme.transitions.duration.standard); diff --git a/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx b/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx index 11d05868..2a4fe23f 100644 --- a/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx +++ b/frontend/src/component/FileManager/TopBar/MoreActionMenu.tsx @@ -4,15 +4,19 @@ import { useCallback, useContext } from "react"; import { useTranslation } from "react-i18next"; import { clearSelected } from "../../../redux/fileManagerSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { NavigatorCapability } from "../../../api/explorer.ts"; +import { downloadAll } from "../../../redux/thunks/download.ts"; import { createShareShortcut, isMacbook } from "../../../redux/thunks/file.ts"; import { inverseSelection, pinCurrentView, refreshFileList, selectAll } from "../../../redux/thunks/filemanager.ts"; import SessionManager from "../../../session"; +import Boolset from "../../../util/boolset.ts"; import { Filesystem } from "../../../util/uri.ts"; import { KeyIndicator } from "../../Frame/NavBar/SearchBar.tsx"; import ArrowSync from "../../Icons/ArrowSync.tsx"; import Border from "../../Icons/Border.tsx"; import BorderAll from "../../Icons/BorderAll.tsx"; import BorderInside from "../../Icons/BorderInside.tsx"; +import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx"; import FolderLink from "../../Icons/FolderLink.tsx"; import PinOutlined from "../../Icons/PinOutlined.tsx"; import { DenseDivider, SquareMenu, SquareMenuItem } from "../ContextMenu/ContextMenu.tsx"; @@ -22,6 +26,8 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => { const { t } = useTranslation(); const fmIndex = useContext(FmIndexContext); const fs = useAppSelector((state) => state.fileManager[fmIndex].current_fs); + const parentCapability = useAppSelector((state) => state.fileManager[fmIndex].list?.parent?.capability); + const canDownloadAll = new Boolset(parentCapability).enabled(NavigatorCapability.download_file); const dispatch = useAppDispatch(); const isLogin = !!SessionManager.currentLoginOrNull(); const theme = useTheme(); @@ -62,6 +68,11 @@ const MoreActionMenu = ({ onClose, ...rest }: MenuProps) => { dispatch(refreshFileList(fmIndex)); }, [dispatch, onClose, fmIndex]); + const onDownloadAllClicked = useCallback(() => { + onClose && onClose({}, "escapeKeyDown"); + dispatch(downloadAll(fmIndex)); + }, [dispatch, onClose, fmIndex]); + return ( { {t("application:fileManager.saveShortcut")} )} + {fs == Filesystem.share && canDownloadAll && ( + + + + + {t("application:fileManager.downloadAll")} + + )} {isLogin && } diff --git a/frontend/src/component/FileManager/TreeView/TreeFiles.tsx b/frontend/src/component/FileManager/TreeView/TreeFiles.tsx index b72ec52f..65345e53 100644 --- a/frontend/src/component/FileManager/TreeView/TreeFiles.tsx +++ b/frontend/src/component/FileManager/TreeView/TreeFiles.tsx @@ -131,7 +131,7 @@ const TreeFiles = React.memo( )) : shadowChild} - {limit < childTreeFiles.length ? ( + {!flatten && limit < childTreeFiles.length ? ( setLimit((l) => l + 50)} /> ) : null} diff --git a/frontend/src/component/Frame/NavBar/PageNavigation.tsx b/frontend/src/component/Frame/NavBar/PageNavigation.tsx index 1b6b9134..067b64cf 100644 --- a/frontend/src/component/Frame/NavBar/PageNavigation.tsx +++ b/frontend/src/component/Frame/NavBar/PageNavigation.tsx @@ -4,7 +4,7 @@ import SvgIcon from "@mui/material/SvgIcon/SvgIcon"; import { memo, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; -import { GroupPermission } from "../../../api/user.ts"; +import { GroupPermission, isAnyAdmin } from "../../../api/user.ts"; import { useAppSelector } from "../../../redux/hooks.ts"; import SessionManager from "../../../session"; import { GroupBS } from "../../../session/utils.ts"; @@ -52,6 +52,9 @@ export interface NavigationItem { icon?: ((props: SvgIconProps) => JSX.Element)[] | (typeof SvgIcon)[]; iconifyName?: string; path: string; + // Delegated admin section bit required to see this item. Full admins + // (is_admin) always pass; undefined means any admin. + permission?: number; } let NavigationItems: NavigationItem[]; @@ -137,80 +140,100 @@ AdminNavigationItems = [ label: "dashboard:nav.settings", icon: [Setting, SettingsOutlined], path: "/admin/settings", + permission: GroupPermission.admin_settings, }, { label: "dashboard:nav.fileSystem", icon: [CubeTreeFilled, CubeTree], path: "/admin/filesystem", + permission: GroupPermission.admin_settings, }, { label: "dashboard:nav.storagePolicy", icon: [Storage, StorageOutlined], path: "/admin/policy", + permission: GroupPermission.admin_storage, }, { label: "dashboard:nav.nodes", icon: [ServerFilled, Server], path: "/admin/node", + permission: GroupPermission.admin_storage, }, { label: "dashboard:nav.groups", icon: [PeopleFilled, People], path: "/admin/group", + permission: GroupPermission.admin_groups, }, { label: "dashboard:nav.users", icon: [Person, PersonOutlined], path: "/admin/user", + permission: GroupPermission.admin_users, }, { label: "dashboard:nav.files", icon: [Folder, FolderOutlined], path: "/admin/file", + permission: GroupPermission.admin_files, }, { label: "dashboard:nav.entities", icon: [BoxMultipleFilled, BoxMultiple], path: "/admin/blob", + permission: GroupPermission.admin_files, }, { label: "dashboard:nav.shares", icon: [ShareAndroid, ShareOutlined], path: "/admin/share", + permission: GroupPermission.admin_shares, }, { label: "dashboard:nav.tasks", icon: [CubeSyncFilled, CubeSync], path: "/admin/task", + permission: GroupPermission.admin_queue, }, { label: "dashboard:vas.orders", icon: [PaymentFilled, Payment], path: "/admin/payment", + permission: GroupPermission.admin_payment, }, { label: "dashboard:nav.events", icon: [SendLoggingFilled, SendLogging], path: "/admin/event", + permission: GroupPermission.admin_events, }, { label: "dashboard:nav.abuseReport", icon: [Warning, WarningOutlined], path: "/admin/abuse", + permission: GroupPermission.admin_reports, }, { label: "dashboard:nav.oauthClients", icon: [ShieldLockFilled, ShieldLock], path: "/admin/oauth", + permission: GroupPermission.admin_settings, }, ]; export const AdminPageNavigation = memo(() => { + const user = SessionManager.currentLoginOrNull(); + const permission = useMemo(() => GroupBS(user?.user), [user?.user?.group?.permission]); + const isFullAdmin = permission.enabled(GroupPermission.is_admin); + const visibleItems = AdminNavigationItems.filter( + (item) => item.permission === undefined || isFullAdmin || permission.enabled(item.permission), + ); return ( <> - + - {AdminNavigationItems.slice(1).map((item) => ( + {visibleItems.slice(1).map((item) => ( ))} @@ -229,7 +252,7 @@ const PageNavigation = () => { const appPromotionEnabled = useAppSelector((state) => state.siteConfig.basic.config.app_promotion); const user = SessionManager.currentLoginOrNull(); const isAdmin = useMemo(() => { - return GroupBS(user?.user).enabled(GroupPermission.is_admin); + return isAnyAdmin(GroupBS(user?.user)); }, [user?.user?.group?.permission]); const remoteDownloadEnabled = useMemo(() => { return GroupBS(user?.user).enabled(GroupPermission.remote_download); @@ -256,16 +279,18 @@ const PageNavigation = () => { )} {customNavItems && customNavItems.length > 0 && ( - {customNavItems.map((item) => ( - - ))} + {customNavItems + .filter((item) => isLogin || item.scope !== "user") + .map((item) => ( + + ))} )} {isLogin && isAdmin && ( diff --git a/frontend/src/component/Frame/NavBar/UserAction.tsx b/frontend/src/component/Frame/NavBar/UserAction.tsx index ab79422c..fb00fa66 100644 --- a/frontend/src/component/Frame/NavBar/UserAction.tsx +++ b/frontend/src/component/Frame/NavBar/UserAction.tsx @@ -17,7 +17,7 @@ import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { GroupPermission } from "../../../api/user.ts"; +import { isAnyAdmin } from "../../../api/user.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import { signout } from "../../../redux/thunks/session.ts"; import SessionManager, { Session } from "../../../session"; @@ -47,7 +47,7 @@ const UserPopover = ({ open, onClose, ...rest }: PopoverProps) => { } const isAdmin = useMemo(() => { - return GroupBS(user).enabled(GroupPermission.is_admin); + return isAnyAdmin(GroupBS(user)); }, [user.group?.permission]); const signWithHint = (email: string) => { diff --git a/frontend/src/component/Pages/Login/ActivateEmailChange.tsx b/frontend/src/component/Pages/Login/ActivateEmailChange.tsx new file mode 100644 index 00000000..bbadd745 --- /dev/null +++ b/frontend/src/component/Pages/Login/ActivateEmailChange.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from "react-i18next"; +import { useAppDispatch } from "../../../redux/hooks.ts"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "../../../util"; +import React, { useEffect, useState } from "react"; +import { Box, Button, Typography } from "@mui/material"; +import PageTitle from "../../../router/PageTitle.tsx"; +import CheckmarkCircle from "../../Icons/CheckmarkCircle.tsx"; +import { setHeadlessFrameLoading } from "../../../redux/globalStateSlice.ts"; +import { sendEmailChangeActivate } from "../../../api/api.ts"; + +const ActivateEmailChange = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const query = useQuery(); + + const [success, setSuccess] = useState(true); + + useEffect(() => { + const sign = query.get("sign"); + const id = query.get("id"); + if (!sign || !id) { + setSuccess(false); + navigate("/session"); + return; + } + + dispatch(setHeadlessFrameLoading(true)); + dispatch(sendEmailChangeActivate(id, decodeURIComponent(sign))) + .then(() => { + setSuccess(true); + }) + .catch(() => { + navigate("/session"); + }) + .finally(() => { + dispatch(setHeadlessFrameLoading(false)); + }); + }, []); + + return ( + + + + {success && ( + <> + + + theme.palette.success.main, + mt: 1, + }} + > + {t("application:login.emailChanged")} + + + + + )} + + + ); +}; + +export default ActivateEmailChange; diff --git a/frontend/src/component/Pages/Login/Signin/SignIn.tsx b/frontend/src/component/Pages/Login/Signin/SignIn.tsx index cfb05f55..078c6b64 100644 --- a/frontend/src/component/Pages/Login/Signin/SignIn.tsx +++ b/frontend/src/component/Pages/Login/Signin/SignIn.tsx @@ -14,7 +14,7 @@ import { sendPrepareLogin, sendResetEmail, } from "../../../../api/api.ts"; -import { AppError, Code } from "../../../../api/request.ts"; +import { ApiPrefix, AppError, Code } from "../../../../api/request.ts"; import { AppRegistration, GrantService, LoginResponse, PrepareLoginResponse } from "../../../../api/user.ts"; import { clearOAuthApp, setOAuthApp, setOAuthAppLoading } from "../../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; @@ -86,6 +86,7 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => { // Get OAuth app from Redux const app = useAppSelector((state) => state.globalState.oauthApp); + const { sso_enabled, sso_auto_redirect } = useAppSelector((state) => state.siteConfig.login.config); const [phase, setPhase] = useState(EmailLoginPhase.CollectEmail); const [email, setEmail] = useState(""); @@ -312,6 +313,18 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => { }); } + // Auto-redirect to the configured SSO provider. `nosso` and an existing + // sso_error keep the password form reachable for admin recovery. + if (sso_enabled && sso_auto_redirect && !ssoError && !query.get("nosso") && !isOAuthFlow) { + const target = new URL(ApiPrefix + "/session/sso", window.location.origin); + const redirect = query.get("redirect"); + if (redirect) { + target.searchParams.set("redirect", redirect); + } + window.location.replace(target.toString()); + return; + } + const init = async () => { if (isOAuthFlow) { const registration = await loadAppRegistration(); diff --git a/frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx b/frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx new file mode 100644 index 00000000..df8bb44f --- /dev/null +++ b/frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from "react-i18next"; +import { useSnackbar } from "notistack"; +import { useAppDispatch } from "../../../redux/hooks.ts"; +import React, { useState } from "react"; +import { DialogContent, FormControl, FormHelperText, Typography } from "@mui/material"; +import DraggableDialog from "../../Dialogs/DraggableDialog.tsx"; +import { DenseFilledTextField } from "../../Common/StyledComponents.tsx"; +import { sendRequestEmailChange } from "../../../api/api.ts"; + +export interface ChangeEmailDialogProps { + open?: boolean; + onClose: () => void; + currentEmail?: string; +} + +const ChangeEmailDialog = ({ open, onClose, currentEmail }: ChangeEmailDialogProps) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const dispatch = useAppDispatch(); + + const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const submit = () => { + setLoading(true); + dispatch(sendRequestEmailChange(email, password)) + .then(() => { + enqueueSnackbar({ message: t("setting.emailChangeSent"), variant: "success" }); + setEmail(""); + setPassword(""); + onClose(); + }) + .finally(() => setLoading(false)); + }; + + return ( + + + + {t("setting.changeEmailDes", { email: currentEmail })} + + + setEmail(e.target.value)} + autoComplete="email" + /> + + + setPassword(e.target.value)} + autoComplete="current-password" + /> + {t("setting.changeEmailPasswordDes")} + + + + ); +}; + +export default ChangeEmailDialog; diff --git a/frontend/src/component/Pages/Setting/PreferenceSetting.tsx b/frontend/src/component/Pages/Setting/PreferenceSetting.tsx index 5a8a5d25..e3a8f887 100644 --- a/frontend/src/component/Pages/Setting/PreferenceSetting.tsx +++ b/frontend/src/component/Pages/Setting/PreferenceSetting.tsx @@ -23,6 +23,7 @@ import { UserSettings as UserSettingsType } from "../../../api/user.ts"; import { languages } from "../../../i18n.ts"; import { setPreferredTheme } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { ViewersByID } from "../../../redux/siteConfigSlice.ts"; import { clearLocalCustomView } from "../../../redux/thunks/filemanager.ts"; import { selectLanguage } from "../../../redux/thunks/settings.ts"; import SessionManager, { UserSettings } from "../../../session"; @@ -85,6 +86,29 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { const [folderClickAction, setFolderClickAction] = useState( SessionManager.getWithFallback(UserSettings.FolderClickAction), ); + const [trashRetentionDays, setTrashRetentionDays] = useState( + Math.round((setting.trash_retention ?? 0) / 86400), + ); + + const onTrashRetentionChange = (e: React.ChangeEvent) => { + const days = Math.max(0, parseInt(e.target.value) || 0); + setTrashRetentionDays(days); + }; + + const saveTrashRetention = () => { + const seconds = trashRetentionDays * 86400; + if (seconds == (setting.trash_retention ?? 0)) { + return; + } + setLoading(true); + dispatch(sendUpdateUserSetting({ trash_retention: seconds })) + .then(() => { + setSetting({ ...setting, trash_retention: seconds }); + }) + .finally(() => { + setLoading(false); + }); + }; const onRetentionCheckChange = (e: React.ChangeEvent) => { setVersionRetentionEnabled(e.target.checked); @@ -106,6 +130,7 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { setVersionRetentionEnabled(setting.version_retention_enabled); setVersionRetentionMax(setting.version_retention_max); setVersionRetentionExts(setting.version_retention_ext); + setTrashRetentionDays(Math.round((setting.trash_retention ?? 0) / 86400)); }, [setting]); const selectTimeZone = (value: string) => { @@ -190,6 +215,27 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { } }; + const preferredViewerEntries = useMemo( + () => Object.entries(setting.preferred_viewers ?? {}), + [setting.preferred_viewers], + ); + + const removePreferredViewer = (ext: string) => { + const next = { ...(setting.preferred_viewers ?? {}) }; + delete next[ext]; + setSetting({ ...setting, preferred_viewers: next }); + setLoading(true); + dispatch(sendUpdateUserSetting({ preferred_viewers: next })) + .then(() => { + const session = SessionManager.currentLoginOrNull(); + if (session?.user) { + SessionManager.updateUserIfExist({ ...session.user, preferred_viewers: next }); + } + SessionManager.set(UserSettings.OpenWithPrefix + ext, undefined); + }) + .finally(() => setLoading(false)); + }; + return ( @@ -233,14 +279,16 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { - {Object.keys(themeOptions).map((color, index) => ( - applyTheme(color)} - selected={(preferredTheme && preferredTheme == color) || (!preferredTheme && defaultTheme == color)} - /> - ))} + {Object.keys(themeOptions) + .filter((color) => !themeOptions[color]?.hidden) + .map((color, index) => ( + applyTheme(color)} + selected={(preferredTheme && preferredTheme == color) || (!preferredTheme && defaultTheme == color)} + /> + ))} @@ -325,6 +373,16 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { + + + { {t("setting.folderClickActionDes")} + + + {preferredViewerEntries.length == 0 && ( + {t("setting.preferredViewersEmpty")} + )} + + {preferredViewerEntries.map(([ext, viewerId]) => ( + removePreferredViewer(ext)} + disabled={loading} + /> + ))} + + {t("setting.preferredViewersDes")} + + ); }; diff --git a/frontend/src/component/Pages/Setting/ProfileSetting.tsx b/frontend/src/component/Pages/Setting/ProfileSetting.tsx index 62d646f2..f38c7924 100644 --- a/frontend/src/component/Pages/Setting/ProfileSetting.tsx +++ b/frontend/src/component/Pages/Setting/ProfileSetting.tsx @@ -1,5 +1,5 @@ import { LoadingButton } from "@mui/lab"; -import { Collapse, Grid2, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material"; +import { Button, Collapse, FormHelperText, Grid2, ListItemText, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material"; import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,10 +7,12 @@ import { sendUpdateUserSetting } from "../../../api/api.ts"; import { UserSettings, ShareLinksInProfileLevel } from "../../../api/user.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import SessionManager from "../../../session"; -import { DefaultButton, DenseFilledTextField } from "../../Common/StyledComponents.tsx"; +import { DefaultButton, DenseFilledTextField, DenseSelect } from "../../Common/StyledComponents.tsx"; +import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; import TimeBadge from "../../Common/TimeBadge.tsx"; import CaretDown from "../../Icons/CaretDown.tsx"; import AvatarSetting from "./AvatarSetting.tsx"; +import ChangeEmailDialog from "./ChangeEmailDialog.tsx"; import ProfileSettingPopover, { useProfileSettingSummary } from "./ProfileSettingPopover.tsx"; import SettingForm from "./SettingForm.tsx"; @@ -42,6 +44,7 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => { const [nick, setNick] = useState(user?.user.nickname); const [nickLoading, setNickLoading] = useState(false); const [profileSettingLoading, setProfileSettingLoading] = useState(false); + const [emailDialogOpen, setEmailDialogOpen] = useState(false); const profileSettingPopup = usePopupState({ variant: "popover", @@ -101,7 +104,12 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => { - + + + + { {...bindPopover(profileSettingPopup)} /> + + + { + const v = e.target.value as string; + dispatch(sendUpdateUserSetting({ share_default_private: v })).then(() => { + setSetting({ + ...setting, + share_default_private: v === "" ? undefined : v === "true", + }); + }); + }} + > + + + + + + + + + + + {t("setting.shareDefaultPrivateDes")} + + setEmailDialogOpen(false)} currentEmail={user?.user.email} /> ); }; diff --git a/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx b/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx index 7f31542a..9f5a3e91 100644 --- a/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx +++ b/frontend/src/component/Pages/Setting/ProfileSettingPopover.tsx @@ -28,6 +28,12 @@ const profileSettingOptions: { description: string; icon?: ((props: SvgIconProps) => JSX.Element) | typeof Eye; }[] = [ + { + value: ShareLinksInProfileLevel.site_default, + label: "application:setting.shareSiteDefault", + description: "application:setting.shareSiteDefaultDes", + icon: Globe, + }, { value: ShareLinksInProfileLevel.public_share_only, label: "application:setting.publicShareOnly", @@ -53,7 +59,7 @@ export const useProfileSettingSummary = (value: ShareLinksInProfileLevel) => { const summary = useMemo(() => { const option = profileSettingOptions.find((opt) => opt.value === (value ?? "")); - return option ? t(option.label) : t("application:setting.publicShareOnly"); + return option ? t(option.label) : t("application:setting.shareSiteDefault"); }, [value, t]); return summary; diff --git a/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx b/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx index f42192a4..04486b20 100644 --- a/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx +++ b/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx @@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next"; import { useSnackbar } from "notistack"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import React, { useEffect, useState } from "react"; -import { Box, DialogContent, FormControl, Stack, styled, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { Box, DialogContent, FormControl, Stack, styled, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material"; import { CSSTransition, SwitchTransition } from "react-transition-group"; import AutoHeight from "../../../Common/AutoHeight.tsx"; import FacebookCircularProgress from "../../../Common/CircularProgress.tsx"; import { get2FAInitSecret, sendUpdateUserSetting } from "../../../../api/api.ts"; import { QRCodeSVG } from "qrcode.react"; import SessionManager from "../../../../session"; +import { copyToClipboard } from "../../../../util"; import { MuiOtpInput } from "mui-one-time-password-input"; export interface Enable2FADialogProps { @@ -129,6 +130,25 @@ const Enable2FADialog = ({ open, onClose, on2FAEnabled }: Enable2FADialogProps) {t("setting.2faDescription")} + + { + copyToClipboard(secret); + enqueueSnackbar({ message: t("setting.secretCopied"), variant: "success" }); + }} + sx={{ + mt: 1, + fontFamily: "monospace", + letterSpacing: 1, + cursor: "pointer", + wordBreak: "break-all", + userSelect: "all", + }} + > + {secret} + + {t("setting.inputCurrent2FACode")} diff --git a/frontend/src/component/Pages/Shares/ShareCard.tsx b/frontend/src/component/Pages/Shares/ShareCard.tsx index 194d0aed..233f38f0 100644 --- a/frontend/src/component/Pages/Shares/ShareCard.tsx +++ b/frontend/src/component/Pages/Shares/ShareCard.tsx @@ -1,5 +1,6 @@ import { Box, + Checkbox, Chip, Grid, ListItemIcon, @@ -30,6 +31,7 @@ import TimeBadge from "../../Common/TimeBadge.tsx"; import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx"; import Clipboard from "../../Icons/Clipboard.tsx"; +import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx"; import DeleteOutlined from "../../Icons/DeleteOutlined.tsx"; import Eye from "../../Icons/Eye.tsx"; import LinkEdit from "../../Icons/LinkEdit.tsx"; @@ -42,6 +44,9 @@ export interface ShareCardProps { onLoad?: () => void; loading?: boolean; onShareDeleted: (id: string) => void; + selecting?: boolean; + selected?: boolean; + onToggleSelect?: (id: string) => void; } interface ActionMenuProps extends MenuProps { @@ -127,7 +132,7 @@ const ActionMenu = ({ share, onShareDeleted, onClose, ...rest }: ActionMenuProps ); }; -const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) => { +const ShareCard = ({ share, onShareDeleted, onLoad, loading, selecting, selected, onToggleSelect }: ShareCardProps) => { const { t } = useTranslation(); const navigate = useNavigate(); const { ref, inView } = useInView({ @@ -162,18 +167,27 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) = expanded={false} onContextMenu={(e) => { e.preventDefault(); + if (selecting) { + return; + } if (share?.owner?.id === user?.user.id) { popupState.open(e); } }} sx={{ p: 0, minHeight: 0, width: "100%", textAlign: "left" }} - {...(share?.owner?.id != user?.user.id + {...(selecting && share?.owner?.id == user?.user.id ? { onClick: () => { - window.open(share?.url ?? "#", "_blank"); + share && onToggleSelect?.(share.id); }, } - : bindTrigger(popupState))} + : share?.owner?.id != user?.user.id + ? { + onClick: () => { + window.open(share?.url ?? "#", "_blank"); + }, + } + : bindTrigger(popupState))} > - {share ? ( + {selecting ? ( + + ) : share ? ( )} + {share?.note && ( + + + {share.note} + + + )} @@ -248,6 +271,12 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) = > {share?.visited ?? 0} + + + + {share?.downloaded ?? 0} + + )} diff --git a/frontend/src/component/Pages/Shares/ShareList.tsx b/frontend/src/component/Pages/Shares/ShareList.tsx index a9063db9..0ceb0116 100644 --- a/frontend/src/component/Pages/Shares/ShareList.tsx +++ b/frontend/src/component/Pages/Shares/ShareList.tsx @@ -1,11 +1,14 @@ import * as React from "react"; import { useCallback, useState } from "react"; -import { Box, Container, FormControl, Grid, ListItemText, SelectChangeEvent } from "@mui/material"; +import { Box, Button, Container, FormControl, Grid, ListItemText, SelectChangeEvent, Stack } from "@mui/material"; +import { useSnackbar } from "notistack"; import { useTranslation } from "react-i18next"; import PageHeader from "../PageHeader.tsx"; -import { getShares } from "../../../api/api.ts"; +import { getShares, sendDeleteShares } from "../../../api/api.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; +import { confirmOperation } from "../../../redux/thunks/dialog.ts"; import Nothing from "../../Common/Nothing.tsx"; +import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx"; import { setSelected } from "../../../redux/fileManagerSlice.ts"; import ShareCard from "./ShareCard.tsx"; import { Share } from "../../../api/explorer.ts"; @@ -18,10 +21,13 @@ const defaultPageSize = 50; const ShareList = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); + const { enqueueSnackbar } = useSnackbar(); const [nextPageToken, setNextPageToken] = useState(""); const [shares, setShares] = useState([]); const [loading, setLoading] = useState(false); const [orderDirection, setOrderDirection] = useState("desc"); + const [selecting, setSelecting] = useState(false); + const [selected, setSelectedIds] = useState>(new Set()); const loadNextPage = useCallback( (originShares: Share[], token?: string, direction?: string) => () => { @@ -62,6 +68,40 @@ const ShareList = () => { [setShares], ); + const onToggleSelect = useCallback( + (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, + [setSelectedIds], + ); + + const exitSelecting = useCallback(() => { + setSelecting(false); + setSelectedIds(new Set()); + }, []); + + const deleteSelected = useCallback(() => { + dispatch(confirmOperation(t("fileManager.deleteShareWarning"))).then(() => { + dispatch(sendDeleteShares([...selected])).then(() => { + enqueueSnackbar({ + message: t("application:share.shareCanceled"), + variant: "success", + action: DefaultCloseAction, + }); + setShares((shares) => shares.filter((share) => !selected.has(share.id))); + exitSelecting(); + }); + }); + }, [dispatch, selected, t, enqueueSnackbar, exitSelecting]); + const onSelectChange = useCallback( (e: SelectChangeEvent) => { setOrderDirection(e.target.value as string); @@ -75,28 +115,53 @@ const ShareList = () => { - - - + {selecting && ( + <> + + + )} + + + + + + {t("application:share.createdAtDesc")} + + + + + {t("application:share.createdAtAsc")} + + + + + } onRefresh={() => refresh()} loading={loading} @@ -105,7 +170,14 @@ const ShareList = () => { {shares.map((share) => ( - + ))} {nextPageToken != undefined && ( <> diff --git a/frontend/src/component/Pages/Tasks/DownloadList.tsx b/frontend/src/component/Pages/Tasks/DownloadList.tsx index a5ba2b8f..c292181a 100644 --- a/frontend/src/component/Pages/Tasks/DownloadList.tsx +++ b/frontend/src/component/Pages/Tasks/DownloadList.tsx @@ -140,12 +140,13 @@ const DownloadList = () => { )} {downloadingTasks == undefined && } - {downloadingTasks && downloadingTasks.map((task) => )} + {downloadingTasks && + downloadingTasks.map((task) => )} {t("download.finished")} {tasks.map((task) => ( - + ))} {nextPageToken != undefined && ( diff --git a/frontend/src/component/Pages/Tasks/TaskCard.tsx b/frontend/src/component/Pages/Tasks/TaskCard.tsx index cfd468fc..49d74ac7 100644 --- a/frontend/src/component/Pages/Tasks/TaskCard.tsx +++ b/frontend/src/component/Pages/Tasks/TaskCard.tsx @@ -17,7 +17,8 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useInView } from "react-intersection-observer"; import { FileType } from "../../../api/explorer.ts"; -import { TaskResponse, TaskType } from "../../../api/workflow.ts"; +import { getTasksPhaseProgress } from "../../../api/api.ts"; +import { TaskResponse, TaskStatus, TaskType } from "../../../api/workflow.ts"; import { useAppDispatch } from "../../../redux/hooks.ts"; import { DefaultButton } from "../../Common/StyledComponents.tsx"; import FileIcon from "../../FileManager/Explorer/FileIcon.tsx"; @@ -96,6 +97,7 @@ export interface TaskCardProps { showProgress?: boolean; task?: TaskResponse; onLoad?: () => void; + onRetried?: () => void; } const taskIconsMap: { @@ -107,7 +109,7 @@ const taskIconsMap: { [TaskType.import]: ArrowImport, }; -const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => { +const TaskCard = ({ loading, showProgress, onLoad, onRetried, task }: TaskCardProps) => { const { t } = useTranslation(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); @@ -119,6 +121,42 @@ const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => { }); const [expanded, setExpanded] = useState(false); + const [transferPercent, setTransferPercent] = useState(undefined); + + // During the transfer phase the summary's download counters are frozen at + // 100%; poll the live phase progress so the card bar keeps moving while + // files are imported to storage. + const transferring = + task?.type === TaskType.remote_download && + task?.status === TaskStatus.processing && + task?.summary?.phase === "transfer"; + + useEffect(() => { + if (!transferring || !task) { + setTransferPercent(undefined); + return; + } + let cancelled = false; + const fetchProgress = () => { + dispatch(getTasksPhaseProgress(task.id)) + .then((res) => { + if (cancelled) { + return; + } + const upload = res?.["upload"]; + if (upload && upload.total > 0) { + setTransferPercent((100 * upload.current) / upload.total); + } + }) + .catch(() => {}); + }; + fetchProgress(); + const iv = setInterval(fetchProgress, 2500); + return () => { + cancelled = true; + clearInterval(iv); + }; + }, [transferring, task?.id]); useEffect(() => { if (!inView) { @@ -151,8 +189,10 @@ const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => { fullWidth percentage={ showProgress - ? ((task?.summary?.props?.download?.downloaded ?? 0) * 100) / - Math.max(task?.summary?.props?.download?.total ?? 0, 1) + ? transferring && transferPercent != undefined + ? transferPercent + : ((task?.summary?.props?.download?.downloaded ?? 0) * 100) / + Math.max(task?.summary?.props?.download?.total ?? 0, 1) : undefined } startIcon={ @@ -208,13 +248,14 @@ const TaskCard = ({ loading, showProgress, onLoad, task }: TaskCardProps) => { status={task.status} error={task.error} summary={task.summary} + transferPercent={transferPercent} /> )} - {task && } + {task && } ); }; diff --git a/frontend/src/component/Pages/Tasks/TaskDetail.tsx b/frontend/src/component/Pages/Tasks/TaskDetail.tsx index d3fd7c20..12c77e3e 100644 --- a/frontend/src/component/Pages/Tasks/TaskDetail.tsx +++ b/frontend/src/component/Pages/Tasks/TaskDetail.tsx @@ -10,9 +10,12 @@ import { TableRow, Typography, } from "@mui/material"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { sendCancelTask, sendDeleteTask, sendRetryTask } from "../../../api/api.ts"; import { TaskResponse, TaskStatus } from "../../../api/workflow.ts"; -import { StyledTableContainerPaper } from "../../Common/StyledComponents.tsx"; +import { useAppDispatch } from "../../../redux/hooks.ts"; +import { SecondaryLoadingButton, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx"; import DownloadFileList from "./DownloadFileList.tsx"; import TaskProgress from "./TaskProgress.tsx"; import TaskProps from "./TaskProps.tsx"; @@ -20,10 +23,44 @@ import TaskProps from "./TaskProps.tsx"; export interface TaskDetailProps { task: TaskResponse; downloading?: boolean; + onRetried?: () => void; } -const TaskDetail = ({ task, downloading }: TaskDetailProps) => { +const TaskDetail = ({ task, downloading, onRetried }: TaskDetailProps) => { const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [retrying, setRetrying] = useState(false); + const [canceling, setCanceling] = useState(false); + + const retry = () => { + setRetrying(true); + dispatch(sendRetryTask(task.id)) + .then(() => onRetried?.()) + .catch(() => {}) + .finally(() => setRetrying(false)); + }; + + const cancel = () => { + setCanceling(true); + dispatch(sendCancelTask(task.id)) + .then(() => onRetried?.()) + .catch(() => {}) + .finally(() => setCanceling(false)); + }; + + const [deleting, setDeleting] = useState(false); + + const deleteRecord = () => { + setDeleting(true); + dispatch(sendDeleteTask(task.id)) + .then(() => onRetried?.()) + .catch(() => {}) + .finally(() => setDeleting(false)); + }; + + const cancelable = task.status == TaskStatus.queued || task.status == TaskStatus.suspending; + const deletable = + task.status == TaskStatus.completed || task.status == TaskStatus.error || task.status == TaskStatus.canceled; return ( @@ -46,7 +83,48 @@ const TaskDetail = ({ task, downloading }: TaskDetailProps) => { })} )} - {task.status == TaskStatus.error && {task.error}} + {cancelable && ( + + {t("common:cancel")} + + )} + {deletable && ( + + {t("common:delete")} + + )} + {task.status == TaskStatus.error && ( + + {t("uploader.retry")} + + } + > + {task.error} + + )} { title={t("application:navbar.taskQueue")} /> {tasks.map((task) => ( - + ))} {nextPageToken != undefined && ( diff --git a/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx b/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx index 9f86107d..143d8148 100644 --- a/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx +++ b/frontend/src/component/Pages/Tasks/TaskSummaryStatus.tsx @@ -27,6 +27,8 @@ export interface TaskSummaryStatusProps { summary?: TaskSummary; error?: string; simplified?: boolean; + // Live transfer-phase progress percent for remote downloads. + transferPercent?: number; } interface TaskStatusContentProps { @@ -46,7 +48,7 @@ const TaskStatusContent = forwardRef(({ icon, title, color, ...props }: TaskStat ); }); -const TaskSummaryStatus = ({ type, status, summary, error, simplified }: TaskSummaryStatusProps) => { +const TaskSummaryStatus = ({ type, status, summary, error, simplified, transferPercent }: TaskSummaryStatusProps) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const theme = useTheme(); @@ -111,7 +113,11 @@ const TaskSummaryStatus = ({ type, status, summary, error, simplified }: TaskSum } else if (summary?.phase == "transfer") { return ( } color={theme.palette.primary.main} /> diff --git a/frontend/src/component/Uploader/Popup/UploadTask.tsx b/frontend/src/component/Uploader/Popup/UploadTask.tsx index 11385594..831fe21b 100644 --- a/frontend/src/component/Uploader/Popup/UploadTask.tsx +++ b/frontend/src/component/Uploader/Popup/UploadTask.tsx @@ -5,11 +5,13 @@ import MuiAccordionSummary from "@mui/material/AccordionSummary"; import Chip from "@mui/material/Chip"; import { lighten, useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; +import dayjs from "dayjs"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { FileType } from "../../../api/explorer.ts"; import { navigateToPath } from "../../../redux/thunks/filemanager.ts"; import { sizeToString } from "../../../util"; +import { formatDuration } from "../../../util/datetime.ts"; import { NoWrapBox } from "../../Common/StyledComponents.tsx"; import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx"; import ArrowClockwiseFilled from "../../Icons/ArrowClockwiseFilled.tsx"; @@ -70,6 +72,16 @@ const getSpeedText = (speed: number, speedAvg: number, useSpeedAvg: boolean) => return `${sizeToString(displayedSpeed ? displayedSpeed : 0)}/s`; }; +// ETA from remaining bytes over the displayed speed; empty when speed is +// unknown or zero. +const getEtaText = (speed: number, speedAvg: number, useSpeedAvg: boolean, loaded: number, total: number) => { + const displayedSpeed = useSpeedAvg ? speedAvg : speed; + if (!displayedSpeed || displayedSpeed <= 0 || total <= loaded) { + return ""; + } + return formatDuration(dayjs.duration(Math.ceil((total - loaded) / displayedSpeed), "seconds")); +}; + const getErrMsg = (error?: Error) => { if (error) { let errMsg = error.message; @@ -165,6 +177,7 @@ export default function UploadTask({ ); case Status.processing: if (progress) { + const eta = getEtaText(speed, speedAvg, useAvgSpeed, progress.total.loaded, progress.total.size); return (
{t("progressDescriptionFull", { @@ -172,6 +185,7 @@ export default function UploadTask({ uploaded: sizeToString(progress.total.loaded), total: sizeToString(progress.total.size), percentage: progress.total.percent.toFixed(2), + eta: eta ? t("etaSuffix", { eta }) : "", })}
); diff --git a/frontend/src/component/Uploader/core/index.ts b/frontend/src/component/Uploader/core/index.ts index 3d3021eb..5ccbeddf 100644 --- a/frontend/src/component/Uploader/core/index.ts +++ b/frontend/src/component/Uploader/core/index.ts @@ -169,11 +169,22 @@ export default class UploadManager { throw new UploaderError(UploaderErrorName.NoPolicySelected, "No policy selected."); } - this.fileInput.onchange = (ev: Event) => this.addFiles(ev, dst, resolve, reject); - this.directoryInput.onchange = (ev: Event) => this.addFiles(ev, dst, resolve, reject); - this.fileInput.value = ""; - this.directoryInput.value = ""; - type == SelectType.File ? this.fileInput.click() : this.directoryInput.click(); + const input = type == SelectType.File ? this.fileInput : this.directoryInput; + // Some mobile pickers deliver multi-selections via several `change` + // events; the first resolve would swallow the rest. Accumulate and + // resolve once the picker goes quiet (#3571). + let files: File[] = []; + let timer: ReturnType | undefined; + input.onchange = (ev: Event) => { + const target = ev.target as HTMLInputElement; + if (target?.files?.length) { + files = files.concat(Array.from(target.files)); + } + clearTimeout(timer); + timer = setTimeout(() => this.addFiles(files, dst, resolve, reject), 400); + }; + input.value = ""; + input.click(); }); }; diff --git a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx index c45cb94d..294710e6 100644 --- a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx +++ b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx @@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { confirmOperation } from "../../../redux/thunks/dialog.ts"; import { getEntityContent } from "../../../redux/thunks/file.ts"; import { saveCode } from "../../../redux/thunks/viewer.ts"; +import SessionManager, { UserSettings } from "../../../session/index.ts"; import { fileExtension } from "../../../util"; import { CascadingSubmenu } from "../../FileManager/ContextMenu/CascadingMenu.tsx"; import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; @@ -107,7 +108,9 @@ const CodeViewer = () => { const [anchorEl, setAnchorEl] = useState(null); const [optionAnchorEl, setOptionAnchorEl] = useState(null); const [language, setLng] = useState(null); + const [charsetState, setCharsetState] = useState(undefined); const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off"); + const [eol, setEol] = useState<"LF" | "CRLF">("LF"); const saveFunction = useRef<() => void>(() => {}); const closeViewer = useCallback(() => { @@ -137,11 +140,16 @@ const CodeViewer = () => { return; } + setCharsetState(charset); setLoaded(false); setOptionAnchorEl(null); dispatch(getEntityContent(viewerState.file, viewerState.version)) .then((res) => { - setValue(new TextDecoder(charset).decode(res)); + const content = new TextDecoder(charset).decode(res); + setValue(content); + // Preserve the file's existing line endings as the default + // (#3009): CRLF files keep CRLF, everything else uses LF. + setEol(content.includes("\r\n") ? "CRLF" : "LF"); setLoaded(true); }) .catch(() => { @@ -151,15 +159,37 @@ const CodeViewer = () => { [viewerState, closeViewer], ); + // Remember charset/language picks per extension (#2249). + const currentExt = fileExtension(viewerState?.file?.name ?? "") ?? ""; + + const pickCharset = useCallback( + (charset: string) => { + SessionManager.set(UserSettings.CodeCharsetPrefix + currentExt, charset); + loadContent(charset); + }, + [currentExt, loadContent], + ); + + const pickLanguage = useCallback( + (l: string) => { + SessionManager.set(UserSettings.CodeLanguagePrefix + currentExt, l); + setLng(l); + }, + [currentExt], + ); + useEffect(() => { if (!viewerState || !viewerState.open) { return; } + const ext = fileExtension(viewerState.file.name) ?? ""; + const rememberedCharset = SessionManager.get(UserSettings.CodeCharsetPrefix + ext); + const rememberedLng = SessionManager.get(UserSettings.CodeLanguagePrefix + ext); setLng(null); setSaved(true); - setLng(codePreviewSuffix[fileExtension(viewerState.file.name) ?? ""] ?? ""); - loadContent(); + setLng(rememberedLng ?? codePreviewSuffix[ext] ?? ""); + loadContent(rememberedCharset); }, [viewerState?.open]); const openMore = useCallback( @@ -188,7 +218,8 @@ const CodeViewer = () => { } setLoading(true); - dispatch(saveCode(value, viewerState.file, viewerState.version, saveAs)) + const eolChar = eol == "LF" ? "\n" : "\r\n"; + dispatch(saveCode(value.replace(/\r\n|\r|\n/g, eolChar), viewerState.file, viewerState.version, saveAs)) .then(() => { setSaved(true); }) @@ -196,7 +227,7 @@ const CodeViewer = () => { setLoading(false); }); }, - [value, viewerState], + [value, viewerState, eol], ); const onChange = useCallback((v: string) => { @@ -280,14 +311,19 @@ const CodeViewer = () => { > {allCharsets.map((charset) => ( - loadContent(charset)}> + pickCharset(charset)}> {charset} + {charset == (charsetState ?? "UTF-8") && ( + + + + )} ))} {allLng.map((l) => ( - setLng(l)}> + pickLanguage(l)}> {l} {l == language && ( @@ -298,6 +334,18 @@ const CodeViewer = () => { ))} + + {(["LF", "CRLF"] as const).map((e) => ( + setEol(e)} dense> + {e} + {e == eol && ( + + + + )} + + ))} + {t("fileManager.wordWrap")} {wordWrap === "on" && ( @@ -327,6 +375,7 @@ const CodeViewer = () => { }} value={value} language={language ?? ""} + eol={eol} onChange={(v) => onChange(v as string)} /> diff --git a/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx b/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx index b9496f62..cb9eb79d 100644 --- a/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx +++ b/frontend/src/component/Viewers/CodeViewer/MonacoEditor.tsx @@ -107,6 +107,11 @@ export interface MonacoEditorProps extends MonacoEditorBaseProps { minHeight?: string | number; onSave?: React.MutableRefObject<() => void>; + + /** + * Desired end-of-line sequence applied to the model via setEOL. + */ + eol?: "LF" | "CRLF"; } // ============ Diff Editor ============ @@ -206,6 +211,7 @@ function MonacoEditor({ className, uri, onSave, + eol, }: MonacoEditorProps) { const containerElement = useRef(null); @@ -322,6 +328,13 @@ function MonacoEditor({ } }, [language]); + useEffect(() => { + if (editor.current && eol) { + const model = editor.current.getModel(); + model?.setEOL(eol == "LF" ? monaco.editor.EndOfLineSequence.LF : monaco.editor.EndOfLineSequence.CRLF); + } + }, [eol]); + useEffect(() => { if (editor.current) { // Don't pass in the model on update because monaco crashes if we pass the model diff --git a/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx b/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx new file mode 100644 index 00000000..bccd5bbc --- /dev/null +++ b/frontend/src/component/Viewers/ModelViewer/ModelViewer.tsx @@ -0,0 +1,75 @@ +import { Suspense, useCallback, useEffect, useState } from "react"; +import { enqueueSnackbar } from "notistack"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { getFileEntityUrl } from "../../../api/api.ts"; +import { closeModelViewer } from "../../../redux/globalStateSlice.ts"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { getFileLinkedUri } from "../../../util"; +import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx"; +import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx"; + +const ModelViewerCanvas = React.lazy(() => import("./ModelViewerCanvas.tsx")); + +const ModelViewer = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const viewerState = useAppSelector((state) => state.globalState.modelViewer); + + const [loading, setLoading] = useState(false); + const [src, setSrc] = useState(""); + + useEffect(() => { + if (!viewerState || !viewerState.open) { + return; + } + + setSrc(""); + setLoading(true); + dispatch( + getFileEntityUrl({ + uris: [getFileLinkedUri(viewerState.file)], + entity: viewerState.version, + }), + ) + .then((res) => setSrc(res.urls[0].url)) + .catch(() => onClose()) + .finally(() => setLoading(false)); + }, [viewerState]); + + const onClose = useCallback(() => { + dispatch(closeModelViewer()); + }, [dispatch]); + + const onError = useCallback(() => { + enqueueSnackbar({ + message: t("fileManager.modelLoadFailed"), + variant: "error", + action: DefaultCloseAction, + }); + onClose(); + }, [onClose, t]); + + return ( + + {!src && } + {src && ( + }> + + + )} + + ); +}; + +export default ModelViewer; diff --git a/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx b/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx new file mode 100644 index 00000000..3ef43c58 --- /dev/null +++ b/frontend/src/component/Viewers/ModelViewer/ModelViewerCanvas.tsx @@ -0,0 +1,135 @@ +import { Box } from "@mui/material"; +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; +import { fileExtension } from "../../../util"; + +export interface ModelViewerCanvasProps { + src: string; + fileName: string; + onLoaded?: () => void; + onError?: () => void; +} + +const loadModel = (src: string, ext: string): Promise => { + switch (ext) { + case "gltf": + case "glb": + return new Promise((resolve, reject) => + new GLTFLoader().load(src, (g) => resolve(g.scene), undefined, reject), + ); + case "fbx": + return new Promise((resolve, reject) => new FBXLoader().load(src, resolve, undefined, reject)); + case "obj": + return new Promise((resolve, reject) => new OBJLoader().load(src, resolve, undefined, reject)); + default: + return Promise.reject(new Error(`unsupported model format: ${ext}`)); + } +}; + +const ModelViewerCanvas = ({ src, fileName, onLoaded, onError }: ModelViewerCanvasProps) => { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container || !src) { + return; + } + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.outputColorSpace = THREE.SRGBColorSpace; + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x1a1a1a); + + const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 5000); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + + scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2)); + const dir = new THREE.DirectionalLight(0xffffff, 1.5); + dir.position.set(3, 5, 4); + scene.add(dir); + scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x2a2a2a)); + + let disposed = false; + let model: THREE.Object3D | undefined; + loadModel(src, fileExtension(fileName) ?? "") + .then((obj) => { + if (disposed) { + return; + } + model = obj; + // Center on origin and scale so the model fits the view. + const box = new THREE.Box3().setFromObject(obj); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const radius = Math.max(size.x, size.y, size.z, 1e-6); + obj.position.sub(center); + const scale = 4 / radius; + obj.scale.setScalar(scale); + scene.add(obj); + + const sphere = new THREE.Box3().setFromObject(obj).getBoundingSphere(new THREE.Sphere()); + camera.position.set(sphere.radius * 1.8, sphere.radius * 1.2, sphere.radius * 1.8); + camera.near = sphere.radius / 100; + camera.far = sphere.radius * 100; + camera.updateProjectionMatrix(); + controls.target.set(0, 0, 0); + controls.update(); + onLoaded?.(); + }) + .catch(() => onError?.()); + + const resize = () => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) { + return; + } + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + }; + resize(); + const observer = new ResizeObserver(resize); + observer.observe(container); + + const animate = () => { + if (disposed) { + return; + } + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + return () => { + disposed = true; + observer.disconnect(); + controls.dispose(); + model?.traverse((o) => { + const mesh = o as THREE.Mesh; + mesh.geometry?.dispose(); + const material = mesh.material as THREE.Material | THREE.Material[] | undefined; + if (Array.isArray(material)) { + material.forEach((m) => m.dispose()); + } else { + material?.dispose(); + } + }); + renderer.dispose(); + container.removeChild(renderer.domElement); + }; + }, [src, fileName]); + + return ; +}; + +export default ModelViewerCanvas; diff --git a/frontend/src/component/Viewers/Video/Artplayer.tsx b/frontend/src/component/Viewers/Video/Artplayer.tsx index 47cf497c..0ccafd4d 100644 --- a/frontend/src/component/Viewers/Video/Artplayer.tsx +++ b/frontend/src/component/Viewers/Video/Artplayer.tsx @@ -82,7 +82,7 @@ const playM3u8 = hls.loadSource(url); hls.attachMedia(video); art.hls = hls; - art.on("destroy", () => hls.destroy()); + hookMediaDestroy(art, "hls"); } else if (video.canPlayType("application/vnd.apple.mpegurl")) { video.src = url; } else { @@ -106,12 +106,35 @@ const playFlv = (video: HTMLVideoElement, url: string, art: Artplayer) => { flv.attachMediaElement(video); flv.load(); art.flv = flv; - art.on("destroy", () => flv.destroy()); + hookMediaDestroy(art, "flv"); } else { art.notice.show = "Unsupported playback format: flv"; } }; +// Registers a single destroy hook per media type. switchUrl() re-invokes the +// customType handler and would otherwise stack one handler per player — +// destroying a stale, already-destroyed instance (mpegts null-derefs, #3139). +const hookedPlayers = new WeakMap>(); +const hookMediaDestroy = (art: Artplayer, key: "hls" | "flv") => { + let flags = hookedPlayers.get(art); + if (!flags) { + flags = new Set(); + hookedPlayers.set(art, flags); + } + if (flags.has(key)) { + return; + } + flags.add(key); + art.on("destroy", () => { + try { + art[key]?.destroy(); + } catch (e) { + console.warn(`Failed to destroy ${key} player:`, e); + } + }); +}; + export default function Player({ option, chapters, diff --git a/frontend/src/component/Viewers/Video/VideoViewer.tsx b/frontend/src/component/Viewers/Video/VideoViewer.tsx index eac93061..a0d5899e 100644 --- a/frontend/src/component/Viewers/Video/VideoViewer.tsx +++ b/frontend/src/component/Viewers/Video/VideoViewer.tsx @@ -469,6 +469,26 @@ const VideoViewer = () => { fontSize: `${subtitleStyle.fontSize ?? 20}px`, }, }, + settings: [ + { + name: "loop", + html: t("application:fileManager.loop"), + switch: false, + mounted(item: any) { + const enabled = this.storage.get("loop") === true; + if (enabled) { + item.switch = true; + this.loop = true; + } + }, + onSwitch(item: any) { + const next = !item.switch; + this.loop = next; + this.storage.set("loop", next); + return next; + }, + }, + ], plugins: [], lang: t("artPlayerLocaleCode", { ns: "common" }), // TODO: review }} diff --git a/frontend/src/redux/globalStateSlice.ts b/frontend/src/redux/globalStateSlice.ts index 6d8a8747..42b4d11b 100644 --- a/frontend/src/redux/globalStateSlice.ts +++ b/frontend/src/redux/globalStateSlice.ts @@ -245,6 +245,7 @@ export interface GlobalStateSlice { pdfViewer?: GeneralViewerState; customViewer?: CustomViewerState; epubViewer?: GeneralViewerState; + modelViewer?: GeneralViewerState; musicPlayer?: MusicPlayerState; excalidrawViewer?: GeneralViewerState; archiveViewer?: GeneralViewerState; @@ -423,6 +424,7 @@ export const globalStateSlice = createSlice({ state.pdfViewer = undefined; state.customViewer = undefined; state.epubViewer = undefined; + state.modelViewer = undefined; state.excalidrawViewer = undefined; state.archiveViewer = undefined; }, @@ -575,6 +577,12 @@ export const globalStateSlice = createSlice({ closeEpubViewer: (state) => { state.epubViewer && (state.epubViewer.open = false); }, + setModelViewer: (state, action: PayloadAction) => { + state.modelViewer = action.payload; + }, + closeModelViewer: (state) => { + state.modelViewer && (state.modelViewer.open = false); + }, setCustomViewer: (state, action: PayloadAction) => { state.customViewer = action.payload; }, @@ -874,6 +882,8 @@ export const { closeMusicPlayer, setEpubViewer, closeEpubViewer, + setModelViewer, + closeModelViewer, setCustomViewer, closeCustomViewer, setPdfViewer, diff --git a/frontend/src/redux/thunks/download.ts b/frontend/src/redux/thunks/download.ts index 64a816d1..bf589811 100644 --- a/frontend/src/redux/thunks/download.ts +++ b/frontend/src/redux/thunks/download.ts @@ -2,8 +2,8 @@ import dayjs from "dayjs"; import i18next from "i18next"; import { closeSnackbar, enqueueSnackbar } from "notistack"; import streamSaver from "streamsaver"; -import { getFileEntityUrl } from "../../api/api.ts"; -import { FileResponse, FileType, Metadata } from "../../api/explorer.ts"; +import { getFileEntityUrl, getFileList } from "../../api/api.ts"; +import { FileResponse, FileType, ListResponse, Metadata } from "../../api/explorer.ts"; import { GroupPermission } from "../../api/user.ts"; import { DefaultCloseAction, @@ -51,6 +51,32 @@ export function downloadFiles(index: number, files: FileResponse[]): AppThunk { }; } +// downloadAll fetches every page of the current directory listing and +// downloads the whole set — the visible list may be only the first page. +export function downloadAll(index: number): AppThunk { + return async (dispatch, getState) => { + const path = getState().fileManager[index]?.path; + if (!path) { + return; + } + + const files: FileResponse[] = []; + let token: string | undefined = undefined; + do { + const res: ListResponse = await dispatch( + getFileList({ uri: path, next_page_token: token, page_size: 1000 }), + ); + files.push(...(res.files ?? [])); + token = res.pagination?.next_token; + } while (token); + + if (files.length == 0) { + return; + } + await dispatch(downloadFiles(index, files)); + }; +} + export function downloadMultipleFiles(files: FileResponse[]): AppThunk { return async (dispatch, _getState) => { // Prepare download options @@ -102,7 +128,45 @@ export function backendBatchDownload(files: FileResponse[]): AppThunk { "application:fileManager.preparingBathDownload", ); - window.location.assign(downloadUrl.urls[0].url); + window.location.assign(await dispatch(pickDownloadRoute(downloadUrl.urls[0].url))); + }; +} + +// pickDownloadRoute lets the user choose one of the admin-configured CDN +// mirror endpoints (#2987); the signed URL keeps its path+query so the +// signature stays valid through the CDN. Cancel falls back to direct. +export function pickDownloadRoute(url: string): AppThunk> { + return async (dispatch, getState) => { + const routes = getState().siteConfig.basic.config.download_cdn_routes; + if (!routes || routes.length === 0) { + return url; + } + + const options: DialogSelectOption[] = [ + { + value: -1, + name: i18next.t("fileManager.downloadRouteDirect"), + description: i18next.t("fileManager.downloadRouteDirectDescription"), + }, + ...routes.map((r, i): DialogSelectOption => ({ value: i, name: r.name, description: r.url })), + ]; + + let picked: number; + try { + picked = (await dispatch(selectOption(options, "fileManager.selectDownloadRoute"))) as number; + } catch { + return url; + } + if (picked < 0 || picked >= routes.length) { + return url; + } + + try { + const u = new URL(url); + return routes[picked].url.replace(/\/+$/, "") + u.pathname + u.search + u.hash; + } catch { + return url; + } }; } @@ -527,11 +591,12 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string) "application:fileManager.preparingDownload", ); + const downloadUrl = await dispatch(pickDownloadRoute(urlRes.urls[0].url)); const streamSaverName = urlRes.urls[0].stream_saver_display_name; if (streamSaverName) { // remove streamSaverParam from query const fileStream = streamSaver.createWriteStream(streamSaverName); - const res = await fetch(urlRes.urls[0].url); + const res = await fetch(downloadUrl); const readableStream = res.body; if (!readableStream) { return; @@ -549,7 +614,7 @@ export function downloadSingleFile(file: FileResponse, preferredEntity?: string) return readableStream.pipeTo(fileStream).finally(() => closeSnackbar(downloadingSnackbar)); } } else { - window.location.assign(urlRes.urls[0].url); + window.location.assign(downloadUrl); } }; } diff --git a/frontend/src/redux/thunks/file.ts b/frontend/src/redux/thunks/file.ts index e7865b40..ea1811b5 100644 --- a/frontend/src/redux/thunks/file.ts +++ b/frontend/src/redux/thunks/file.ts @@ -6,6 +6,7 @@ import { getFileEntityUrl, getFileList, getFileThumb, + isNameConflictBatchError, sendCreateFile, sendDeleteFiles, sendEmptyTrash, @@ -75,7 +76,7 @@ import { } from "../globalStateSlice.ts"; import { ConfigLoadState, Viewers } from "../siteConfigSlice.ts"; import { AppThunk } from "../store.ts"; -import { confirmOperation, deleteConfirmation, renameForm, requestCreateNew, selectPath } from "./dialog.ts"; +import { confirmOperation, deleteConfirmation, renameForm, requestCreateNew, selectOption, selectPath } from "./dialog.ts"; import { downloadSingleFile } from "./download.ts"; import { navigateToPath, refreshFileList, updateUserCapacity } from "./filemanager.ts"; import { queueLoadShareInfo } from "./share.ts"; @@ -380,6 +381,7 @@ export function submitRenameFile(index: number, file: FileResponse, newName: str sendRenameFile({ uri: file.path, new_name: newName, + expect_id: file.id, }), ); } catch (e) { @@ -665,14 +667,47 @@ export function moveFiles(index: number, src: FileResponse[], dst: string, isCop return; } + const moveReq = (onConflict?: "skip" | "overwrite") => ({ + uris: src.map((f) => f.path), + dst, + copy: isCopy, + expect_ids: src.map((f) => f.id), + on_conflict: onConflict, + }); + const moveLabel = isCopy ? "application:modals.processingCopying" : "application:modals.processingMoving"; + let success = true; try { - await longRunningTaskWithSnackbar( - dispatch(sendMoveFile({ uris: src.map((f) => f.path), dst, copy: isCopy })), - isCopy ? "application:modals.processingCopying" : "application:modals.processingMoving", - ); + await longRunningTaskWithSnackbar(dispatch(sendMoveFile(moveReq())), moveLabel); } catch (e) { success = false; + if (isNameConflictBatchError(e as Error)) { + const onConflict = await dispatch( + selectOption( + [ + { + name: i18next.t("application:modals.overwrite"), + description: i18next.t("application:modals.conflictOverwriteDes"), + value: "overwrite", + }, + { + name: i18next.t("application:modals.skip"), + description: i18next.t("application:modals.conflictSkipDes"), + value: "skip", + }, + ], + "application:modals.nameConflict", + ), + ).catch(() => undefined); + if (onConflict == "skip" || onConflict == "overwrite") { + try { + await longRunningTaskWithSnackbar(dispatch(sendMoveFile(moveReq(onConflict))), moveLabel); + success = true; + } catch (e) { + // Retried operation failed; error snackbar already shown. + } + } + } } if (isCopy) { diff --git a/frontend/src/redux/thunks/share.ts b/frontend/src/redux/thunks/share.ts index fcdbe4b5..05c526fe 100644 --- a/frontend/src/redux/thunks/share.ts +++ b/frontend/src/redux/thunks/share.ts @@ -34,6 +34,7 @@ export function createOrUpdateShareLink( allow_edit: setting.allow_edit, preview_only: setting.preview_only, upload_only: setting.upload_only, + note: setting.note?.trim() || undefined, downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined, expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined, }; diff --git a/frontend/src/redux/thunks/viewer.ts b/frontend/src/redux/thunks/viewer.ts index 4ecd68d4..00336e96 100644 --- a/frontend/src/redux/thunks/viewer.ts +++ b/frontend/src/redux/thunks/viewer.ts @@ -23,6 +23,7 @@ import { setImageEditor, setImageViewer, setMarkdownViewer, + setModelViewer, setMusicPlayer, setPdfViewer, setPhotopeaViewer, @@ -54,6 +55,7 @@ export const builtInViewers = { music: "music", excalidraw: "excalidraw", archive: "archive", + model3d: "model3d", }; export function openViewers( @@ -72,11 +74,17 @@ export function openViewers( const ext = fileExtension(file.name) ?? ""; const entitySize = size ?? file.size; - // Try user preference - const userPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext); - if (!ignorePreference && userPreference && ViewersByID[userPreference]) { - dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion)); - return; + // Try user preference — the server-synced map wins over the per-device + // localStorage copy so the choice follows the user across devices. A + // stale server entry (viewer removed site-wide) falls back to local. + if (!ignorePreference) { + const serverPreference = SessionManager.currentUser()?.preferred_viewers?.[ext]; + const localPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext); + const userPreference = [serverPreference, localPreference].find((id) => id && ViewersByID[id]); + if (userPreference) { + dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion)); + return; + } } const viewerOptions = Viewers[ext]; @@ -239,6 +247,15 @@ export function openViewer( }), ); break; + case builtInViewers.model3d: + dispatch( + setModelViewer({ + open: true, + file, + version: preferredVersion ?? primaryEntity, + }), + ); + break; case builtInViewers.archive: dispatch( setArchiveViewer({ diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index d3270cdb..93cc7d55 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -4,6 +4,7 @@ import ErrorBoundary from "../component/Common/ErrorBoundary.tsx"; import HeadlessFrame from "../component/Frame/HeadlessFrame.tsx"; import { HomeRedirect } from "../component/Pages/HomeRedirect.tsx"; import Activate from "../component/Pages/Login/Activate.tsx"; +import ActivateEmailChange from "../component/Pages/Login/ActivateEmailChange.tsx"; import Authorize from "../component/Pages/Login/Authorize.tsx"; import Reset from "../component/Pages/Login/Reset.tsx"; import SessionIntro from "../component/Pages/Login/SessionIntro.tsx"; @@ -41,6 +42,10 @@ export const router = createBrowserRouter([ path: "activate", element: , }, + { + path: "activate_email", + element: , + }, { path: "reset", element: , diff --git a/frontend/src/session/index.ts b/frontend/src/session/index.ts index 2d68e9cc..e6b37600 100644 --- a/frontend/src/session/index.ts +++ b/frontend/src/session/index.ts @@ -25,6 +25,8 @@ export enum UserSettings { BookLocationPrefix = "book_location_", MusicVolume = "music_volume", OpenWithPrefix = "open_with_", + CodeCharsetPrefix = "code_charset_", + CodeLanguagePrefix = "code_language_", ConcurrentLimit = "concurrent_limit", UseAvgSpeed = "use_avg_speed", TaskFilter = "task_filter", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5cf35793..31e85433 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1297,6 +1297,11 @@ react-devtools-inline "4.4.0" react-is "^17.0.2" +"@dimforge/rapier3d-compat@~0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389" + integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== + "@emotion/babel-plugin@^11.11.0": version "11.11.0" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz" @@ -3800,6 +3805,11 @@ dependencies: tippy.js "^6.3.1" +"@tweenjs/tween.js@~23.1.3": + version "23.1.3" + resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d" + integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== + "@types/acorn@^4.0.0": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22" @@ -4056,6 +4066,11 @@ resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz" integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== +"@types/stats.js@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e" + integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== + "@types/streamsaver@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/streamsaver/-/streamsaver-2.0.4.tgz#b57e47f9246dc0ed69378f7d1387857c94043ecf" @@ -4073,6 +4088,18 @@ dependencies: "@types/geojson" "*" +"@types/three@^0.186.0": + version "0.186.0" + resolved "https://registry.yarnpkg.com/@types/three/-/three-0.186.0.tgz#fc936323d7eeb885052fd5de352e3772f488ea99" + integrity sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ== + dependencies: + "@dimforge/rapier3d-compat" "~0.12.0" + "@tweenjs/tween.js" "~23.1.3" + "@types/stats.js" "*" + "@types/webxr" ">=0.5.17" + fflate "~0.8.3" + meshoptimizer "~1.1.1" + "@types/trusted-types@^2.0.2": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -4103,6 +4130,11 @@ resolved "https://registry.yarnpkg.com/@types/webappsec-credential-management/-/webappsec-credential-management-0.6.9.tgz#c6c17c631afdfad5859deb69c6a106886b263f73" integrity sha512-4uDknRaO9G1fonoEALN6SDYvyRb7luVYP7lm7y0OkdVdMcU7rIh+Nq/VepDe0arkh+gfUzwCr5cNfqmKm+x5wA== +"@types/webxr@>=0.5.17": + version "0.5.24" + resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44" + integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg== + "@types/wicg-file-system-access@^2023.10.5": version "2023.10.5" resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz#14b3c25eb4d914b5734795bdea71da229f918b9d" @@ -6177,6 +6209,11 @@ fdir@^6.4.3: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72" integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw== +fflate@~0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -7702,6 +7739,11 @@ mermaid@10.9.3: uuid "^9.0.0" web-worker "^1.2.0" +meshoptimizer@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz#20c7d050d59bdc573154814f6a37d47d89908cca" + integrity sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g== + micromark-core-commonmark@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" @@ -9913,6 +9955,11 @@ terser@^5.17.4: commander "^2.20.0" source-map-support "~0.5.20" +three@^0.186.0: + version "0.186.0" + resolved "https://registry.yarnpkg.com/three/-/three-0.186.0.tgz#08f70ce80dffa9247a567b421165bec630e86f8d" + integrity sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ== + timeago-react@^3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/timeago-react/-/timeago-react-3.0.6.tgz#d1b55af67ed21b1c5429b9ba8043d0d0f7dab295" diff --git a/go.mod b/go.mod index 8cbde9dd..4a2a1332 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( 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.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 github.com/dsoprea/go-heic-exif-extractor v0.0.0-20210512044107-62067e44c235 @@ -32,7 +31,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gomodule/redigo v1.9.2 github.com/google/go-querystring v1.1.0 - 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.3 @@ -62,7 +60,6 @@ require ( golang.org/x/image v0.41.0 golang.org/x/text v0.37.0 golang.org/x/time v0.12.0 - golang.org/x/tools v0.44.0 modernc.org/sqlite v1.30.0 ) @@ -105,9 +102,8 @@ require ( github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl/v2 v2.13.0 // indirect @@ -136,6 +132,9 @@ require ( 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/sergi/go-diff v1.2.0 // indirect + github.com/smartystreets/assertions v1.0.0 // indirect + github.com/smartystreets/goconvey v1.6.4 // 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 @@ -152,6 +151,7 @@ require ( golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/ini.v1 v1.51.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/go.sum b/go.sum index f1355df4..21399bc7 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,11 @@ ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 h1:GwdJbXydHCYPedeeLt4x/lrlIISQ4JTH1mRWuE5ZZ14= ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43/go.mod h1:uj3pm+hUTVN/X5yfdBexHlZv+1Xu5u5ZbZx7+CDavNU= -bazil.org/fuse v0.0.0-20180421153158-65cc252bf669/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= -bitbucket.org/creachadair/shell v0.0.6/go.mod h1:8Qqi/cYk7vPnsOePHroKXDJYmb5x7ENhtiFtfZq8K+M= -bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= @@ -35,117 +30,45 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/spanner v1.17.0/go.mod h1:+17t2ixFwRG4lWRwE+5kipDR9Ef07Jkmc8z0IbMDKUs= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -code.gitea.io/sdk/gitea v0.11.3/go.mod h1:z3uwDV/b9Ls47NGukYM9XhnHtqPh/J+t40lsUrR6JDY= -contrib.go.opencensus.io/exporter/aws v0.0.0-20181029163544-2befc13012d0/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= -contrib.go.opencensus.io/exporter/ocagent v0.5.0/go.mod h1:ImxhfLRpxoYiSq891pBrLVhN+qmP8BTVvdH2YLs7Gl0= -contrib.go.opencensus.io/exporter/stackdriver v0.12.1/go.mod h1:iwB6wGarfphGGe/e5CWqyUk/cLzKnWsOKPVW3no6OTw= -contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc= -contrib.go.opencensus.io/integrations/ocsql v0.1.4/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= -contrib.go.opencensus.io/resource v0.1.1/go.mod h1:F361eGI91LCmW1I/Saf+rX0+OFcigGlFvXwEGEnkRLA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= entgo.io/ent v0.13.0 h1:DclxWczaCpyiKn6ZWVcJjq1zIKtJ11iNKy+08lNYsJE= entgo.io/ent v0.13.0/go.mod h1:+oU8oGna69xy29O+g+NEz+/TM7yJDhQQGJfuOWq1pT8= -github.com/Azure/azure-amqp-common-go/v2 v2.1.0/go.mod h1:R8rea+gJRuJR6QxTir/XuEd+YuKoUiazDC/N96FiDEU= -github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= -github.com/Azure/azure-sdk-for-go v29.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go v30.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-service-bus-go v0.9.1/go.mod h1:yzBx6/BUGfjfeqbRZny9AQIbIe3AcV9WZbAdpkoXOa0= -github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0= -github.com/Azure/go-autorest v12.0.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.2/go.mod h1:af5vUNlDNkCjOZeSGFgIJxDje9qdjsO6hshx0gTmZt4= -github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20191009163259-e802c2cb94ae/go.mod h1:mjwGPas4yKduTyubHvD1Atl9r1rUq8DfVy+gkVvZ+oo= -github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig v2.15.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= -github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 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= -github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= -github.com/alecthomas/kingpin v2.2.6+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y= 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= -github.com/apache/beam v2.28.0+incompatible/go.mod h1:/8NX3Qi8vGstDLLaeaU7+lzVEu/ACaQhYjeefzQ0y1o= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/apex/log v1.1.4/go.mod h1:AlpoD9aScyQfJDVHmLMEcx4oU6LqzkWp4Mg9GdAcEvQ= -github.com/apex/logs v0.0.4/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= -github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= -github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= -github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= -github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= -github.com/aws/aws-sdk-go v1.19.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.19.45/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.23.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.25.11/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.34.0 h1:brux2dRrlwCF5JhTL7MUT3WUwo9zfDHZZp3+g3Mvlmo= github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= -github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= -github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= 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= @@ -158,73 +81,28 @@ github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7 github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/caarlos0/ctrlc v1.0.0/go.mod h1:CdXpj4rmq0q/1Eb44M9zi2nKB0QraNKuRGYGrrHhcQw= -github.com/campoy/unique v0.0.0-20180121183637-88950e537e7e/go.mod h1:9IOqJGCPMSc6E5ydlp5NIonxObaeu/Iub/X03EKPVYo= -github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= -github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= -github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY= -github.com/cloudflare/cfssl v1.6.1 h1:aIOUjpeuDJOpWjVJFP2ByplF53OgqG8I1S40Ggdlk3g= -github.com/cloudflare/cfssl v1.6.1/go.mod h1:ENhCj4Z17+bY2XikpxVmTHDg/C2IsG2Q0ZBeXpAqhCk= -github.com/cloudflare/redoctober v0.0.0-20201013214028-99c99a8e7544/go.mod h1:6Se34jNoqrd8bTxrmJB2Bg2aoZ2CdSXonils9NsiNgo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20210322005330-6414d713912e/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= -github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= -github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= -github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= -github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= -github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8= -github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA= github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= -github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 h1:simG0vMYFvNriGhaaat7QVVkaVkXzvqcohaBoLZl9Hg= github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM= -github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -262,49 +140,27 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU= github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw= github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8= -github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= -github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= -github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.3.0-java/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v0.6.1/go.mod h1:txg5va2Qkip90uYoSKH+nkAAmXrb2j3iq4FLwdrCbXQ= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= -github.com/etcd-io/gofail v0.0.0-20190801230047-ad7f989257ca/go.mod h1:49H/RkXP8pKaZy4h0d+NW16rSLhyVBt4o6VLJbmOqDE= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= -github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fullstorydev/grpcurl v1.8.0/go.mod h1:Mn2jWbdMrQGJQ8UD62uNyMumT2acsZUCkZIqFxsQf1o= -github.com/fullstorydev/grpcurl v1.8.1/go.mod h1:3BWhvHZwNO7iLXaQlojdg5NA6SxUDePli4ecpK1N7gw= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg= github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro= github.com/gin-contrib/gzip v1.2.4 h1:yNz4EhPC2kHSZJD1oc1zwp7MLEhEZ3goQeGM3a1b6jU= @@ -319,7 +175,6 @@ github.com/gin-contrib/static v0.0.0-20191128031702-f81c604d8ac2/go.mod h1:VhW/C github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= @@ -328,15 +183,10 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ini/ini v1.50.0 h1:ogX6RS8VstVN8MJcwhEP78hHhWaI3klN02+97bByabY= github.com/go-ini/ini v1.50.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -355,8 +205,6 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.8.0/go.mod h1:9JhgTzTaE31GZDpH/HSvHiRJrJ3iKAgqqH0Bl/Ocjdk= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= -github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= -github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= @@ -374,17 +222,10 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -394,13 +235,9 @@ github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgR github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -425,16 +262,11 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= -github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= -github.com/google/certificate-transparency-go v1.1.2-0.20210422104406-9f33727a7a18/go.mod h1:6CKh9dscIRoqc2kC6YUFICHZMT9NrClyPrRVFrdw1QQ= -github.com/google/certificate-transparency-go v1.1.2-0.20210511102531-373a877eec92/go.mod h1:kXWPsHVPSKVuxPPG69BRtumCbAW537FydV/GH89oBhM= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -448,21 +280,15 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= -github.com/google/go-licenses v0.0.0-20210329231322-ce1d9163b77d/go.mod h1:+TYOmkVoJOpwnS0wfdsJCV9CoD5nJYsHoFk/0CrTK4M= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/go-replayers/grpcreplay v0.1.0/go.mod h1:8Ig2Idjpr6gifRd6pNVggX6TC1Zw6Jx74AKp7QNH2QE= -github.com/google/go-replayers/httpreplay v0.1.0/go.mod h1:YKZViNhiGgqdBlUbI2MwGpq4pXxNmhJLPHQ7cv2b5no= github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/licenseclassifier v0.0.0-20210325184830-bb04aff29e72/go.mod h1:qsqn2hxC+vURpyBRygGUuinTO42MFRLcsmQ/P8v94+M= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian v2.1.1-0.20190517191504-25dcb96d9e51+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -479,109 +305,42 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg= -github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= -github.com/google/trillian v1.3.14-0.20210409160123-c5ea3abd4a41/go.mod h1:1dPv0CUjNQVFEDuAUFhZql16pw/VlPgaX8qj+g5pVzQ= -github.com/google/trillian v1.3.14-0.20210428093031-b4ddea2e86b1/go.mod h1:FdIJX+NoDk/dIN2ZxTyz5nAJWgf+NSSSriPAMThChTY= -github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/wire v0.3.0/go.mod h1:i1DMg/Lu8Sz5yYl25iOdmc5CT5qusaa+zmRWs16741s= -github.com/googleapis/gax-go v2.0.2+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gordonklaus/ineffassign v0.0.0-20200309095847-7953dde2c7bf/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= -github.com/goreleaser/goreleaser v0.134.0/go.mod h1:ZT6Y2rSYa6NxQzIsdfWWNWAlYGXGbreo66NmE+3X3WQ= -github.com/goreleaser/nfpm v1.2.1/go.mod h1:TtWrABZozuLOttX2uDlYyECfQX7x5XYkVxhjYcR6G9w= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= -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.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= -github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.9.2/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.14.6/go.mod h1:zdiPV4Yse/1gnckTHtghG4GkDEdKCRJduHpTxT3/jcw= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= -github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible h1:/2MdLc7zHJqzV7J2uVGaoGymVobB/OHC8wmEyWRaK68= github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s= -github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= -github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/imdario/mergo v0.3.4/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/jarcoal/httpmock v1.0.5/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= -github.com/jhump/protoreflect v1.6.1/go.mod h1:RZQ/lnuN+zqeRVpQigTwO6o0AJUkxbnSnpuG7toUTG4= -github.com/jhump/protoreflect v1.8.2/go.mod h1:7GcYQDdMU/O/BBrl/cX6PNHpXh6cenjd8pneu5yW7Tg= github.com/jinzhu/gorm v1.9.11 h1:gaHGvE+UnWGlbWG4Y3FUwY1EcZ5n6S9WtqBA/uySMLE= github.com/jinzhu/gorm v1.9.11/go.mod h1:bu/pK8szGZ2puuErfU0RwyeNdsf3e6nCX/noXaVxkfw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -589,24 +348,11 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= -github.com/jmhodges/clock v0.0.0-20160418191101-880ee4c33548/go.mod h1:hGT6jSUVzF6no3QaDSMLGLEHtHSBSefs+MgcDWnmhmo= -github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= -github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -616,17 +362,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/juju/ratelimit v1.0.1 h1:+7AIFJVQ0EQgq/K9+0Krm7m530Du7tIz0METWzN0RgY= github.com/juju/ratelimit v1.0.1/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/kisom/goutils v1.4.3/go.mod h1:Lp5qrquG7yhYnWzZCI/68Pa/GpFynw//od6EkGnWpac= 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= @@ -635,8 +372,6 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -644,208 +379,83 @@ github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NB github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ks3sdklib/aws-sdk-go v1.6.2 h1:nxtaaU3hDD5x6gmoxs/qijSJqZrjFapYYuTiVCEgobA= github.com/ks3sdklib/aws-sdk-go v1.6.2/go.mod h1:jGcsV0dJgMmStAyqjkKVUu6F167pAXYZAS3LqoZMmtM= -github.com/kylelemons/go-gypsy v1.0.0/go.mod h1:chkXM0zjdpXOiqkCW1XcCHDfjfk14PH2KKkQWxfJUcU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= -github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= -github.com/lyft/protoc-gen-star v0.5.1/go.mod h1:9toiA3cC7z5uVbODF7kEQ91Xn7XNFkVUl+SrEe+ZORU= -github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/meilisearch/meilisearch-go v0.36.0 h1:N1etykTektXt5KPcSbhBO0d5Xx5NaKj4pJWEM7WA5dI= github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6Vk9IivcwKocWRuW7W78= -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= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= 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= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2 h1:daZqE/T/yEoKIQNd3rwNeLsiS0VpZFfJulR0t/rtgAE= github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY= github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= github.com/mozillazg/go-httpheader v0.4.0 h1:aBn6aRXtFzyDLZ4VIRLsZbbJloagQfMnCiYgOq6hK4w= github.com/mozillazg/go-httpheader v0.4.0/go.mod h1:PuT8h0pw6efvp8ZeUec1Rs7dwjK08bt6gKSReGMqtdA= -github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007/go.mod h1:m2XC9Qq0AlmmVksL6FktJCdTYyLk7V3fKyp0sl1yWQo= -github.com/mwitkow/go-proto-validators v0.2.0/go.mod h1:ZfA1hW+UH/2ZHOWvQ3HnQaU0DtnpXu850MZiy+YUgcc= -github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= -github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= -github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= -github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= -github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 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.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= -github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= -github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= -github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= -github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= -github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= -github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= -github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= -github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= -github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= -github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= -github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= -github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= -github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= 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= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= -github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= -github.com/prometheus/common v0.24.0/go.mod h1:H6QK/N6XVT42whUeIdI3dp36w49c+/iMDk7UAI2qm7Q= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/pseudomuto/protoc-gen-doc v1.4.1/go.mod h1:exDTOVwqpp30eV/EDPFLZy3Pwr2sn6hBC1WIYH/UbIg= -github.com/pseudomuto/protokit v0.2.0/go.mod h1:2PdH30hxVHsup8KpBTOXTBeMVhJZVio3Q8ViKSAXT0Q= github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk= github.com/qiniu/go-sdk/v7 v7.19.0 h1:k3AzDPil8QHIQnki6xXt4YRAjE52oRoBUXQ4bV+Wc5U= github.com/qiniu/go-sdk/v7 v7.19.0/go.mod h1:nqoYCNo53ZlGA521RvRethvxUDvXKt4gtYXOwye868w= @@ -859,155 +469,75 @@ github.com/rafaeljusto/redigomock v0.0.0-20191117212112-00b2509252a1/go.mod h1:J github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= -github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= -github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0 h1:UVQPSSmc3qtTi+zPPkCXvZX9VvW/xT/NsRvKfwY81a8= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/soheilhy/cmux v0.1.5-0.20210205191134-5ec6847320e5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -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= -github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= -github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= -github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= -github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v0.0.0-20170130113145-4d4bfba8f1d1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= github.com/tencentyun/cos-go-sdk-v5 v0.7.54 h1:FRamEhNBbSeggyYfWfzFejTLftgbICocSYFk4PKTSV4= github.com/tencentyun/cos-go-sdk-v5 v0.7.54/go.mod h1:UN+VdbCl1hg+kKi5RXqZgaP+Boqfmk+D04GRc4XFk70= -github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= -github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= -github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= -github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4= -github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20200427203606-3cfed13b9966/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 h1:rB0J+hLNltG1Qv+UF+MkdFz89XMps5BOAFJN4xWjc+s= github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90/go.mod h1:BUbeWZiieNxAuuADTBNb3/aeje6on3DhU3rpWsQSB1E= -github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -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.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= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= -github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= -github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xanzy/go-gitlab v0.31.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= -github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= -github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1017,28 +547,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= -github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= -github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= -github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= -github.com/zmap/zcrypto v0.0.0-20210123152837-9cf5beac6d91/go.mod h1:R/deQh6+tSWlgI9tb4jNmXxn8nSCabl5ZQsBX9//I/E= -github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk= -github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= -go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= -go.etcd.io/etcd/api/v3 v3.5.0-alpha.0/go.mod h1:mPcW6aZJukV6Aa81LSKpBjQXTWlXB5r74ymPoSWa3Sw= -go.etcd.io/etcd/client/v2 v2.305.0-alpha.0/go.mod h1:kdV+xzCJ3luEBSIeQyB/OEKkWKd8Zkux4sbDeANrosU= -go.etcd.io/etcd/client/v3 v3.5.0-alpha.0/go.mod h1:wKt7jgDgf/OfKiYmCq5WFGxOFAkVMLxiiXgLDFhECr8= -go.etcd.io/etcd/etcdctl/v3 v3.5.0-alpha.0/go.mod h1:YPwSaBciV5G6Gpt435AasAG3ROetZsKNUzibRa/++oo= -go.etcd.io/etcd/pkg/v3 v3.5.0-alpha.0/go.mod h1:tV31atvwzcybuqejDoY3oaNRTtlD2l/Ot78Pc9w7DMY= -go.etcd.io/etcd/raft/v3 v3.5.0-alpha.0/go.mod h1:FAwse6Zlm5v4tEWZaTjmNhe17Int4Oxbu7+2r0DiD3w= -go.etcd.io/etcd/server/v3 v3.5.0-alpha.0/go.mod h1:tsKetYpt980ZTpzl/gb+UOJj9RkIyCb1u4wjzMg90BQ= -go.etcd.io/etcd/tests/v3 v3.5.0-alpha.0/go.mod h1:HnrHxjyCuZ8YDt8PYVyQQ5d1ZQfzJVEtQWllr5Vp/30= -go.etcd.io/etcd/v3 v3.5.0-alpha.0/go.mod h1:JZ79d3LV6NUfPjUxXrpiFAYcjhT+06qqw+i28snx8To= -go.opencensus.io v0.15.0/go.mod h1:UffZAU+4sDEINUGP/B7UfBBkq4fqLu9zXAX7ke6CHW0= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -1046,46 +555,20 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= -gocloud.dev v0.19.0/go.mod h1:SmKwiR8YwIMMJvQBKLsC3fHNyMwXLw3PMDO+VVteJMI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.0.0-20180501155221-613d6eafa307/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191117063200-497ca9f6d64f/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= @@ -1101,7 +584,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk= golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -1120,7 +602,6 @@ golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRu golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -1131,18 +612,13 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1150,17 +626,10 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191119073136-fc4aabc6c914/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -1169,7 +638,6 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -1180,12 +648,10 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= @@ -1193,9 +659,7 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1205,13 +669,10 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1222,36 +683,23 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190620070143-6f217b454f45/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191119060738-e882bf8e40c2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1260,31 +708,21 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210511113859-b0526f3d8744/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1297,7 +735,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1315,48 +752,35 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191010075000-0337d82405ff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191118222007-07fc4c7f2b98/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -1368,24 +792,18 @@ golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200426102838-f3a5411a4c3b/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201014170642-d1624618ad65/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= @@ -1396,12 +814,9 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.6.0/go.mod h1:btoxGiFvQNVUZQ8W08zLtrVS08CNpINPEfxXxgJL1Q4= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.10.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= @@ -1419,28 +834,19 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.45.0/go.mod h1:ISLIJCedJolbZvDfAk+Ctuq5hf+aJ33WgtUsfyFoLXA= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20181107211654-5fc9ac540362/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190620144150-6af8c5fc6601/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= @@ -1457,10 +863,8 @@ google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= @@ -1476,20 +880,12 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210331142528-b7513248f0ba/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210413151531-c14fb6ef47c3/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210510173355-fb37daa5cd7a/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= @@ -1499,15 +895,11 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1518,7 +910,6 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.25.1-0.20200805231151-a709e31e5d12/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= @@ -1529,29 +920,17 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= -gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= -gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -1567,7 +946,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.1.4/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs= @@ -1594,10 +972,6 @@ modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -pack.ag/amqp v0.11.2/go.mod h1:4/cbmt4EJXSKlG6LCfWHoqmN0uFdy5i/+YFz+fTfhV4= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= -sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/inventory/ban_expires_test.go b/inventory/ban_expires_test.go new file mode 100644 index 00000000..20691a5d --- /dev/null +++ b/inventory/ban_expires_test.go @@ -0,0 +1,98 @@ +package inventory + +import ( + "context" + "testing" + "time" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + entuser "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/stretchr/testify/require" +) + +func TestLiftExpiredBan(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() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + past := time.Now().Add(-time.Hour) + future := time.Now().Add(time.Hour) + uc := NewUserClient(client) + + t.Run("expired manual ban lifted", func(t *testing.T) { + u := client.User.Create().SetEmail("lift-exp@example.com").SetNick("u"). + SetStatus(entuser.StatusManualBanned).SetBanExpires(past).SetGroup(group).SaveX(ctx) + out, err := uc.LiftExpiredBan(ctx, u) + require.NoError(t, err) + require.Equal(t, entuser.StatusActive, out.Status) + require.Nil(t, client.User.GetX(ctx, u.ID).BanExpires) + }) + + t.Run("expired sys ban lifted", func(t *testing.T) { + u := client.User.Create().SetEmail("lift-sys@example.com").SetNick("u"). + SetStatus(entuser.StatusSysBanned).SetBanExpires(past).SetGroup(group).SaveX(ctx) + out, err := uc.LiftExpiredBan(ctx, u) + require.NoError(t, err) + require.Equal(t, entuser.StatusActive, out.Status) + }) + + t.Run("future ban untouched", func(t *testing.T) { + u := client.User.Create().SetEmail("lift-future@example.com").SetNick("u"). + SetStatus(entuser.StatusManualBanned).SetBanExpires(future).SetGroup(group).SaveX(ctx) + out, err := uc.LiftExpiredBan(ctx, u) + require.NoError(t, err) + require.Equal(t, entuser.StatusManualBanned, out.Status) + require.NotNil(t, client.User.GetX(ctx, u.ID).BanExpires) + }) + + t.Run("permanent ban untouched", func(t *testing.T) { + u := client.User.Create().SetEmail("lift-perm@example.com").SetNick("u"). + SetStatus(entuser.StatusManualBanned).SetGroup(group).SaveX(ctx) + out, err := uc.LiftExpiredBan(ctx, u) + require.NoError(t, err) + require.Equal(t, entuser.StatusManualBanned, out.Status) + }) + + t.Run("active user untouched", func(t *testing.T) { + u := client.User.Create().SetEmail("lift-active@example.com").SetNick("u"). + SetStatus(entuser.StatusActive).SetGroup(group).SaveX(ctx) + out, err := uc.LiftExpiredBan(ctx, u) + require.NoError(t, err) + require.Equal(t, entuser.StatusActive, out.Status) + }) +} + +func TestUpsertBanExpires(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() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + uc := NewUserClient(client) + future := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + + u := client.User.Create().SetEmail("upsert-ban@example.com").SetNick("u"). + SetStatus(entuser.StatusActive).SetGroup(group).SaveX(ctx) + + // Ban with expiry and reason + u.Status = entuser.StatusManualBanned + u.BanExpires = &future + u.BanReason = "spam" + _, err := uc.Upsert(ctx, u, "", "") + require.NoError(t, err) + got := client.User.GetX(ctx, u.ID) + require.Equal(t, future, got.BanExpires.UTC()) + require.Equal(t, "spam", got.BanReason) + + // Unban clears the expiry and reason + u.Status = entuser.StatusActive + u.BanExpires = nil + u.BanReason = "" + _, err = uc.Upsert(ctx, u, "", "") + require.NoError(t, err) + got = client.User.GetX(ctx, u.ID) + require.Nil(t, got.BanExpires) + require.Equal(t, "", got.BanReason) +} diff --git a/inventory/client.go b/inventory/client.go index 2a1ac1f4..223386c3 100644 --- a/inventory/client.go +++ b/inventory/client.go @@ -5,6 +5,7 @@ import ( rawsql "database/sql" "database/sql/driver" "fmt" + "strings" "time" "entgo.io/ent/dialect/sql" @@ -37,6 +38,12 @@ func InitializeDBClient(l logging.Logger, return nil, fmt.Errorf("failed to migrate database: %w", err) } } else { + // Version is current, but additive schema changes (new columns) + // still need to reach existing databases between releases. + RepairHeatWaveNotSecondary(l, client, ctx) + if err := client.Schema.Create(ctx); err != nil { + return nil, fmt.Errorf("failed to apply additive schema changes: %w", err) + } l.Info("Database schema is up to date.") } @@ -68,7 +75,7 @@ func NewRawEntClient(l logging.Logger, config conf.ConfigProvider) (*ent.Client, // If Database connection string provided, use it directly. if dbConfig.DatabaseURL != "" { l.Info("Connect to database with connection string") - client, err = sql.Open(string(confDBType), dbConfig.DatabaseURL) + client, err = sql.Open(string(confDBType), ensureMySQLParseTime(confDBType, dbConfig.DatabaseURL)) } else { switch confDBType { @@ -136,6 +143,25 @@ func NewRawEntClient(l logging.Logger, config conf.ConfigProvider) (*ent.Client, return ent.NewClient(driverOpt), nil } +// ensureMySQLParseTime appends parseTime=True to a user-provided MySQL DSN +// when absent. Without it the driver returns DATETIME columns as strings, +// which fail to scan into time.Time fields (cloudreve/cloudreve#2872). +func ensureMySQLParseTime(dbType conf.DBType, dsn string) string { + if dbType != conf.MySqlDB { + return dsn + } + idx := strings.IndexByte(dsn, '?') + if idx < 0 { + return dsn + "?parseTime=True" + } + for _, p := range strings.Split(dsn[idx+1:], "&") { + if strings.HasPrefix(strings.ToLower(p), "parsetime=") { + return dsn + } + } + return dsn + "&parseTime=True" +} + type sqlite3Driver struct { *sqlite.Driver } @@ -149,9 +175,18 @@ func (d sqlite3Driver) Open(name string) (conn driver.Conn, err error) { if err != nil { return } - _, err = conn.(sqlite3DriverConn).Exec("PRAGMA foreign_keys = ON;", nil) - if err != nil { - _ = conn.Close() + // WAL lets readers coexist with the writer; busy_timeout makes a brief + // lock wait instead of failing instantly with SQLITE_BUSY (#2917). + for _, pragma := range []string{ + "PRAGMA journal_mode = WAL;", + "PRAGMA synchronous = NORMAL;", + "PRAGMA busy_timeout = 5000;", + "PRAGMA foreign_keys = ON;", + } { + if _, err = conn.(sqlite3DriverConn).Exec(pragma, nil); err != nil { + _ = conn.Close() + return + } } return } diff --git a/inventory/dav_account.go b/inventory/dav_account.go index d2f7e3cc..3c069531 100644 --- a/inventory/dav_account.go +++ b/inventory/dav_account.go @@ -10,6 +10,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/conf" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/samber/lo" + "strings" ) type ( @@ -25,6 +26,11 @@ type ( GetByIDAndUserID(ctx context.Context, id, userID int) (*ent.DavAccount, error) // Delete deletes the dav account. Delete(ctx context.Context, id int) error + // UpdateURIPrefix rewrites the mount URIs of a user's dav accounts after + // a file rename or move: an account mounting exactly oldURI, or any path + // under it, is repointed to newURI. This keeps mounts working when the + // bound folder changes location. + UpdateURIPrefix(ctx context.Context, userID int, oldURI, newURI string) error } ListDavAccountArgs struct { @@ -96,6 +102,35 @@ func (c *davAccountClient) Delete(ctx context.Context, id int) error { return c.client.DavAccount.DeleteOneID(id).Exec(ctx) } +func (c *davAccountClient) UpdateURIPrefix(ctx context.Context, userID int, oldURI, newURI string) error { + oldURI = strings.TrimSuffix(oldURI, "/") + newURI = strings.TrimSuffix(newURI, "/") + if oldURI == newURI || userID == 0 { + return nil + } + + accounts, err := c.client.DavAccount.Query(). + Where( + davaccount.OwnerIDEQ(userID), + davaccount.Or( + davaccount.URIEQ(oldURI), + davaccount.URIHasPrefix(oldURI+"/"), + ), + ). + All(ctx) + if err != nil { + return fmt.Errorf("failed to query dav accounts for URI rewrite: %w", err) + } + + for _, account := range accounts { + updated := strings.TrimSuffix(newURI+strings.TrimPrefix(account.URI, oldURI), "/") + if err := c.client.DavAccount.UpdateOneID(account.ID).SetURI(updated).Exec(ctx); err != nil { + return fmt.Errorf("failed to rewrite dav account URI: %w", err) + } + } + return nil +} + func (c *davAccountClient) List(ctx context.Context, args *ListDavAccountArgs) (*ListDavAccountResult, error) { query := c.listQuery(args) diff --git a/inventory/dav_account_test.go b/inventory/dav_account_test.go new file mode 100644 index 00000000..204e94f2 --- /dev/null +++ b/inventory/dav_account_test.go @@ -0,0 +1,67 @@ +package inventory + +import ( + "context" + "fmt" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/stretchr/testify/require" +) + +func TestUpdateURIPrefix(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() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + owner := client.User.Create().SetEmail("dav@example.com").SetNick("dav").SetGroup(group).SaveX(ctx) + other := client.User.Create().SetEmail("other@example.com").SetNick("other").SetGroup(group).SaveX(ctx) + + seq := 0 + mk := func(userID int, uri string) { + seq++ + client.DavAccount.Create(). + SetName("mount"). + SetURI(uri). + SetPassword(fmt.Sprintf("pw-%d", seq)). + SetOptions(&boolset.BooleanSet{}). + SetOwnerID(userID). + SaveX(ctx) + } + getURIs := func(userID int) []string { + accounts, err := client.DavAccount.Query().All(ctx) + require.NoError(t, err) + var uris []string + for _, a := range accounts { + if a.OwnerID == userID { + uris = append(uris, a.URI) + } + } + return uris + } + + mk(owner.ID, "my://hash/dir") + mk(owner.ID, "my://hash/dir/sub") + mk(owner.ID, "my://hash/dir-other") + mk(other.ID, "my://hash/dir") + + c := NewDavAccountClient(client, "sqlite3", nil) + require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/dir", "my://hash/moved/dir")) + + got := getURIs(owner.ID) + require.ElementsMatch(t, []string{"my://hash/moved/dir", "my://hash/moved/dir/sub", "my://hash/dir-other"}, got) + // Other user's account untouched. + require.Equal(t, []string{"my://hash/dir"}, getURIs(other.ID)) + + // Trailing-slash stored URI is normalized and rewritten. + mk(owner.ID, "my://hash/base/") + require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/base/", "my://hash/base2")) + require.Contains(t, getURIs(owner.ID), "my://hash/base2") + + // Same-URI no-op and empty results are clean. + require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/x", "my://hash/x")) + require.NoError(t, c.UpdateURIPrefix(ctx, owner.ID, "my://hash/missing", "my://hash/y")) + require.NoError(t, c.UpdateURIPrefix(ctx, 0, "my://hash/dir", "my://hash/z")) +} diff --git a/inventory/debug/debug.go b/inventory/debug/debug.go index e327f6d8..25934db5 100644 --- a/inventory/debug/debug.go +++ b/inventory/debug/debug.go @@ -5,7 +5,7 @@ import ( "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "fmt" - "github.com/google/uuid" + "github.com/gofrs/uuid" "time" ) @@ -85,7 +85,7 @@ func (d *DebugDriver) Tx(ctx context.Context) (dialect.Tx, error) { if err != nil { return nil, err } - id := uuid.New().String() + id := uuid.Must(uuid.NewV4()).String() d.log(ctx, fmt.Sprintf("driver.Tx(%s): started", id)) return &DebugTx{tx, id, d.log, ctx}, nil } @@ -102,7 +102,7 @@ func (d *DebugDriver) BeginTx(ctx context.Context, opts *sql.TxOptions) (dialect if err != nil { return nil, err } - id := uuid.New().String() + id := uuid.Must(uuid.NewV4()).String() d.log(ctx, fmt.Sprintf("driver.BeginTx(%s): started", id)) return &DebugTx{tx, id, d.log, ctx}, nil } diff --git a/inventory/entity_props_test.go b/inventory/entity_props_test.go new file mode 100644 index 00000000..c67c01ed --- /dev/null +++ b/inventory/entity_props_test.go @@ -0,0 +1,52 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/stretchr/testify/require" +) + +func TestUpdateEntityProps(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() + + policy := client.StoragePolicy.Create(). + SetName("local"). + SetType(string(types.PolicyTypeLocal)). + SetBucketName("bucket"). + SetAccessKey("ak"). + SetSecretKey("sk"). + SetMaxSize(0). + SetDirNameRule("d"). + SetFileNameRule("f"). + SaveX(ctx) + + e := client.Entity.Create(). + SetType(int(types.EntityTypeVersion)). + SetSource("uploads/1/test.bin"). + SetSize(100). + SetStoragePolicyEntities(policy.ID). + SaveX(ctx) + + c := NewFileClient(client, "sqlite3", nil) + + e.Props = &types.EntityProps{RecycleFailCount: 3} + require.NoError(t, c.UpdateEntityProps(ctx, e)) + + got := client.Entity.GetX(ctx, e.ID) + require.NotNil(t, got.Props) + require.Equal(t, 3, got.Props.RecycleFailCount) + + // Other props survive a partial update. + e.Props.RecycleFailCount = 5 + e.Props.UnlinkOnly = true + require.NoError(t, c.UpdateEntityProps(ctx, e)) + + got = client.Entity.GetX(ctx, e.ID) + require.Equal(t, 5, got.Props.RecycleFailCount) + require.True(t, got.Props.UnlinkOnly) +} diff --git a/inventory/entity_refcount_test.go b/inventory/entity_refcount_test.go new file mode 100644 index 00000000..71923dcb --- /dev/null +++ b/inventory/entity_refcount_test.go @@ -0,0 +1,79 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/stretchr/testify/require" +) + +func TestParseReferenceCountFilter(t *testing.T) { + f, err := ParseReferenceCountFilter("stale") + require.NoError(t, err) + require.True(t, f.Stale) + + f, err = ParseReferenceCountFilter("gt:2") + require.NoError(t, err) + require.Equal(t, "gt", f.Op) + require.Equal(t, 2, f.Value) + + f, err = ParseReferenceCountFilter("eq:0") + require.NoError(t, err) + require.Equal(t, "eq", f.Op) + + for _, bad := range []string{"", "gt", "xx:1", "gt:abc", "1"} { + _, err := ParseReferenceCountFilter(bad) + require.Error(t, err, "expr %q should be rejected", bad) + } +} + +func TestListEntitiesReferenceCountFilter(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() + + p := client.StoragePolicy.Create().SetName("p").SetType("local"). + SetStatus(storagepolicy.StatusActive).SaveX(ctx) + mk := func(name string, ref int) { + client.Entity.Create().SetType(0).SetSource(name).SetSize(1). + SetReferenceCount(ref).SetStoragePolicyEntities(p.ID).SaveX(ctx) + } + mk("a", 0) + mk("b", -1) + mk("c", 1) + mk("d", 3) + + fc := NewFileClient(client, conf.SQLite3DB, nil) + args := &ListEntityParameters{PaginationArgs: &PaginationArgs{Page: 0, PageSize: 50}} + + res, err := fc.ListEntities(ctx, args) + require.NoError(t, err) + require.Equal(t, 4, res.TotalItems) + + stale, _ := ParseReferenceCountFilter("stale") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: stale}) + require.NoError(t, err) + require.Equal(t, 2, res.TotalItems) + + gt, _ := ParseReferenceCountFilter("gt:1") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: gt}) + require.NoError(t, err) + require.Equal(t, 1, res.TotalItems) + + lt, _ := ParseReferenceCountFilter("lt:1") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: lt}) + require.NoError(t, err) + require.Equal(t, 2, res.TotalItems) + + eq, _ := ParseReferenceCountFilter("eq:1") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: eq}) + require.NoError(t, err) + require.Equal(t, 1, res.TotalItems) +} diff --git a/inventory/file.go b/inventory/file.go index 3b344bd9..5f1abfde 100644 --- a/inventory/file.go +++ b/inventory/file.go @@ -3,6 +3,8 @@ package inventory import ( "context" "fmt" + "strconv" + "strings" "time" "entgo.io/ent/dialect/sql" @@ -19,7 +21,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/gofrs/uuid" "github.com/samber/lo" - "golang.org/x/tools/container/intsets" + "math" ) const ( @@ -60,6 +62,9 @@ type ( HasMetadata string Shared bool HasDirectLink bool + // Deleted filters by soft-delete state: nil lists everything (current + // behavior), true lists only trash, false only live files. + Deleted *bool } MetadataFilter struct { @@ -90,6 +95,16 @@ type ( EntityType *types.EntityType UserID int StoragePolicyID int + ReferenceCount *ReferenceCountFilter + } + + // ReferenceCountFilter compares entity reference_count against a value. + // Stale selects entities awaiting recycle (reference_count <= 0) and + // ignores Value. + ReferenceCountFilter struct { + Op string + Value int + Stale bool } ListEntityResult struct { @@ -187,6 +202,8 @@ type FileClient interface { RemoveStaleEntities(ctx context.Context, file *ent.File) (StorageDiff, error) // RemoveEntitiesByID hard-delete entities by IDs. RemoveEntitiesByID(ctx context.Context, ids ...int) (map[int]int64, error) + // UpdateEntityProps persists mutated EntityProps back to the given entities. + UpdateEntityProps(ctx context.Context, entities ...*ent.Entity) error // CapEntities caps the number of entities of a given file. The oldest entities will be unlinked // if entity count exceed limit. CapEntities(ctx context.Context, file *ent.File, owner *ent.User, max int, entityType types.EntityType) (StorageDiff, error) @@ -400,7 +417,7 @@ func (f *fileClient) CreateDirectLink(ctx context.Context, file int, name string func (f *fileClient) GetByHashID(ctx context.Context, hashID string) (*ent.File, error) { id, err := f.hasher.Decode(hashID, hashid.FileID) if err != nil { - return nil, fmt.Errorf("ailed to decode hash id %q: %w", hashID, err) + return nil, fmt.Errorf("failed to decode hash id %q: %w", hashID, err) } return withFileEagerLoading(ctx, f.client.File.Query().Where(file.ID(id))).First(ctx) @@ -421,7 +438,7 @@ func (f *fileClient) SoftDelete(ctx context.Context, file *ent.File) error { } func (f *fileClient) RemoveEntitiesByID(ctx context.Context, ids ...int) (map[int]int64, error) { - groups, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids) + groups, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids) // storageReduced stores the relation between owner ID and storage reduced. storageReduced := make(map[int]int64) @@ -458,11 +475,20 @@ func (f *fileClient) RemoveEntitiesByID(ctx context.Context, ids ...int) (map[in return storageReduced, nil } +func (f *fileClient) UpdateEntityProps(ctx context.Context, entities ...*ent.Entity) error { + for _, e := range entities { + if err := f.client.Entity.UpdateOne(e).SetProps(e.Props).Exec(ctx); err != nil { + return fmt.Errorf("failed to update props of entity %d: %w", e.ID, err) + } + } + return nil +} + func (f *fileClient) StaleEntities(ctx context.Context, ids ...int) ([]*ent.Entity, error) { res := make([]*ent.Entity, 0, len(ids)) if len(ids) > 0 { // If explicit IDs are given, we can query them directly - groups, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids) + groups, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids) for _, group := range groups { entities, err := f.client.Entity.Query(). Where(group). @@ -490,7 +516,7 @@ func (f *fileClient) StaleEntities(ctx context.Context, ids ...int) ([]*ent.Enti } func (f *fileClient) DeleteByUser(ctx context.Context, uid int) error { - batchSize := capPageSize(f.maxSQlParam, intsets.MaxInt, 10) + batchSize := capPageSize(f.maxSQlParam, math.MaxInt, 10) for { files, err := f.client.File.Query(). WithEntities(). @@ -538,7 +564,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ }) for ref, entityGroup := range entitiesGrouped { - entityPageGroup, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, entityGroup) + entityPageGroup, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, entityGroup) for _, group := range entityPageGroup { if err := f.client.Entity.Update(). Where(group). @@ -550,7 +576,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ } // 2. Filter out entities with <=0 reference count, Update recycle options for above entities; - entityGroup, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, uniqueEntities) + entityGroup, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, uniqueEntities) toBeRecycled := make([]*ent.Entity, 0, len(entities)) for _, group := range entityGroup { e, err := f.client.Entity.Query().Where(group).Where(entity.ReferenceCountLTE(0)).All(ctx) @@ -562,7 +588,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ } // 3. Update recycle options for above entities; - pageSize := capPageSize(f.maxSQlParam, intsets.MaxInt, 10) + pageSize := capPageSize(f.maxSQlParam, math.MaxInt, 10) chunks := lo.Chunk(lo.Map(toBeRecycled, func(item *ent.Entity, index int) int { return item.ID }), max(pageSize, 1)) @@ -576,7 +602,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ } hardDeleteCtx := schema.SkipSoftDelete(ctx) - fileGroups, chunks := f.batchInCondition(intsets.MaxInt, 10, 1, + fileGroups, chunks := f.batchInCondition(math.MaxInt, 10, 1, lo.Map(files, func(file *ent.File, index int) int { return file.ID }), @@ -608,7 +634,7 @@ func (f *fileClient) Delete(ctx context.Context, files []*ent.File, options *typ func (f *fileClient) Copy(ctx context.Context, args *CopyParameter) (map[int][]*ent.File, StorageDiff, error) { files := args.Files dstMap := args.DstMap - pageSize := capPageSize(f.maxSQlParam, intsets.MaxInt, 10) + pageSize := capPageSize(f.maxSQlParam, math.MaxInt, 10) // 1. Copy files and metadata copyFileStm := lo.Map(files, func(file *ent.File, index int) *ent.FileCreate { @@ -672,7 +698,7 @@ func (f *fileClient) Copy(ctx context.Context, args *CopyParameter) (map[int][]* return entity.ID, true }) - entityBatch, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids) + entityBatch, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids) entityStm = append(entityStm, lo.Map(entityBatch, func(batch predicate.Entity, index int) *ent.EntityUpdate { return f.client.Entity.Update().Where(batch).AddReferenceCount(1).AddFile(newFile) })...) @@ -740,7 +766,7 @@ func (f *fileClient) RemoveMetadata(ctx context.Context, file *ent.File, keys .. return nil } ctx = schema.SkipSoftDelete(ctx) - groups, _ := f.batchInConditionMetadataName(intsets.MaxInt, 10, 1, keys) + groups, _ := f.batchInConditionMetadataName(math.MaxInt, 10, 1, keys) for _, group := range groups { if _, err := f.client.Metadata.Delete().Where(metadata.FileID(file.ID), group).Exec(ctx); err != nil { return fmt.Errorf("failed to remove metadata: %v", err) @@ -982,7 +1008,7 @@ func (f *fileClient) CreateEntity(ctx context.Context, file *ent.File, args *Ent } func (f *fileClient) SetParent(ctx context.Context, files []*ent.File, parent *ent.File) error { - groups, _ := f.batchInCondition(intsets.MaxInt, 10, 1, lo.Map(files, func(file *ent.File, index int) int { + groups, _ := f.batchInCondition(math.MaxInt, 10, 1, lo.Map(files, func(file *ent.File, index int) int { return file.ID })) for _, group := range groups { @@ -1107,7 +1133,7 @@ func (f *fileClient) Rename(ctx context.Context, original *ent.File, newName str } func (f *fileClient) GetEntitiesByIDs(ctx context.Context, ids []int, page int) ([]*ent.Entity, int, error) { - groups, _ := f.batchInConditionEntityID(intsets.MaxInt, 10, 1, ids) + groups, _ := f.batchInConditionEntityID(math.MaxInt, 10, 1, ids) if page >= len(groups) || page < 0 { return nil, -1, fmt.Errorf("page out of range") } @@ -1133,7 +1159,7 @@ func (f *fileClient) GetByID(ctx context.Context, ids int) (*ent.File, error) { } func (f *fileClient) GetByIDs(ctx context.Context, ids []int, page int) ([]*ent.File, int, error) { - groups, _ := f.batchInCondition(intsets.MaxInt, 10, 1, ids) + groups, _ := f.batchInCondition(math.MaxInt, 10, 1, ids) if page >= len(groups) || page < 0 { return nil, -1, fmt.Errorf("page out of range") } @@ -1177,6 +1203,15 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile query = query.Where(file.HasDirectLinksWith(directlink.DeletedAtIsNil())) } + if args.Deleted != nil { + if *args.Deleted { + // Trash = files detached from their parent by soft-delete. + query = query.Where(file.Not(file.HasParent())) + } else { + query = query.Where(file.HasParent()) + } + } + query.Order(getFileOrderOption(&ListFileParameters{ PaginationArgs: args.PaginationArgs, })...) @@ -1202,6 +1237,25 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile }, nil } +// ParseReferenceCountFilter parses a reference-count filter expression: +// "stale" for entities awaiting recycle, or "gt:N" / "lt:N" / "eq:N". +func ParseReferenceCountFilter(expr string) (*ReferenceCountFilter, error) { + if expr == "stale" { + return &ReferenceCountFilter{Stale: true}, nil + } + + op, valStr, ok := strings.Cut(expr, ":") + if !ok || (op != "gt" && op != "lt" && op != "eq") { + return nil, fmt.Errorf("invalid reference count filter %q", expr) + } + + v, err := strconv.Atoi(valStr) + if err != nil { + return nil, fmt.Errorf("invalid reference count filter %q: %w", expr, err) + } + return &ReferenceCountFilter{Op: op, Value: v}, nil +} + func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameters) (*ListEntityResult, error) { query := f.client.Entity.Query() if args.EntityType != nil { @@ -1216,6 +1270,19 @@ func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameter query = query.Where(entity.StoragePolicyEntities(args.StoragePolicyID)) } + if args.ReferenceCount != nil { + switch { + case args.ReferenceCount.Stale: + query = query.Where(entity.ReferenceCountLTE(0)) + case args.ReferenceCount.Op == "gt": + query = query.Where(entity.ReferenceCountGT(args.ReferenceCount.Value)) + case args.ReferenceCount.Op == "lt": + query = query.Where(entity.ReferenceCountLT(args.ReferenceCount.Value)) + case args.ReferenceCount.Op == "eq": + query = query.Where(entity.ReferenceCountEQ(args.ReferenceCount.Value)) + } + } + query.Order(getEntityOrderOption(args)...) // Count total items diff --git a/inventory/heatwave_test.go b/inventory/heatwave_test.go new file mode 100644 index 00000000..780afe1a --- /dev/null +++ b/inventory/heatwave_test.go @@ -0,0 +1,43 @@ +package inventory + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const heatWaveDDL = "CREATE TABLE `groups` (\n" + + " `id` bigint NOT NULL AUTO_INCREMENT,\n" + + " `created_at` datetime(3) NULL,\n" + + " `name` varchar(255) NOT NULL,\n" + + " `permissions` blob NOT NULL COMMENT 'json blob' NOT SECONDARY,\n" + + " `note` varchar(255) NULL DEFAULT 'not secondary',\n" + + " PRIMARY KEY (`id`),\n" + + " UNIQUE KEY `groups_name` (`name`),\n" + + " KEY `groups_created_at` (`created_at`)\n" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + +func TestExtractColumnDef(t *testing.T) { + t.Run("strips NOT SECONDARY keeping the rest", func(t *testing.T) { + def, err := extractColumnDef(heatWaveDDL, "permissions") + require.NoError(t, err) + require.Equal(t, "`permissions` blob NOT NULL COMMENT 'json blob'", def) + }) + + t.Run("leaves unrelated columns intact", func(t *testing.T) { + def, err := extractColumnDef(heatWaveDDL, "note") + require.NoError(t, err) + // The string 'not secondary' inside a DEFAULT literal must survive. + require.Equal(t, "`note` varchar(255) NULL DEFAULT 'not secondary'", def) + }) + + t.Run("index lines are not matched", func(t *testing.T) { + _, err := extractColumnDef(heatWaveDDL, "groups_name") + require.Error(t, err) + }) + + t.Run("missing column errors", func(t *testing.T) { + _, err := extractColumnDef(heatWaveDDL, "nope") + require.Error(t, err) + }) +} diff --git a/inventory/migration.go b/inventory/migration.go index 993e71ef..a03c254c 100644 --- a/inventory/migration.go +++ b/inventory/migration.go @@ -41,6 +41,8 @@ func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion st func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver, requiredDbVersion string) error { l.Info("Start initializing database schema...") l.Info("Creating basic table schema...") + repairLegacyUserGroupColumn(l, client, ctx) + RepairHeatWaveNotSecondary(l, client, ctx) if err := client.Schema.Create(ctx); err != nil { return fmt.Errorf("Failed creating schema resources: %w", err) } @@ -67,6 +69,154 @@ func migrate(l logging.Logger, client *ent.Client, ctx context.Context, kv cache return nil } +// repairLegacyUserGroupColumn backfills users.group_users before ent's +// auto-migration. v3 bound user groups via a nullable group_id column (or no +// column at all for groupless users); the v4 schema requires NOT NULL +// group_users, so the schema copy fails mid-upgrade on old databases +// (upstream #2934). All statements are error-tolerant: on a fresh install +// the users/groups tables simply do not exist yet and every statement no-ops. +func repairLegacyUserGroupColumn(l logging.Logger, client *ent.Client, ctx context.Context) { + if _, err := client.ExecContext(ctx, `ALTER TABLE users ADD COLUMN group_users INTEGER`); err != nil { + l.Debug("Skip adding group_users column: %s", err) + } + + // Prefer the legacy group_id binding when present; otherwise fall back to + // the first non-admin group (id 1 is the seeded admin group), then any group. + queries := []string{ + `UPDATE users SET group_users = COALESCE(group_id, (SELECT MIN(id) FROM groups WHERE id <> 1), (SELECT MIN(id) FROM groups)) WHERE group_users IS NULL`, + `UPDATE users SET group_users = COALESCE((SELECT MIN(id) FROM groups WHERE id <> 1), (SELECT MIN(id) FROM groups)) WHERE group_users IS NULL`, + } + for _, q := range queries { + _, err := client.ExecContext(ctx, q) + if err == nil { + return + } + l.Debug("Skip legacy group_users backfill: %s", err) + } +} + +// RepairHeatWaveNotSecondary strips the MySQL HeatWave `NOT SECONDARY` column +// attribute before ent's schema migration. Atlas treats it as an unknown EXTRA +// value and aborts schema inspection (upstream #3452). For every flagged column +// we replay its own definition from SHOW CREATE TABLE minus the attribute via +// ALTER TABLE ... MODIFY COLUMN, which clears it without touching the logical +// schema. On non-MySQL backends the INFORMATION_SCHEMA query fails and the whole +// function is a no-op. +func RepairHeatWaveNotSecondary(l logging.Logger, client *ent.Client, ctx context.Context) { + rows, err := client.QueryContext(ctx, + `SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND UPPER(EXTRA) LIKE '%NOT SECONDARY%'`) + if err != nil { + l.Debug("Skip HeatWave NOT SECONDARY repair: %s", err) + return + } + + var flagged [][2]string + for rows.Next() { + var table, column string + if err := rows.Scan(&table, &column); err != nil { + l.Debug("Skip HeatWave NOT SECONDARY repair scan: %s", err) + rows.Close() + return + } + flagged = append(flagged, [2]string{table, column}) + } + rows.Close() + + for _, tc := range flagged { + def, err := heatWaveColumnDef(client, ctx, tc[0], tc[1]) + if err != nil { + l.Warning("Skip NOT SECONDARY repair for %s.%s: %s", tc[0], tc[1], err) + continue + } + + stm := fmt.Sprintf("ALTER TABLE `%s` MODIFY COLUMN %s", + strings.ReplaceAll(tc[0], "`", "``"), def) + if _, err := client.ExecContext(ctx, stm); err != nil { + l.Warning("Failed to strip NOT SECONDARY on %s.%s: %s", tc[0], tc[1], err) + continue + } + l.Info("Stripped HeatWave NOT SECONDARY attribute on %s.%s", tc[0], tc[1]) + } +} + +// heatWaveColumnDef extracts a column's full definition from SHOW CREATE TABLE +// and returns it without the `NOT SECONDARY` attribute, ready for use after +// ALTER TABLE ... MODIFY COLUMN. +func heatWaveColumnDef(client *ent.Client, ctx context.Context, table, column string) (string, error) { + rows, err := client.QueryContext(ctx, + fmt.Sprintf("SHOW CREATE TABLE `%s`", strings.ReplaceAll(table, "`", "``"))) + if err != nil { + return "", err + } + defer rows.Close() + + var name, ddl string + if !rows.Next() { + return "", fmt.Errorf("table %s not found", table) + } + if err := rows.Scan(&name, &ddl); err != nil { + return "", err + } + + return extractColumnDef(ddl, column) +} + +// extractColumnDef finds `column`'s definition line in SHOW CREATE TABLE output +// and returns it without the `NOT SECONDARY` attribute. +func extractColumnDef(ddl, column string) (string, error) { + quoted := "`" + strings.ReplaceAll(column, "`", "``") + "`" + for _, line := range strings.Split(ddl, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, quoted+" ") { + continue + } + def := stripNotSecondary(strings.TrimSuffix(line, ",")) + if findNotSecondary(def) >= 0 { + return "", fmt.Errorf("failed to strip NOT SECONDARY from column %s", column) + } + return def, nil + } + return "", fmt.Errorf("column %s not found in table definition", column) +} + +func stripNotSecondary(def string) string { + // Remove the `NOT SECONDARY` attribute clause, but not the same words + // inside a quoted literal (e.g. DEFAULT 'not secondary'). + out := def + for { + idx := findNotSecondary(out) + if idx < 0 { + return strings.TrimSpace(out) + } + out = out[:idx] + out[idx+len("NOT SECONDARY"):] + } +} + +// findNotSecondary returns the index of the first `NOT SECONDARY` occurrence +// outside single-quoted string literals, or -1. +func findNotSecondary(def string) int { + const marker = "NOT SECONDARY" + upper := strings.ToUpper(def) + inQuote := false + for i := 0; i+len(marker) <= len(upper); i++ { + if upper[i] == '\'' { + if inQuote && i+1 < len(upper) && upper[i+1] == '\'' { + i++ // escaped '' inside a literal + continue + } + inQuote = !inQuote + } + if !inQuote && strings.HasPrefix(upper[i:], marker) { + end := i + len(marker) + if (i == 0 || upper[i-1] == ' ') && (end == len(upper) || upper[end] == ' ' || upper[end] == ',') { + return i + } + } + } + return -1 +} + func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Context, kv cache.Driver) { // clean kv cache if err := kv.DeleteAll(); err != nil { @@ -288,11 +438,9 @@ func migrateMasterNode(l logging.Logger, client *ent.Client, ctx context.Context const ( OAuthClientDesktopGUID = "393a1839-f52e-498e-9972-e77cc2241eee" - OAuthClientDesktopSecret = "8GaQIu3lOSdqYoDHi9cR8IZ4pvuMH8ya" OAuthClientDesktopName = "application:oauth.desktop" OAuthClientDesktopRedirectURI = "/callback/desktop" OAuthClientiOSGUID = "220db97a-44a3-44f7-99b6-d767262b4daa" - OAuthClientiOSSecret = "1kxOW4IyVOkPlsKCnTwzfHyP8XrbpfaF" OAuthClientiOSName = "application:setting.iOSApp" OAuthClientiOSRedirectURI = "/callback/ios" ) @@ -316,7 +464,7 @@ func migrateOAuthClientiOS(l logging.Logger, client *ent.Client, ctx context.Con } if _, err := client.OAuthClient.Create(). SetGUID(OAuthClientiOSGUID). - SetSecret(OAuthClientiOSSecret). + SetSecret(""). SetName(OAuthClientiOSName). SetRedirectUris([]string{OAuthClientiOSRedirectURI}). SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "UserSecurityInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write", "Finance.Write", "DavAccount.Write"}). @@ -337,7 +485,7 @@ func migrateOAuthClientDesktop(l logging.Logger, client *ent.Client, ctx context if _, err := client.OAuthClient.Create(). SetGUID(OAuthClientDesktopGUID). - SetSecret(OAuthClientDesktopSecret). + SetSecret(""). SetName(OAuthClientDesktopName). SetRedirectUris([]string{OAuthClientDesktopRedirectURI}). SetScopes([]string{"profile", "email", "openid", "offline_access", "UserInfo.Write", "Workflow.Write", "Files.Write", "Shares.Write"}). @@ -578,6 +726,47 @@ var patches = []Patch{ return nil }, }, + { + Name: "apply_default_model3d_viewer", + EndVersion: "4.20.0", + Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error { + fileViewersSetting, err := client.Setting.Query().Where(setting.Name("file_viewers")).First(ctx) + if err != nil { + return fmt.Errorf("failed to query file_viewers setting: %w", err) + } + + var fileViewers []types.ViewerGroup + if err := json.Unmarshal([]byte(fileViewersSetting.Value), &fileViewers); err != nil { + return fmt.Errorf("failed to unmarshal file_viewers setting: %w", err) + } + + for _, viewer := range fileViewers[0].Viewers { + if viewer.ID == "model3d" { + return nil + } + } + + var defaultModelViewer types.Viewer + for _, viewer := range defaultFileViewers[0].Viewers { + if viewer.ID == "model3d" { + defaultModelViewer = viewer + break + } + } + + fileViewers[0].Viewers = append(fileViewers[0].Viewers, defaultModelViewer) + newFileViewersSetting, err := json.Marshal(fileViewers) + if err != nil { + return fmt.Errorf("failed to marshal file_viewers setting: %w", err) + } + + if _, err := client.Setting.UpdateOne(fileViewersSetting).SetValue(string(newFileViewersSetting)).Save(ctx); err != nil { + return fmt.Errorf("failed to update file_viewers setting: %w", err) + } + + return nil + }, + }, { Name: "reset_secret_key", EndVersion: "4.13.0", @@ -588,6 +777,23 @@ var patches = []Patch{ return fmt.Errorf("failed to update secret_key setting: %w", err) } + return nil + }, + }, + { + // Built-in desktop/iOS clients are public clients shipped in binaries — + // a shared hardcoded secret authenticates nothing. Blank it so token + // exchange relies on PKCE (RFC 8252) instead. + Name: "oauth_builtin_public_clients", + EndVersion: "4.20.0", + Func: func(l logging.Logger, client *ent.Client, ctx context.Context) error { + if _, err := client.OAuthClient.Update(). + Where(oauthclient.GUIDIn(OAuthClientDesktopGUID, OAuthClientiOSGUID)). + SetSecret(""). + Save(ctx); err != nil { + return fmt.Errorf("failed to clear built-in OAuth client secrets: %w", err) + } + return nil }, }, @@ -620,7 +826,7 @@ func applyPatches(l logging.Logger, client *ent.Client, ctx context.Context, req return fmt.Errorf("failed to parse required version %s: %w", requiredDbVersion, err) } - if latestAppliedVersion == nil || requiredVersion.Compare(requiredVersion) > 0 { + if latestAppliedVersion == nil || requiredVersion.Compare(latestAppliedVersion) > 0 { latestAppliedVersion = requiredVersion } diff --git a/inventory/migration_test.go b/inventory/migration_test.go new file mode 100644 index 00000000..01224345 --- /dev/null +++ b/inventory/migration_test.go @@ -0,0 +1,97 @@ +package inventory + +import ( + "context" + rawsql "database/sql" + "path/filepath" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/stretchr/testify/require" +) + +// legacyV3DB builds a minimal v3-shaped sqlite database: users bind their +// group via a nullable group_id column, and groupless users carry NULL. +func legacyV3DB(t *testing.T, dbPath string) { + t.Helper() + raw, err := rawsql.Open("sqlite3", "file:"+dbPath) + require.NoError(t, err) + defer raw.Close() + + stmts := []string{ + `CREATE TABLE groups (id INTEGER PRIMARY KEY, created_at DATETIME, updated_at DATETIME, name TEXT, permissions BLOB)`, + `INSERT INTO groups (id, created_at, updated_at, name, permissions) VALUES + (1, '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'Admin', X'00'), + (2, '2024-01-01 00:00:00', '2024-01-01 00:00:00', 'User', X'00')`, + `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME, + email VARCHAR(100), + nick VARCHAR(100), + password VARCHAR(255), + status VARCHAR(20), + storage BIGINT, + group_id INTEGER + )`, + `INSERT INTO users (created_at, updated_at, email, nick, status, storage, group_id) VALUES + ('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'admin@x', 'admin', 'active', 0, 1), + ('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'user@x', 'user', 'active', 0, 2), + ('2024-01-01 00:00:00', '2024-01-01 00:00:00', 'groupless@x', 'groupless', 'active', 0, NULL)`, + } + for _, q := range stmts { + _, err := raw.Exec(q) + require.NoError(t, err, q) + } +} + +// TestRepairLegacyUserGroupColumn reproduces upstream #2934: upgrading a v3 +// database fails inside ent's copy-migration because group_users is NOT NULL +// in v4 while v3 rows can be groupless. The repair backfills the column so +// Schema.Create succeeds. +func TestRepairLegacyUserGroupColumn(t *testing.T) { + ctx := context.Background() + l := logging.NewConsoleLogger(logging.LevelError) + + openClient := func(t *testing.T) *ent.Client { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + legacyV3DB(t, dbPath) + client, err := ent.Open("sqlite3", "file:"+dbPath) + require.NoError(t, err) + return client + } + + t.Run("schema create fails without repair", func(t *testing.T) { + client := openClient(t) + defer client.Close() + err := client.Schema.Create(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "group_users") + }) + + t.Run("repair backfills and migration succeeds", func(t *testing.T) { + client := openClient(t) + defer client.Close() + + repairLegacyUserGroupColumn(l, client, ctx) + require.NoError(t, client.Schema.Create(ctx)) + + rows, err := client.QueryContext(ctx, `SELECT id, group_users FROM users ORDER BY id`) + require.NoError(t, err) + defer rows.Close() + + got := map[int]int{} + for rows.Next() { + var id, gid int + require.NoError(t, rows.Scan(&id, &gid)) + got[id] = gid + } + require.NoError(t, rows.Err()) + + // group_id bindings preserved; groupless user lands on the + // non-admin group (id 2), not the admin group. + require.Equal(t, map[int]int{1: 1, 2: 2, 3: 2}, got) + }) +} diff --git a/inventory/mysql_dsn_test.go b/inventory/mysql_dsn_test.go new file mode 100644 index 00000000..1ae5e959 --- /dev/null +++ b/inventory/mysql_dsn_test.go @@ -0,0 +1,27 @@ +package inventory + +import ( + "testing" + + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/stretchr/testify/require" +) + +func TestEnsureMySQLParseTime(t *testing.T) { + dsn := "user:pass@tcp(127.0.0.1:3306)/cloudreve" + require.Equal(t, dsn+"?parseTime=True", ensureMySQLParseTime(conf.MySqlDB, dsn)) + + withParams := dsn + "?charset=utf8mb4" + require.Equal(t, withParams+"&parseTime=True", ensureMySQLParseTime(conf.MySqlDB, withParams)) + + existing := dsn + "?charset=utf8mb4&parseTime=false" + require.Equal(t, existing, ensureMySQLParseTime(conf.MySqlDB, existing)) + + urlForm := "mysql://user:pass@host:3306/cloudreve?tls=true" + require.Equal(t, urlForm+"&parseTime=True", ensureMySQLParseTime(conf.MySqlDB, urlForm)) + + // Non-MySQL DSNs pass through untouched. + pg := "postgresql://user:pass@host:5432/cloudreve?sslmode=require" + require.Equal(t, pg, ensureMySQLParseTime(conf.PostgresDB, pg)) + require.Equal(t, dsn, ensureMySQLParseTime(conf.SQLiteDB, dsn)) +} diff --git a/inventory/policy.go b/inventory/policy.go index 285c01b1..9efc14c4 100644 --- a/inventory/policy.go +++ b/inventory/policy.go @@ -125,6 +125,11 @@ func (c *storagePolicyClient) Upsert(ctx context.Context, policy *ent.StoragePol SetSettings(policy.Settings). SetNillableNodeID(nodeId) + // Status is optional in the update payload; empty keeps the stored value. + if policy.Status != "" { + updateQuery.SetStatus(policy.Status) + } + if policy.Type != types.PolicyTypeOd { updateQuery.SetAccessKey(policy.AccessKey) } @@ -146,7 +151,9 @@ func (c *storagePolicyClient) GetByGroup(ctx context.Context, group *ent.Group) val, skipCache := ctx.Value(SkipStoragePolicyCache{}).(bool) skipCache = skipCache && val - res, err := withStoragePolicyEagerLoading(ctx, c.client.Group.QueryStoragePolicies(group)).WithNode().First(ctx) + res, err := withStoragePolicyEagerLoading(ctx, c.client.Group.QueryStoragePolicies(group)). + Where(storagepolicy.StatusEQ(storagepolicy.StatusActive)). + WithNode().First(ctx) if err != nil { return nil, fmt.Errorf("get storage policies: %w", err) } diff --git a/inventory/policy_suspend_test.go b/inventory/policy_suspend_test.go new file mode 100644 index 00000000..50f6f300 --- /dev/null +++ b/inventory/policy_suspend_test.go @@ -0,0 +1,42 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/stretchr/testify/require" +) + +func TestGetByGroupSkipsSuspended(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() + + pc := NewStoragePolicyClient(client, nil) + + t.Run("suspended group policy yields not found", func(t *testing.T) { + p := client.StoragePolicy.Create(). + SetName("suspended").SetType("local"). + SetStatus(storagepolicy.StatusSuspended).SaveX(ctx) + group := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}). + SetStoragePolicies(p).SaveX(ctx) + + _, err := pc.GetByGroup(ctx, group) + require.Error(t, err) + }) + + t.Run("active group policy returned", func(t *testing.T) { + p := client.StoragePolicy.Create(). + SetName("active").SetType("local"). + SetStatus(storagepolicy.StatusActive).SaveX(ctx) + group := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}). + SetStoragePolicies(p).SaveX(ctx) + + got, err := pc.GetByGroup(ctx, group) + require.NoError(t, err) + require.Equal(t, "active", got.Name) + }) +} diff --git a/inventory/setting.go b/inventory/setting.go index 064a6d2d..0b6a048a 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -26,7 +26,7 @@ type ( Get(ctx context.Context, name string) (string, error) // Set sets a setting value to DB. Set(ctx context.Context, settings map[string]string) error - // Gets gets multiple setting values from DB, returns error if any setting cannot be found. + // Gets gets multiple setting values from DB, returns a map of found settings; names with no matching setting are omitted. Gets(ctx context.Context, names []string) (map[string]string, error) } ) @@ -294,6 +294,12 @@ var ( DisplayName: "fileManager.imageViewer", Exts: []string{"bmp", "png", "gif", "jpg", "jpeg", "svg", "webp", "heic", "heif"}, }, + { + ID: "model3d", + Type: types.ViewerTypeBuiltin, + DisplayName: "fileManager.modelViewer", + Exts: []string{"fbx", "obj", "gltf", "glb"}, + }, { ID: "monaco", Type: types.ViewerTypeBuiltin, @@ -558,6 +564,8 @@ var DefaultSettings = map[string]string{ "sso_client_secret": "", "sso_scopes": "", "sso_register_enabled": "1", + "sso_auto_redirect": "0", + "download_cdn_routes": "", "email_filter_mode": "0", "email_filter_list": "", "email_disable_subaddress": "0", diff --git a/inventory/task.go b/inventory/task.go index 81edf774..0df40883 100644 --- a/inventory/task.go +++ b/inventory/task.go @@ -41,10 +41,15 @@ type TaskClient interface { GetTaskByID(ctx context.Context, taskID int) (*ent.Task, error) // SetCompleteByID sets the task with the given ID to complete. SetCompleteByID(ctx context.Context, taskID int) error + // SetStatusByID sets the status of the task with the given ID. + SetStatusByID(ctx context.Context, taskID int, status task.Status) error // List returns a list of tasks with the given args. List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error) // DeleteByIDs deletes the tasks with the given IDs. DeleteByIDs(ctx context.Context, ids ...int) error + + // HideByIDs marks tasks owned by the given user as hidden. + HideByIDs(ctx context.Context, ownerID int, ids ...int) error // DeleteBy deletes the tasks with the given args. DeleteBy(ctx context.Context, args *DeleteTaskArgs) error } @@ -56,6 +61,8 @@ type ( Status []task.Status UserID int CorrelationID *uuid.UUID + // ExcludeHidden filters out tasks hidden by their owner. + ExcludeHidden bool } ListTaskResult struct { @@ -122,6 +129,15 @@ func (c *taskClient) DeleteByIDs(ctx context.Context, ids ...int) error { return err } +func (c *taskClient) HideByIDs(ctx context.Context, ownerID int, ids ...int) error { + _, err := c.client.Task. + Update(). + Where(task.IDIn(ids...), task.UserTasks(ownerID)). + SetHidden(true). + Save(ctx) + return err +} + func (c *taskClient) DeleteBy(ctx context.Context, args *DeleteTaskArgs) error { query := c.client.Task. Delete(). @@ -199,6 +215,13 @@ func (c *taskClient) SetCompleteByID(ctx context.Context, taskID int) error { return err } +func (c *taskClient) SetStatusByID(ctx context.Context, taskID int, status task.Status) error { + _, err := c.client.Task.UpdateOneID(taskID). + SetStatus(status). + Save(ctx) + return err +} + func (c *taskClient) List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error) { q := c.client.Task.Query() if args.UserID != 0 { @@ -217,6 +240,10 @@ func (c *taskClient) List(ctx context.Context, args *ListTaskArgs) (*ListTaskRes q.Where(task.CorrelationID(*args.CorrelationID)) } + if args.ExcludeHidden { + q.Where(task.Hidden(false)) + } + q = withTaskEagerLoading(ctx, q) var ( tasks []*ent.Task diff --git a/inventory/types/types.go b/inventory/types/types.go index d6162ceb..3787b739 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -17,6 +17,17 @@ type ( DisableViewSync bool `json:"disable_view_sync,omitempty"` FsViewMap map[string]ExplorerView `json:"fs_view_map,omitempty"` ShareLinksInProfile ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"` + // ShareDefaultPrivate overrides the site-wide private-share default + // for this user. nil means inherit the site default. + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` + // PreferredViewers maps file extensions (without dot, lowercase) to + // viewer IDs chosen via "always open with". Synced server-side so the + // preference follows the user across devices. + PreferredViewers map[string]string `json:"preferred_viewers,omitempty"` + // TrashRetention overrides the group's trash retention in seconds. + // 0 means inherit the group setting. Applied when a file is moved to + // trash — already-trashed files keep their original expiry. + TrashRetention int `json:"trash_retention,omitempty"` } ShareLinksInProfileLevel string @@ -36,6 +47,9 @@ type ( MaxWalkedFiles int `json:"max_walked_files,omitempty"` TrashRetention int `json:"trash_retention,omitempty"` RedirectedSource bool `json:"redirected_source,omitempty"` + // LoginIPWhitelist restricts sign-in to the given IPs/CIDR ranges. + // Empty means no restriction. + LoginIPWhitelist []string `json:"login_ip_whitelist,omitempty"` } // PolicySetting 非公有的存储策略属性 @@ -50,6 +64,10 @@ type ( NameRegexp string `json:"file_regexp,omitempty"` // IsNameRegexp Whether above regexp is a deny list. IsNameRegexpDenyList bool `json:"is_name_regexp_deny_list,omitempty"` + // AllowNativeName permits characters only illegal on Windows + // filesystems (:*?"<>|) in file names. Path separators and dot-names + // stay illegal (#3065). + AllowNativeName bool `json:"allow_native_name,omitempty"` // OauthRedirect Oauth 重定向地址 OauthRedirect string `json:"od_redirect,omitempty"` // CustomProxy whether to use custom-proxy to get file content @@ -179,6 +197,10 @@ type ( EntityProps struct { UnlinkOnly bool `json:"unlink_only,omitempty"` EncryptMetadata *EncryptMetadata `json:"encrypt_metadata,omitempty"` + // RecycleFailCount tracks consecutive driver-delete failures during + // entity recycling. Entities reaching the threshold are force-removed + // from the DB so one un-deletable blob cannot stall the sweep forever. + RecycleFailCount int `json:"recycle_fail_count,omitempty"` } Cipher string @@ -233,6 +255,9 @@ type ( PreviewOnly bool `json:"preview_only,omitempty"` // Whether share visitors can upload but cannot list or download (drop box) UploadOnly bool `json:"upload_only,omitempty"` + // Owner-defined note/alias for identifying the share in My Shares; + // never exposed to share visitors (#3570). + Note string `json:"note,omitempty"` } OAuthClientProps struct { @@ -269,8 +294,48 @@ const ( GroupPermissionSetExplicitUser_placeholder GroupPermissionIgnoreFileOwnership // not used GroupPermissionUniqueRedirectDirectLink + // GroupPermissionWebDAVReadOnly restricts the group's WebDAV access to + // read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK, + // PROPPATCH) are rejected even if the group's WebDAV access is enabled. + GroupPermissionWebDAVReadOnly + // Delegated admin section permissions. A group with any of these bits — + // but without GroupPermissionIsAdmin — is a delegated administrator that + // can only access the corresponding admin sections. GroupPermissionIsAdmin + // implies all sections. + GroupPermissionAdminUsers + GroupPermissionAdminGroups + GroupPermissionAdminFiles + GroupPermissionAdminShares + GroupPermissionAdminStorage + GroupPermissionAdminQueue + GroupPermissionAdminSettings + GroupPermissionAdminPayment + GroupPermissionAdminEvents + GroupPermissionAdminReports ) +// DelegatedAdminPermissions lists every per-section admin permission bit. +// GroupPermissionIsAdmin implies all of them. +func DelegatedAdminPermissions() []GroupPermission { + return []GroupPermission{ + GroupPermissionAdminUsers, + GroupPermissionAdminGroups, + GroupPermissionAdminFiles, + GroupPermissionAdminShares, + GroupPermissionAdminStorage, + GroupPermissionAdminQueue, + GroupPermissionAdminSettings, + GroupPermissionAdminPayment, + GroupPermissionAdminEvents, + GroupPermissionAdminReports, + } +} + +// AdminPermissionBits returns all admin-capable bits, including IsAdmin. +func AdminPermissionBits() []GroupPermission { + return append([]GroupPermission{GroupPermissionIsAdmin}, DelegatedAdminPermissions()...) +} + const ( NodeCapabilityNone NodeCapability = iota NodeCapabilityCreateArchive @@ -394,6 +459,11 @@ const ( ProfilePublicShareOnly = ShareLinksInProfileLevel("") ProfileAllShare = ShareLinksInProfileLevel("all_share") ProfileHideShare = ShareLinksInProfileLevel("hide_share") + // ProfileSharePublic is the explicit "password-free shares only" choice. + // The empty value (ProfilePublicShareOnly) doubles as "inherit the + // site-wide default"; ProfileSharePublic lets a user force public-only + // visibility even when the site default differs (#3390). + ProfileSharePublic = ShareLinksInProfileLevel("public_share") ) const ( diff --git a/inventory/user.go b/inventory/user.go index 991ba1ae..d312f084 100644 --- a/inventory/user.go +++ b/inventory/user.go @@ -61,6 +61,9 @@ type ( GetActiveByID(ctx context.Context, id int) (*ent.User, error) // SetStatus Set user to given status SetStatus(ctx context.Context, u *ent.User, status user.Status) (*ent.User, error) + // LiftExpiredBan restores a banned user whose ban_expires has passed. + // It returns the (possibly updated) user; permanent bans are untouched. + LiftExpiredBan(ctx context.Context, u *ent.User) (*ent.User, error) // AnonymousUser returns the anonymous user. AnonymousUser(ctx context.Context) (*ent.User, error) // GetLoginUserByID returns the login user by its ID. It emits some errors and fallback to anonymous user. @@ -104,6 +107,10 @@ type ( ListUsers(ctx context.Context, args *ListUserParameters) (*ListUserResult, error) // Upsert upserts a user. Upsert(ctx context.Context, u *ent.User, password, twoFa string) (*ent.User, error) + // UpdateLastLogin stamps the user's last successful sign-in time. + UpdateLastLogin(ctx context.Context, uid int) error + // BatchUpdate applies a status and/or group change to the given users. + BatchUpdate(ctx context.Context, ids []int, status *user.Status, groupID int) (int, error) // Delete deletes a user. Delete(ctx context.Context, uid int) error // CalculateStorage calculate user's storage from scratch and update user's storage. @@ -115,6 +122,7 @@ type ( Status user.Status Nick string Email string + IDs []int } ListUserResult struct { *PaginationResults @@ -364,6 +372,46 @@ func (c *userClient) SetStatus(ctx context.Context, u *ent.User, status user.Sta return c.client.User.UpdateOne(u).SetStatus(status).Save(ctx) } +func (c *userClient) UpdateLastLogin(ctx context.Context, uid int) error { + return c.client.User.UpdateOneID(uid).SetLastLogin(time.Now()).Exec(ctx) +} + +// BatchUpdate applies a status and/or group change to the given user IDs and +// returns the number of updated rows. Ban lifts/expiry fields are cleared when +// a user is (re)activated or banned through this path. +func (c *userClient) BatchUpdate(ctx context.Context, ids []int, status *user.Status, groupID int) (int, error) { + if len(ids) == 0 { + return 0, nil + } + + stm := c.client.User.Update().Where(user.IDIn(ids...)) + if status != nil { + stm.SetStatus(*status).ClearBanExpires().ClearBanReason() + } + if groupID > 0 { + stm.SetGroupUsers(groupID) + } + return stm.Save(ctx) +} + +func (c *userClient) LiftExpiredBan(ctx context.Context, u *ent.User) (*ent.User, error) { + banned := u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned + if !banned || u.BanExpires == nil || u.BanExpires.After(time.Now()) { + return u, nil + } + + if err := c.client.User.UpdateOneID(u.ID). + SetStatus(user.StatusActive). + ClearBanExpires(). + Exec(ctx); err != nil { + return nil, err + } + + u.Status = user.StatusActive + u.BanExpires = nil + return u, nil +} + func (c *userClient) Create(ctx context.Context, args *NewUserArgs) (*ent.User, error) { // Try to check if there's user with same email. if existedUser, err := c.GetByEmail(ctx, args.Email); err == nil { @@ -527,6 +575,9 @@ func (c *userClient) ListUsers(ctx context.Context, args *ListUserParameters) (* if args.Email != "" { query = query.Where(user.EmailContainsFold(args.Email)) } + if len(args.IDs) > 0 { + query = query.Where(user.IDIn(args.IDs...)) + } query.Order(getUserOrderOption(args)...) // Count total items @@ -579,6 +630,14 @@ func (c *userClient) Upsert(ctx context.Context, u *ent.User, password, twoFa st SetStatus(u.Status). SetGroupID(u.GroupUsers) + if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned { + q.SetNillableBanExpires(u.BanExpires) + q.SetBanReason(u.BanReason) + } else { + q.ClearBanExpires() + q.ClearBanReason() + } + if password != "" { pwdDigest, err := digestPassword(password) if err != nil { @@ -605,6 +664,8 @@ func getUserOrderOption(args *ListUserParameters) []user.OrderOption { return []user.OrderOption{user.ByEmail(orderTerm), user.ByID(orderTerm)} case user.FieldUpdatedAt: return []user.OrderOption{user.ByUpdatedAt(orderTerm), user.ByID(orderTerm)} + case user.FieldLastLogin: + return []user.OrderOption{user.ByLastLogin(orderTerm), user.ByID(orderTerm)} default: return []user.OrderOption{user.ByID(orderTerm)} } diff --git a/inventory/user_batch_test.go b/inventory/user_batch_test.go new file mode 100644 index 00000000..7c884b02 --- /dev/null +++ b/inventory/user_batch_test.go @@ -0,0 +1,97 @@ +package inventory + +import ( + "context" + "testing" + "time" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + entuser "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/stretchr/testify/require" +) + +func TestBatchUpdateUsers(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() + + g1 := client.Group.Create().SetName("g1").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + g2 := client.Group.Create().SetName("g2").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + uc := NewUserClient(client) + + u1 := client.User.Create().SetEmail("b1@example.com").SetNick("u1").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx) + u2 := client.User.Create().SetEmail("b2@example.com").SetNick("u2").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx) + u3 := client.User.Create().SetEmail("b3@example.com").SetNick("u3").SetStatus(entuser.StatusActive).SetGroup(g1).SaveX(ctx) + + t.Run("status change clears ban fields", func(t *testing.T) { + banned := client.User.Create().SetEmail("banned@example.com").SetNick("ub"). + SetStatus(entuser.StatusManualBanned).SetBanExpires(time.Now().Add(time.Hour)). + SetBanReason("spam").SetGroup(g1).SaveX(ctx) + + st := entuser.StatusInactive + n, err := uc.BatchUpdate(ctx, []int{banned.ID}, &st, 0) + require.NoError(t, err) + require.Equal(t, 1, n) + + got := client.User.GetX(ctx, banned.ID) + require.Equal(t, entuser.StatusInactive, got.Status) + require.Nil(t, got.BanExpires) + require.Equal(t, "", got.BanReason) + }) + + t.Run("group change", func(t *testing.T) { + n, err := uc.BatchUpdate(ctx, []int{u1.ID, u2.ID}, nil, g2.ID) + require.NoError(t, err) + require.Equal(t, 2, n) + + require.Equal(t, g2.ID, client.User.GetX(ctx, u1.ID).QueryGroup().OnlyIDX(ctx)) + require.Equal(t, g2.ID, client.User.GetX(ctx, u2.ID).QueryGroup().OnlyIDX(ctx)) + require.Equal(t, g1.ID, client.User.GetX(ctx, u3.ID).QueryGroup().OnlyIDX(ctx)) + }) + + t.Run("empty id list is no-op", func(t *testing.T) { + st := entuser.StatusInactive + n, err := uc.BatchUpdate(ctx, nil, &st, g2.ID) + require.NoError(t, err) + require.Equal(t, 0, n) + }) +} + +func TestListUsersByIDs(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() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + uc := NewUserClient(client) + + u1 := client.User.Create().SetEmail("id1@example.com").SetNick("u1").SetGroup(group).SaveX(ctx) + u2 := client.User.Create().SetEmail("id2@example.com").SetNick("u2").SetGroup(group).SaveX(ctx) + client.User.Create().SetEmail("id3@example.com").SetNick("u3").SetGroup(group).SaveX(ctx) + + res, err := uc.ListUsers(ctx, &ListUserParameters{ + PaginationArgs: &PaginationArgs{Page: 0, PageSize: 10}, + IDs: []int{u1.ID, u2.ID}, + }) + require.NoError(t, err) + require.Equal(t, 2, res.PaginationResults.TotalItems) + require.Len(t, res.Users, 2) +} + +func TestUpdateLastLogin(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() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + uc := NewUserClient(client) + + u := client.User.Create().SetEmail("ll@example.com").SetNick("u").SetGroup(group).SaveX(ctx) + require.Nil(t, client.User.GetX(ctx, u.ID).LastLogin) + + require.NoError(t, uc.UpdateLastLogin(ctx, u.ID)) + got := client.User.GetX(ctx, u.ID) + require.NotNil(t, got.LastLogin) + require.WithinDuration(t, time.Now(), *got.LastLogin, time.Minute) +} diff --git a/main.go b/main.go index d9385366..ffa1b2c5 100644 --- a/main.go +++ b/main.go @@ -19,20 +19,8 @@ func init() { flag.BoolVar(&util.UseWorkingDir, "use-working-dir", false, "Use working directory, instead of executable directory") flag.StringVar(&confPath, "c", util.RelativePath("conf.ini"), "Path to the config file.") flag.StringVar(&scriptName, "database-script", "", "Name of database util script.") - //flag.Parse() - - //staticFS = bootstrap.NewFS(staticZip) - //bootstrap.Init(confPath, staticFS) } func main() { cmd.Execute() - return - // 关闭数据库连接 - - //if scriptName != "" { - // // 开始运行助手数据库脚本 - // bootstrap.RunScript(scriptName) - // return - //} } diff --git a/middleware/adminsection_test.go b/middleware/adminsection_test.go new file mode 100644 index 00000000..703c9362 --- /dev/null +++ b/middleware/adminsection_test.go @@ -0,0 +1,109 @@ +package middleware + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" +) + +func perms(bits ...types.GroupPermission) *boolset.BooleanSet { + b := &boolset.BooleanSet{} + for _, p := range bits { + boolset.Set(int(p), true, b) + } + return b +} + +func newAdminRequest(t *testing.T, permissions *boolset.BooleanSet) *gin.Context { + t.Helper() + w := httptest.NewRecorder() + c := gin.CreateTestContextOnly(w, testEngine) + c.Request = httptest.NewRequest("GET", "/api/v4/admin/summary", nil) + dep := dependency.NewDependency( + dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)), + ) + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), dependency.DepCtx{}, dep)) + u := &ent.User{ + ID: 2, + Edges: ent.UserEdges{ + Group: &ent.Group{Permissions: permissions}, + }, + } + util.WithValue(c, inventory.UserCtx{}, u) + return c +} + +func TestIsAdminOrDelegated(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := IsAdminOrDelegated() + + t.Run("full admin passes", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionIsAdmin)) + handler(c) + if c.IsAborted() { + t.Fatal("full admin was rejected") + } + }) + + t.Run("delegated admin passes", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionAdminUsers)) + handler(c) + if c.IsAborted() { + t.Fatal("delegated admin was rejected") + } + }) + + t.Run("regular user rejected", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionShare)) + handler(c) + if !c.IsAborted() { + t.Fatal("regular user was not rejected") + } + }) +} + +func TestAdminSection(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := AdminSection(types.GroupPermissionAdminUsers, types.GroupPermissionAdminGroups) + + t.Run("full admin passes any section", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionIsAdmin)) + handler(c) + if c.IsAborted() { + t.Fatal("full admin was rejected") + } + }) + + t.Run("matching delegated section passes", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionAdminUsers)) + handler(c) + if c.IsAborted() { + t.Fatal("delegated admin with matching section was rejected") + } + }) + + t.Run("non-matching delegated section rejected", func(t *testing.T) { + c := newAdminRequest(t, perms(types.GroupPermissionAdminFiles)) + handler(c) + if !c.IsAborted() { + t.Fatal("delegated admin without matching section was not rejected") + } + }) + + t.Run("regular user rejected", func(t *testing.T) { + c := newAdminRequest(t, perms()) + handler(c) + if !c.IsAborted() { + t.Fatal("regular user was not rejected") + } + }) +} diff --git a/middleware/auth.go b/middleware/auth.go index 0653faeb..135f115d 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "crypto/subtle" "net/http" + "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" @@ -104,44 +105,77 @@ func LoginRequired() gin.HandlerFunc { // WebDAVAuth 验证WebDAV登录及权限 func WebDAVAuth() gin.HandlerFunc { return func(c *gin.Context) { + dep := dependency.FromContext(c) + l := dep.Logger() + username, password, ok := c.Request.BasicAuth() + var expectedUser *ent.User + bearerAuth := false if !ok { - // OPTIONS 请求不需要鉴权 - if c.Request.Method == http.MethodOptions { - c.Next() + // Bearer auth: accept Cloudreve API access tokens so OIDC-only + // users and API clients can mount without a DAV password (#3548). + if !strings.HasPrefix(c.GetHeader(auth.AuthorizationHeader), auth.TokenHeaderPrefix) { + // OPTIONS 请求不需要鉴权 + if c.Request.Method == http.MethodOptions { + c.Next() + return + } + c.Writer.Header().Add("WWW-Authenticate", `Basic realm="cloudreve"`) + c.Writer.Header().Add("WWW-Authenticate", `Bearer realm="cloudreve"`) + c.Status(http.StatusUnauthorized) + c.Abort() return } - c.Writer.Header()["WWW-Authenticate"] = []string{`Basic realm="cloudreve"`} - c.Status(http.StatusUnauthorized) - c.Abort() - return - } - dep := dependency.FromContext(c) - l := dep.Logger() - userClient := dep.UserClient() - expectedUser, err := userClient.GetActiveByDavAccount(c, username, password) - if err != nil { - if username == "" { - if u, err := userClient.GetByEmail(c, username); err == nil { - // Try login with known user but incorrect password, record audit log - SetUserCtxByUser(c, u) - } + if _, err := dep.TokenAuth().VerifyAndRetrieveUser(c); err != nil { + l.Debug("WebDAVAuth: bearer token rejected: %s", err) + c.Status(http.StatusUnauthorized) + c.Abort() + return } - l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err) - c.Status(http.StatusUnauthorized) - c.Abort() - return - } + uid := inventory.UserIDFromContext(c) + if uid == 0 { + c.Status(http.StatusUnauthorized) + c.Abort() + return + } - // Validate dav account - accounts, err := expectedUser.Edges.DavAccountsOrErr() - if err != nil || len(accounts) == 0 { - l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err) - c.Status(http.StatusUnauthorized) - c.Abort() - return + if err := SetUserCtx(c, uid); err != nil { + l.Debug("WebDAVAuth: failed to load bearer user %d: %s", uid, err) + c.Status(http.StatusUnauthorized) + c.Abort() + return + } + + expectedUser = inventory.UserFromContext(c) + bearerAuth = true + } else { + userClient := dep.UserClient() + var err error + expectedUser, err = userClient.GetActiveByDavAccount(c, username, password) + if err != nil { + if username == "" { + if u, err := userClient.GetByEmail(c, username); err == nil { + // Try login with known user but incorrect password, record audit log + SetUserCtxByUser(c, u) + } + } + + l.Debug("WebDAVAuth: failed to get user %q with provided credential: %s", username, err) + c.Status(http.StatusUnauthorized) + c.Abort() + return + } + + // Validate dav account + accounts, err := expectedUser.Edges.DavAccountsOrErr() + if err != nil || len(accounts) == 0 { + l.Debug("WebDAVAuth: failed to get user dav accounts %q with provided credential: %s", username, err) + c.Status(http.StatusUnauthorized) + c.Abort() + return + } } // 用户组已启用WebDAV? @@ -160,17 +194,38 @@ func WebDAVAuth() gin.HandlerFunc { return } + // Scoped client tokens need at least Files.Read; tokens without + // Files.Write are confined to read-only access. + if bearerAuth { + if err := auth.CheckScope(c, types.ScopeFilesRead); err != nil { + c.Status(http.StatusForbidden) + c.Abort() + return + } + } + // 检查是否只读 - if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) { + readOnly := group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) + if len(expectedUser.Edges.DavAccounts) > 0 && + expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) { + readOnly = true + } + if bearerAuth && auth.CheckScope(c, types.ScopeFilesWrite) != nil { + readOnly = true + } + + if readOnly { switch c.Request.Method { - case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK": + case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "PROPPATCH": c.Status(http.StatusForbidden) c.Abort() return } } - SetUserCtxByUser(c, expectedUser) + if !bearerAuth { + SetUserCtxByUser(c, expectedUser) + } c.Next() } } @@ -280,6 +335,64 @@ func IsAdmin() gin.HandlerFunc { } } +// IsAdminOrDelegated allows full admins and delegated administrators (groups +// carrying at least one per-section admin permission bit). +func IsAdminOrDelegated() gin.HandlerFunc { + return func(c *gin.Context) { + user := inventory.UserFromContext(c) + permissions := user.Edges.Group.Permissions + if !permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + delegated := false + for _, p := range types.DelegatedAdminPermissions() { + if permissions.Enabled(int(p)) { + delegated = true + break + } + } + + if !delegated { + c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil)) + c.Abort() + return + } + } + + c.Next() + } +} + +// AdminSection requires the full admin permission or at least one of the +// given delegated admin section permissions. Delegated-admin passes are +// audit-logged since they exercise elevated permissions without full admin. +func AdminSection(sections ...types.GroupPermission) gin.HandlerFunc { + return func(c *gin.Context) { + user := inventory.UserFromContext(c) + permissions := user.Edges.Group.Permissions + if !permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + allowed := false + for _, p := range sections { + if permissions.Enabled(int(p)) { + allowed = true + break + } + } + + if !allowed { + c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "", nil)) + c.Abort() + return + } + + dependency.FromContext(c).Logger().Info( + "Delegated admin %q (uid=%d) accessed %s %s", + user.Email, user.ID, c.Request.Method, c.FullPath(), + ) + } + + c.Next() + } +} + // RequiredScopes checks if the JWT token has the required scopes. // If the token has scopes (hasScopes is true), it verifies the token has all required scopes. // Write scopes implicitly include Read scopes for the same resource (e.g., "File.Write" includes "File.Read"). diff --git a/middleware/davbearer_test.go b/middleware/davbearer_test.go new file mode 100644 index 00000000..c3619193 --- /dev/null +++ b/middleware/davbearer_test.go @@ -0,0 +1,194 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" +) + +type stubTokenAuth struct { + uid int + scopes []string +} + +func (s stubTokenAuth) Issue(ctx context.Context, args *auth.IssueTokenArgs) (*auth.Token, error) { + return nil, nil +} + +func (s stubTokenAuth) VerifyAndRetrieveUser(c *gin.Context) (bool, error) { + if c.GetHeader(auth.AuthorizationHeader) == "Bearer good" { + util.WithValue(c, inventory.UserIDCtx{}, s.uid) + if s.scopes != nil { + util.WithValue(c, auth.ScopeContextKey{}, s.scopes) + } + } + return false, nil +} + +func (s stubTokenAuth) Refresh(ctx context.Context, refreshToken string) (*auth.Token, error) { + return nil, nil +} + +func (s stubTokenAuth) Claims(ctx context.Context, tokenStr string) (*auth.Claims, error) { + return nil, nil +} + +func newDavAuthDep(t *testing.T, groupPerms *boolset.BooleanSet, scopes []string) dependency.Dep { + t.Helper() + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1") + t.Cleanup(func() { client.Close() }) + + group := client.Group.Create(). + SetName("g"). + SetPermissions(groupPerms). + SaveX(context.Background()) + + u := client.User.Create(). + SetEmail("dav@test.com"). + SetNick("dav"). + SetStatus("active"). + SetPassword("x"). + SetGroup(group). + SaveX(context.Background()) + + return dependency.NewDependency( + dependency.WithKV(cache.NewMemoStore("", nil)), + dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)), + dependency.WithUserClient(inventory.NewUserClient(client)), + dependency.WithTokenAuth(stubTokenAuth{uid: u.ID, scopes: scopes}), + ) +} + +func newDavRequest(t *testing.T, dep dependency.Dep, method, authHeader string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + w := httptest.NewRecorder() + c := gin.CreateTestContextOnly(w, testEngine) + req := httptest.NewRequest(method, "/dav/file.txt", nil) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + c.Request = req.WithContext(context.WithValue(req.Context(), dependency.DepCtx{}, dep)) + return c, w +} + +func TestWebDAVBearerAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := WebDAVAuth() + + davPerms := &boolset.BooleanSet{} + boolset.Set(int(types.GroupPermissionWebDAV), true, davPerms) + readOnlyPerms := &boolset.BooleanSet{} + boolset.Set(int(types.GroupPermissionWebDAV), true, readOnlyPerms) + boolset.Set(int(types.GroupPermissionWebDAVReadOnly), true, readOnlyPerms) + + t.Run("valid bearer token authenticates", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.IsAborted() { + t.Fatal("valid bearer token was rejected") + } + u := inventory.UserFromContext(c) + if u == nil || u.Email != "dav@test.com" { + t.Fatalf("expected bearer user in ctx, got %+v", u) + } + }) + + t.Run("invalid bearer token rejected", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer bad") + handler(c) + if !c.IsAborted() || c.Writer.Status() != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", c.Writer.Status()) + } + }) + + t.Run("no credentials still issues basic+bearer challenge", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, w := newDavRequest(t, dep, http.MethodGet, "") + handler(c) + if c.Writer.Status() != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", c.Writer.Status()) + } + if challenges := w.Header().Values("WWW-Authenticate"); len(challenges) != 2 { + t.Fatalf("expected Basic+Bearer challenges, got %v", challenges) + } + }) + + t.Run("options without credentials passes", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodOptions, "") + handler(c) + if c.IsAborted() { + t.Fatal("unauthenticated OPTIONS was rejected") + } + }) + + t.Run("bearer user without group dav permission forbidden", func(t *testing.T) { + dep := newDavAuthDep(t, &boolset.BooleanSet{}, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403, got %d", c.Writer.Status()) + } + }) + + t.Run("group read-only blocks bearer writes", func(t *testing.T) { + dep := newDavAuthDep(t, readOnlyPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodPut, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403 for PUT, got %d", c.Writer.Status()) + } + c, _ = newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.IsAborted() { + t.Fatal("read-only bearer GET was rejected") + } + }) + + t.Run("scoped token without Files.Write is read-only", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, []string{types.ScopeFilesRead}) + c, _ := newDavRequest(t, dep, http.MethodDelete, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403 for DELETE, got %d", c.Writer.Status()) + } + c, _ = newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.IsAborted() { + t.Fatal("scoped read token GET was rejected") + } + }) + + t.Run("scoped token without Files.Read forbidden", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, []string{"User.Read"}) + c, _ := newDavRequest(t, dep, http.MethodGet, "Bearer good") + handler(c) + if c.Writer.Status() != http.StatusForbidden { + t.Fatalf("expected 403, got %d", c.Writer.Status()) + } + }) + + t.Run("basic auth path unchanged", func(t *testing.T) { + dep := newDavAuthDep(t, davPerms, nil) + c, _ := newDavRequest(t, dep, http.MethodGet, "Basic dGVzdDp0ZXN0") + handler(c) + // No dav account exists -> 401 via the basic credential path. + if c.Writer.Status() != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", c.Writer.Status()) + } + }) +} diff --git a/middleware/ratelimit_test.go b/middleware/ratelimit_test.go index 1b9a72dc..d1dd576f 100644 --- a/middleware/ratelimit_test.go +++ b/middleware/ratelimit_test.go @@ -85,9 +85,9 @@ func TestRateLimitExpiry(t *testing.T) { t.Fatal("second request not rejected") } - // Window expiry frees the bucket. MemoStore TTL is Unix-second granular - // (item valid while Expires >= now), so a 1s window can live ~2s. - time.Sleep(2100 * time.Millisecond) + // Window expiry frees the bucket; TTL semantics are covered in pkg/cache + // tests, so the bucket is cleared directly rather than sleeping ~2s. + dep.KV().Delete(rateLimitPrefix + "login:192.0.2.1") c, _ = newRateLimitContext(t, dep, "192.0.2.1") handler(c) diff --git a/middleware/session.go b/middleware/session.go index aa1fae7d..1d269f15 100644 --- a/middleware/session.go +++ b/middleware/session.go @@ -6,7 +6,6 @@ import ( "net/http" "strings" - "github.com/cloudreve/Cloudreve/v4/pkg/conf" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-contrib/sessions" @@ -22,7 +21,7 @@ const SessionName = "cloudreve-session" func Session(dep dependency.Dep) gin.HandlerFunc { Store = sessionstore.NewStore(dep.KV(), []byte(dep.ConfigProvider().System().SessionSecret)) sameSiteMode := http.SameSiteDefaultMode - switch strings.ToLower(conf.CORSConfig.SameSite) { + switch strings.ToLower(dep.ConfigProvider().Cors().SameSite) { case "default": sameSiteMode = http.SameSiteDefaultMode case "none": @@ -39,7 +38,7 @@ func Session(dep dependency.Dep) gin.HandlerFunc { MaxAge: 60 * 86400, Path: "/", SameSite: sameSiteMode, - Secure: conf.CORSConfig.Secure, + Secure: dep.ConfigProvider().Cors().Secure, }) return sessions.Sessions(SessionName, Store) @@ -61,7 +60,7 @@ func CSRFCheck() gin.HandlerFunc { return } - c.JSON(200, serializer.ErrDeprecated(serializer.CodeNoPermissionErr, "Invalid origin", nil)) + c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeNoPermissionErr, "Invalid origin", nil)) c.Abort() } } diff --git a/pkg/auth/hmac_test.go b/pkg/auth/hmac_test.go new file mode 100644 index 00000000..e1039273 --- /dev/null +++ b/pkg/auth/hmac_test.go @@ -0,0 +1,53 @@ +package auth + +import ( + "testing" + "time" +) + +func TestHMACSignCheckRoundTrip(t *testing.T) { + a := HMACAuth{SecretKey: []byte("secret")} + + sign := a.Sign("body-content", 0) + if err := a.Check("body-content", sign); err != nil { + t.Fatalf("expected valid sign, got %v", err) + } +} + +func TestHMACCheckExpired(t *testing.T) { + a := HMACAuth{SecretKey: []byte("secret")} + + sign := a.Sign("body", time.Now().Add(-time.Hour).Unix()) + if err := a.Check("body", sign); err != ErrExpired { + t.Fatalf("expected ErrExpired, got %v", err) + } +} + +func TestHMACCheckMissingExpires(t *testing.T) { + a := HMACAuth{SecretKey: []byte("secret")} + + if err := a.Check("body", "abc:"); err != ErrExpiresMissing { + t.Fatalf("expected ErrExpiresMissing, got %v", err) + } +} + +func TestHMACCheckTampered(t *testing.T) { + a := HMACAuth{SecretKey: []byte("secret")} + + if err := a.Check("other-body", a.Sign("body", 0)); err == nil { + t.Fatal("expected invalid sign error for tampered body") + } + + if err := a.Check("body", a.Sign("body", 0)+"tampered"); err == nil { + t.Fatal("expected invalid sign error for tampered signature") + } +} + +func TestHMACZeroExpiresNeverExpires(t *testing.T) { + a := HMACAuth{SecretKey: []byte("secret")} + + sign := a.Sign("body", 0) + if err := a.Check("body", sign); err != nil { + t.Fatalf("expires=0 should never expire, got %v", err) + } +} diff --git a/pkg/auth/jwt_test.go b/pkg/auth/jwt_test.go new file mode 100644 index 00000000..42aa4abf --- /dev/null +++ b/pkg/auth/jwt_test.go @@ -0,0 +1,205 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" +) + +type stubSettingProvider struct { + setting.Provider + tokenAuth *setting.TokenAuth + siteBasic *setting.SiteBasic +} + +func (s *stubSettingProvider) TokenAuth(ctx context.Context) *setting.TokenAuth { + return s.tokenAuth +} + +func (s *stubSettingProvider) SiteBasic(ctx context.Context) *setting.SiteBasic { + return s.siteBasic +} + +type stubUserClient struct { + inventory.UserClient + user *ent.User + err error +} + +func (s *stubUserClient) GetActiveByID(ctx context.Context, id int) (*ent.User, error) { + if s.err != nil { + return nil, s.err + } + return s.user, nil +} + +func newTestTokenAuth(t *testing.T, userClient inventory.UserClient, kv cache.Driver) *tokenAuth { + t.Helper() + encoder, err := hashid.New("test-salt") + if err != nil { + t.Fatalf("failed to create hashid encoder: %v", err) + } + + return &tokenAuth{ + idEncoder: encoder, + s: &stubSettingProvider{ + tokenAuth: &setting.TokenAuth{ + AccessTokenTTL: time.Hour, + RefreshTokenTTL: 24 * time.Hour, + }, + siteBasic: &setting.SiteBasic{ID: "site-id"}, + }, + secret: []byte("jwt-secret"), + userClient: userClient, + l: logging.NewConsoleLogger(logging.LevelDebug), + kv: kv, + } +} + +func TestIssueAndClaimsRoundTrip(t *testing.T) { + ta := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil)) + + user := &ent.User{ID: 42, Email: "u@example.com", Password: "pw"} + token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user}) + if err != nil { + t.Fatalf("Issue failed: %v", err) + } + + claims, err := ta.Claims(context.Background(), token.AccessToken) + if err != nil { + t.Fatalf("Claims failed: %v", err) + } + if claims.TokenType != TokenTypeAccess { + t.Fatalf("expected access token type, got %q", claims.TokenType) + } + + uid, err := ta.idEncoder.Decode(claims.Subject, hashid.UserID) + if err != nil { + t.Fatalf("failed to decode subject: %v", err) + } + if uid != user.ID { + t.Fatalf("expected uid %d, got %d", user.ID, uid) + } + + refreshClaims, err := ta.Claims(context.Background(), token.RefreshToken) + if err != nil { + t.Fatalf("Claims on refresh token failed: %v", err) + } + if refreshClaims.TokenType != TokenTypeRefresh { + t.Fatalf("expected refresh token type, got %q", refreshClaims.TokenType) + } + if refreshClaims.RootTokenID == nil { + t.Fatal("refresh token missing RootTokenID") + } +} + +func TestClaimsRejectsTamperedToken(t *testing.T) { + ta := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil)) + + if _, err := ta.Claims(context.Background(), "not.a.jwt"); err == nil { + t.Fatal("expected error for malformed token") + } + + // Sign with a different secret must fail verification. + other := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil)) + other.secret = []byte("other-secret") + token, err := other.Issue(context.Background(), &IssueTokenArgs{User: &ent.User{ID: 1}}) + if err != nil { + t.Fatalf("Issue failed: %v", err) + } + if _, err := ta.Claims(context.Background(), token.AccessToken); err == nil { + t.Fatal("expected signature mismatch error") + } +} + +func TestRefreshRotatesTokenPair(t *testing.T) { + user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"} + kv := cache.NewMemoStore("", nil) + ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv) + + token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user}) + if err != nil { + t.Fatalf("Issue failed: %v", err) + } + + refreshed, err := ta.Refresh(context.Background(), token.RefreshToken) + if err != nil { + t.Fatalf("Refresh failed: %v", err) + } + if refreshed.AccessToken == "" || refreshed.RefreshToken == "" { + t.Fatal("refresh returned empty token pair") + } +} + +func TestRefreshRejectsAccessToken(t *testing.T) { + user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"} + ta := newTestTokenAuth(t, &stubUserClient{user: user}, cache.NewMemoStore("", nil)) + + token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user}) + if err != nil { + t.Fatalf("Issue failed: %v", err) + } + + if _, err := ta.Refresh(context.Background(), token.AccessToken); err != ErrInvalidRefreshToken { + t.Fatalf("expected ErrInvalidRefreshToken, got %v", err) + } +} + +func TestRefreshRejectsRevokedRootToken(t *testing.T) { + user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"} + kv := cache.NewMemoStore("", nil) + ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv) + + token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user}) + if err != nil { + t.Fatalf("Issue failed: %v", err) + } + + claims, err := ta.Claims(context.Background(), token.RefreshToken) + if err != nil { + t.Fatalf("Claims failed: %v", err) + } + kv.Set(RevokeTokenPrefix+claims.RootTokenID.String(), true, 0) + + if _, err := ta.Refresh(context.Background(), token.RefreshToken); err != ErrInvalidRefreshToken { + t.Fatalf("expected ErrInvalidRefreshToken for revoked root, got %v", err) + } +} + +func TestRefreshRejectsChangedUserState(t *testing.T) { + user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"} + kv := cache.NewMemoStore("", nil) + ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv) + + token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user}) + if err != nil { + t.Fatalf("Issue failed: %v", err) + } + + // Password change invalidates the state hash in the refresh token. + changed := &ent.User{ID: 7, Email: "r@example.com", Password: "new-pw"} + ta.userClient = &stubUserClient{user: changed} + + if _, err := ta.Refresh(context.Background(), token.RefreshToken); err != ErrInvalidRefreshToken { + t.Fatalf("expected ErrInvalidRefreshToken after password change, got %v", err) + } +} + +func TestValidateScopes(t *testing.T) { + if !ValidateScopes([]string{"a", "b"}, []string{"a", "b", "c"}) { + t.Fatal("subset should validate") + } + if ValidateScopes([]string{"a", "z"}, []string{"a", "b"}) { + t.Fatal("superset scope should be rejected") + } + if !ValidateScopes(nil, []string{"a"}) { + t.Fatal("empty request should validate") + } +} diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go index 8b935253..b73b41c8 100644 --- a/pkg/auth/oidc.go +++ b/pkg/auth/oidc.go @@ -21,6 +21,12 @@ type OIDCIDTokenClaims struct { UpdatedAt int64 `json:"updated_at,omitempty"` Email string `json:"email,omitempty"` EmailVerified bool `json:"email_verified,omitempty"` + // AD FS and similar providers emit these instead of the standard + // profile claims; their userinfo endpoints often return only sub. + UPN string `json:"upn,omitempty"` + UniqueName string `json:"unique_name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` } type JWKSet struct { diff --git a/pkg/auth/oidc_consumer.go b/pkg/auth/oidc_consumer.go index fde768e9..145b0e05 100644 --- a/pkg/auth/oidc_consumer.go +++ b/pkg/auth/oidc_consumer.go @@ -43,6 +43,11 @@ type OIDCUserInfo struct { Picture string `json:"picture,omitempty"` Email string `json:"email,omitempty"` EmailVerified *bool `json:"email_verified,omitempty"` + // AD FS-style alternates, mirrored from the ID-token claims. + UPN string `json:"upn,omitempty"` + UniqueName string `json:"unique_name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` } // Validate checks the discovery document has the endpoints the code flow requires. diff --git a/pkg/auth/oidc_consumer_test.go b/pkg/auth/oidc_consumer_test.go index 804e0f04..ab5c1499 100644 --- a/pkg/auth/oidc_consumer_test.go +++ b/pkg/auth/oidc_consumer_test.go @@ -89,6 +89,23 @@ func TestVerifyOIDCIDToken(t *testing.T) { _, err := VerifyOIDCIDToken("not-a-jwt", "https://idp.example.com", "cloudreve", "nonce-1", jwks) require.Error(t, err) }) + + t.Run("adfs profile claims", func(t *testing.T) { + claims := validClaims() + claims.Email = "" + claims.UPN = "user@corp.example.com" + claims.UniqueName = `CORP\user` + claims.GivenName = "Jane" + claims.FamilyName = "Doe" + claims.Name = "Jane Doe" + token := signConsumerToken(t, pemKey, claims) + parsed, err := VerifyOIDCIDToken(token, "https://idp.example.com", "cloudreve", "nonce-1", jwks) + require.NoError(t, err) + require.Equal(t, "user@corp.example.com", parsed.UPN) + require.Equal(t, `CORP\user`, parsed.UniqueName) + require.Equal(t, "Jane", parsed.GivenName) + require.Equal(t, "Doe", parsed.FamilyName) + }) } func TestOIDCDiscoveryValidate(t *testing.T) { diff --git a/pkg/cache/memo_test.go b/pkg/cache/memo_test.go index 1f812018..a530977a 100644 --- a/pkg/cache/memo_test.go +++ b/pkg/cache/memo_test.go @@ -63,7 +63,7 @@ func TestMemoStore_Get(t *testing.T) { // 过期 { _ = store.Set("string", "string_val", 1) - time.Sleep(time.Duration(2) * time.Second) + store.Store.Store("string", itemWithTTL{Value: "string_val", Expires: time.Now().Unix() - 1}) val, ok := store.Get("string") asserts.Nil(val) asserts.False(ok) @@ -141,7 +141,7 @@ func TestMemoStore_GarbageCollect(t *testing.T) { asserts := assert.New(t) store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) store.Set("test", 1, 1) - time.Sleep(time.Duration(2000) * time.Millisecond) + store.Store.Store("test", itemWithTTL{Value: 1, Expires: time.Now().Unix() - 1}) store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug)) _, ok := store.Get("test") asserts.False(ok) diff --git a/pkg/cluster/pool_test.go b/pkg/cluster/pool_test.go new file mode 100644 index 00000000..5fc2cc89 --- /dev/null +++ b/pkg/cluster/pool_test.go @@ -0,0 +1,88 @@ +package cluster + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/ent/node" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubSettings struct { + setting.Provider +} + +func capsOf(bits ...types.NodeCapability) *boolset.BooleanSet { + b := &boolset.BooleanSet{} + for _, c := range bits { + boolset.Set(int(c), true, b) + } + return b +} + +func newPoolFixture(t *testing.T) (*ent.Client, NodePool, context.Context) { + t.Helper() + ctx := context.Background() + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1") + t.Cleanup(func() { client.Close() }) + + logger := logging.NewConsoleLogger(logging.LevelError) + cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger) + require.NoError(t, err) + + pool, err := NewNodePool(ctx, logger, cfg, &stubSettings{}, inventory.NewNodeClient(client)) + require.NoError(t, err) + return client, pool, ctx +} + +func TestWeightedNodePoolGet(t *testing.T) { + a := assert.New(t) + client, pool, ctx := newPoolFixture(t) + + n1 := client.Node.Create(). + SetName("n1").SetType(node.TypeMaster).SetStatus(node.StatusActive). + SetServer("http://n1").SetCapabilities(capsOf(types.NodeCapabilityRemoteDownload)). + SetWeight(10).SaveX(ctx) + client.Node.Create(). + SetName("n2").SetType(node.TypeMaster).SetStatus(node.StatusActive). + SetServer("http://n2").SetCapabilities(capsOf(types.NodeCapabilityRemoteDownload)). + SetWeight(1).SaveX(ctx) + + pool.(*weightedNodePool).Upsert(ctx, n1) + // Upsert requires a full refresh — rebuild pool from the seeded DB instead. + pool, err := NewNodePool(ctx, logging.NewConsoleLogger(logging.LevelError), pool.(*weightedNodePool).conf, &stubSettings{}, inventory.NewNodeClient(client)) + require.NoError(t, err) + + // Capability with no matching nodes errors. + _, err = pool.Get(ctx, types.NodeCapabilityCreateArchive, 0) + a.True(errors.Is(err, ErrNoAvailableNode)) + + // Preferred node wins regardless of weight. + selected, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, n1.ID) + require.NoError(t, err) + a.Equal(n1.ID, selected.ID()) + + // Without preference, the heavier node wins the first pick. + selected, err = pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0) + require.NoError(t, err) + a.Equal(n1.ID, selected.ID()) +} + +func TestWeightedNodePoolGetUnknownCapability(t *testing.T) { + a := assert.New(t) + _, pool, ctx := newPoolFixture(t) + + _, err := pool.Get(ctx, types.NodeCapabilityRemoteDownload, 0) + a.True(errors.Is(err, ErrNoAvailableNode)) +} diff --git a/pkg/cluster/routes/routes.go b/pkg/cluster/routes/routes.go index 79b18f1b..11b2d6cc 100644 --- a/pkg/cluster/routes/routes.go +++ b/pkg/cluster/routes/routes.go @@ -18,15 +18,17 @@ const ( ) var ( - masterPing *url.URL - masterUserActivate *url.URL - masterUserReset *url.URL - masterHome *url.URL + masterPing *url.URL + masterUserActivate *url.URL + masterUserActivateEmail *url.URL + masterUserReset *url.URL + masterHome *url.URL ) func init() { masterPing, _ = url.Parse(constants.APIPrefix + "/site/ping") masterUserActivate, _ = url.Parse("/session/activate") + masterUserActivateEmail, _ = url.Parse("/session/activate_email") masterUserReset, _ = url.Parse("/session/reset") } @@ -62,6 +64,15 @@ func MasterUserActivateUrl(base *url.URL) *url.URL { return base.ResolveReference(masterUserActivate) } +func MasterUserActivateEmailAPIUrl(base *url.URL, uid string) *url.URL { + route, _ := url.Parse(constants.APIPrefix + "/user/activate_email/" + uid) + return base.ResolveReference(route) +} + +func MasterUserActivateEmailUrl(base *url.URL) *url.URL { + return base.ResolveReference(masterUserActivateEmail) +} + func MasterUserResetUrl(base *url.URL) *url.URL { return base.ResolveReference(masterUserReset) } @@ -203,11 +214,16 @@ func SlaveFileListRoute(srcPath string, recursive bool) string { return fmt.Sprintf("%s?%s", base, query.Encode()) } -func SlaveThumbUrl(base *url.URL, srcPath, ext string) *url.URL { +func SlaveThumbUrl(base *url.URL, srcPath, ext string, entityID int) *url.URL { srcPath = url.PathEscape(base64.URLEncoding.EncodeToString([]byte(srcPath))) ext = url.PathEscape(ext) route, _ := url.Parse(constants.APIPrefixSlave + fmt.Sprintf("/file/thumb/%s/%s", srcPath, ext)) base = base.ResolveReference(route) + if entityID > 0 { + query := base.Query() + query.Set("eid", strconv.Itoa(entityID)) + base.RawQuery = query.Encode() + } return base } diff --git a/pkg/conf/conf_test.go b/pkg/conf/conf_test.go new file mode 100644 index 00000000..7786e682 --- /dev/null +++ b/pkg/conf/conf_test.go @@ -0,0 +1,27 @@ +package conf + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/stretchr/testify/require" +) + +func TestEnvOverrideDatabaseName(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, "conf.ini") + require.NoError(t, os.WriteFile(confPath, []byte("[System]\nDebug = false\nMode = master\nListen = :5212\nSessionSecret = s\nHashIDSalt = s\n"), 0644)) + + t.Setenv("CR_CONF_Database.Type", "postgres") + t.Setenv("CR_CONF_Database.Host", "postgresql") + t.Setenv("CR_CONF_Database.User", "user") + t.Setenv("CR_CONF_Database.Name", "database") + t.Setenv("CR_CONF_Database.Port", "5432") + + p, err := NewIniConfigProvider(confPath, logging.NewConsoleLogger(logging.LevelDebug)) + require.NoError(t, err) + require.Equal(t, "user", p.Database().User) + require.Equal(t, "database", p.Database().Name) +} diff --git a/pkg/downloader/aria2/aria2_test.go b/pkg/downloader/aria2/aria2_test.go new file mode 100644 index 00000000..3c1611e1 --- /dev/null +++ b/pkg/downloader/aria2/aria2_test.go @@ -0,0 +1,121 @@ +package aria2 + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/downloader" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/require" +) + +type stubSettingProvider struct { + setting.Provider +} + +// rpcStub answers aria2 JSON-RPC over HTTP for the methods under test. +func rpcStub(t *testing.T, responses map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Id any `json:"id"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + result, ok := responses[req.Method] + require.True(t, ok, "unexpected rpc method: %s", req.Method) + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": req.Id, + "result": result, + }) + })) +} + +func newTestClient(server string) downloader.Downloader { + return New( + logging.NewConsoleLogger(logging.LevelError), + &stubSettingProvider{}, + &types.Aria2Setting{Server: server, TempPath: "/tmp"}, + ) +} + +func TestCreateTaskReturnsHandle(t *testing.T) { + srv := rpcStub(t, map[string]any{"aria2.addUri": "gid-abc"}) + defer srv.Close() + + c := newTestClient(srv.URL) + handle, err := c.CreateTask(context.Background(), "https://example.com/file", nil) + require.NoError(t, err) + require.Equal(t, "gid-abc", handle.ID) +} + +func TestInfoMapsStatuses(t *testing.T) { + cases := []struct { + aria2Status string + want downloader.Status + }{ + {"complete", downloader.StatusCompleted}, + {"error", downloader.StatusError}, + {"waiting", downloader.StatusDownloading}, + {"paused", downloader.StatusDownloading}, + } + for _, tc := range cases { + srv := rpcStub(t, map[string]any{ + "aria2.tellStatus": map[string]any{ + "gid": "g1", + "status": tc.aria2Status, + "totalLength": "100", + "completedLength": "50", + "bittorrent": map[string]any{}, + }, + }) + c := newTestClient(srv.URL) + status, err := c.Info(context.Background(), &downloader.TaskHandle{ID: "g1"}) + require.NoError(t, err) + require.Equal(t, tc.want, status.State, "aria2 status %q", tc.aria2Status) + srv.Close() + } +} + +func TestInfoSeedingWhenTorrentComplete(t *testing.T) { + srv := rpcStub(t, map[string]any{ + "aria2.tellStatus": map[string]any{ + "gid": "g1", + "status": "active", + "totalLength": "100", + "completedLength": "100", + "bittorrent": map[string]any{"mode": "single"}, + }, + }) + defer srv.Close() + + c := newTestClient(srv.URL) + status, err := c.Info(context.Background(), &downloader.TaskHandle{ID: "g1"}) + require.NoError(t, err) + require.Equal(t, downloader.StatusSeeding, status.State) +} + +func TestCancelRemovesTask(t *testing.T) { + srv := rpcStub(t, map[string]any{ + "aria2.tellStatus": map[string]any{ + "gid": "g1", + "status": "active", + "totalLength": "100", + "completedLength": "10", + "bittorrent": map[string]any{}, + "dir": "/tmp", + }, + "aria2.remove": "g1", + }) + defer srv.Close() + + c := newTestClient(srv.URL) + require.NoError(t, c.Cancel(context.Background(), &downloader.TaskHandle{ID: "g1"})) +} diff --git a/pkg/downloader/qbittorrent/qbittorrent.go b/pkg/downloader/qbittorrent/qbittorrent.go index 110cf2cd..c82c17f3 100644 --- a/pkg/downloader/qbittorrent/qbittorrent.go +++ b/pkg/downloader/qbittorrent/qbittorrent.go @@ -189,7 +189,7 @@ func (c *qbittorrentClient) Info(ctx context.Context, handle *downloader.TaskHan // Combining and converting all info state := downloader.StatusDownloading switch torrents[0].State { - case "downloading", "pausedDL", "allocating", "metaDL", "queuedDL", "stalledDL", "checkingDL", "forcedDL", "checkingResumeData", "moving", "forcedMetaDL": + case "downloading", "pausedDL", "stoppedDL", "allocating", "metaDL", "queuedDL", "stalledDL", "checkingDL", "forcedDL", "checkingResumeData", "moving", "forcedMetaDL": state = downloader.StatusDownloading case "uploading", "queuedUP", "stalledUP", "checkingUP", "forcedUP": state = downloader.StatusSeeding diff --git a/pkg/downloader/slave/slave.go b/pkg/downloader/slave/slave.go index 2ab27102..fd05942b 100644 --- a/pkg/downloader/slave/slave.go +++ b/pkg/downloader/slave/slave.go @@ -6,7 +6,7 @@ import ( "encoding/json" "fmt" - "github.com/cloudflare/cfssl/scan/crypto/sha1" + "crypto/sha1" "github.com/cloudreve/Cloudreve/v4/application/constants" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/downloader" diff --git a/pkg/filemanager/driver/cos/cos.go b/pkg/filemanager/driver/cos/cos.go index 1f66e7cf..0a3cbe26 100644 --- a/pkg/filemanager/driver/cos/cos.go +++ b/pkg/filemanager/driver/cos/cos.go @@ -234,7 +234,7 @@ func (handler Driver) CORS() error { return err } -// Get 获取文件 +// Open opens a file for reading func (handler *Driver) Open(ctx context.Context, path string) (*os.File, error) { return nil, errors.New("not implemented") } diff --git a/pkg/filemanager/driver/cos/media.go b/pkg/filemanager/driver/cos/media.go index 0a894a92..71750034 100644 --- a/pkg/filemanager/driver/cos/media.go +++ b/pkg/filemanager/driver/cos/media.go @@ -9,9 +9,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/mediameta" "github.com/cloudreve/Cloudreve/v4/pkg/request" "github.com/samber/lo" - "math" "net/http" - "strconv" "strings" "time" ) @@ -206,78 +204,25 @@ func (handler *Driver) extractMediaInfo(ctx context.Context, path string, opt *u } func parseGpsInfo(imageInfo ImageInfo) []driver.MediaMeta { - latitude := imageInfo["GPSLatitude"] // 31deg 16.26808' - longitude := imageInfo["GPSLongitude"] // 120deg 42.91039' - latRef := imageInfo["GPSLatitudeRef"] // North - lonRef := imageInfo["GPSLongitudeRef"] // East - - // Make sure all value exist in map - if latitude.Value == "" || longitude.Value == "" || latRef.Value == "" || lonRef.Value == "" { - return nil - } - - lat := parseRawGPS(latitude.Value, latRef.Value) - lon := parseRawGPS(longitude.Value, lonRef.Value) - if !math.IsNaN(lat) && !math.IsNaN(lon) { - lat, lng := mediameta.NormalizeGPS(lat, lon) - return []driver.MediaMeta{{ - Key: mediameta.GpsLat, - Value: fmt.Sprintf("%f", lat), - }, { - Key: mediameta.GpsLng, - Value: fmt.Sprintf("%f", lng), - }} - } - - return nil + return mediameta.GpsMeta(imageInfo["GPSLatitude"].Value, imageInfo["GPSLongitude"].Value, + imageInfo["GPSLatitudeRef"].Value, imageInfo["GPSLongitudeRef"].Value, parseRawGPS) } func parseRawGPS(gpsStr string, ref string) float64 { elem := strings.Split(gpsStr, " ") - if len(elem) < 1 { - return 0 - } - var ( - deg float64 - minutes float64 - seconds float64 - ) - - deg = getGpsElemValue(elem[0]) + var deg, minutes, seconds float64 + if len(elem) >= 1 { + deg = mediameta.GpsRationalElem(elem[0]) + } if len(elem) >= 2 { - minutes = getGpsElemValue(elem[1]) + minutes = mediameta.GpsRationalElem(elem[1]) } if len(elem) >= 3 { - seconds = getGpsElemValue(elem[2]) - } - - decimal := deg + minutes/60.0 + seconds/3600.0 - - if ref == "S" || ref == "W" { - return -decimal - } - - return decimal -} - -func getGpsElemValue(elm string) float64 { - elements := strings.Split(elm, "/") - if len(elements) != 2 { - return 0 - } - - numerator, err := strconv.ParseFloat(elements[0], 64) - if err != nil { - return 0 - } - - denominator, err := strconv.ParseFloat(elements[1], 64) - if err != nil || denominator == 0 { - return 0 + seconds = mediameta.GpsRationalElem(elem[2]) } - return numerator / denominator + return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W") } func handleCosError(resp string, originErr error) error { diff --git a/pkg/filemanager/driver/local/local.go b/pkg/filemanager/driver/local/local.go index 699c5d91..757f1926 100644 --- a/pkg/filemanager/driver/local/local.go +++ b/pkg/filemanager/driver/local/local.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strings" "time" "github.com/cloudreve/Cloudreve/v4/ent" @@ -151,16 +152,10 @@ func (handler *Driver) Put(ctx context.Context, file *fs.UploadRequest) error { } defer out.Close() - stat, err := out.Stat() - if err != nil { - handler.l.Warning("Failed to read file info: %s", err) - return err - } - - if stat.Size() < file.Offset { - return errors.New("size of unfinished uploaded chunks is not as expected") - } - + // Chunks may arrive out of order under concurrent uploads, so the current + // file size is not a valid precondition for the chunk offset. Positioned + // writes are safe here; a missing range is caught by the assembled-size + // check in CompleteUpload. if _, err := out.Seek(file.Offset, io.SeekStart); err != nil { return fmt.Errorf("failed to seek to desired offset %d: %s", file.Offset, err) } @@ -184,6 +179,8 @@ func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, e handler.l.Warning("Failed to delete file: %s", err) retErr = err deleteFailed = append(deleteFailed, value) + } else { + handler.pruneEmptyAncestors(filePath) } } @@ -194,6 +191,23 @@ func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, e return deleteFailed, retErr } +// pruneEmptyAncestors removes empty parent directories left behind by a +// deleted blob (#3290). os.Remove fails on non-empty directories, so the +// walk self-terminates; the climb is also bounded to the application +// root so it can never escape the storage tree. +func (handler *Driver) pruneEmptyAncestors(filePath string) { + root := filepath.Dir(util.RelativePath("x")) + for dir := filepath.Dir(filePath); ; dir = filepath.Dir(dir) { + rel, err := filepath.Rel(root, dir) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return + } + if err := os.Remove(dir); err != nil { + return + } + } +} + // Thumb 获取文件缩略图 func (handler *Driver) Thumb(ctx context.Context, expire *time.Time, ext string, e fs.Entity) (string, error) { return "", errors.New("not implemented") @@ -259,6 +273,24 @@ func (handler *Driver) CancelToken(ctx context.Context, uploadSession *fs.Upload } func (handler *Driver) CompleteUpload(ctx context.Context, session *fs.UploadSession) error { + // Concurrent chunks are written at their own offsets and may land out of + // order; verify the assembled file matches the declared size before + // reporting success so a missing or lost range fails loudly instead of + // leaving a corrupted blob. + if session.Props != nil && session.Props.SavePath != "" { + stat, err := os.Stat(handler.LocalPath(ctx, session.Props.SavePath)) + if err != nil { + return fmt.Errorf("failed to stat uploaded file: %w", err) + } + if stat.Size() != session.Props.Size { + return serializer.NewError( + serializer.CodeUploadFailed, + fmt.Sprintf("uploaded file size mismatch: expected %d, got %d", session.Props.Size, stat.Size()), + nil, + ) + } + } + if session.Callback == "" { return nil } diff --git a/pkg/filemanager/driver/local/local_test.go b/pkg/filemanager/driver/local/local_test.go new file mode 100644 index 00000000..999d78a6 --- /dev/null +++ b/pkg/filemanager/driver/local/local_test.go @@ -0,0 +1,80 @@ +package local + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/stretchr/testify/require" +) + +func TestDeletePrunesEmptyAncestors(t *testing.T) { + old := util.UseWorkingDir + util.UseWorkingDir = true + t.Cleanup(func() { util.UseWorkingDir = old }) + t.Chdir(t.TempDir()) + + nested := filepath.Join("uploads", "3", "deep", "nested") + require.NoError(t, os.MkdirAll(nested, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(nested, "a.txt"), []byte("a"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(nested, "b.txt"), []byte("b"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join("uploads", "keep.txt"), []byte("k"), 0644)) + + d := New(nil, logging.NewConsoleLogger(logging.LevelDebug), nil) + + // Deleting both files should remove deep/nested/3 but stop at uploads + // (still holds keep.txt). + failed, err := d.Delete(context.Background(), + filepath.Join("uploads", "3", "deep", "nested", "a.txt"), + filepath.Join("uploads", "3", "deep", "nested", "b.txt"), + ) + require.NoError(t, err) + require.Empty(t, failed) + require.NoDirExists(t, filepath.Join("uploads", "3")) + require.DirExists(t, "uploads") + require.FileExists(t, filepath.Join("uploads", "keep.txt")) +} + +func TestPutOutOfOrderChunks(t *testing.T) { + old := util.UseWorkingDir + util.UseWorkingDir = true + t.Cleanup(func() { util.UseWorkingDir = old }) + t.Chdir(t.TempDir()) + + d := New(nil, logging.NewConsoleLogger(logging.LevelDebug), nil) + ctx := context.Background() + savePath := filepath.Join("uploads", "blob.bin") + + props := &fs.UploadProps{SavePath: savePath, Size: 12} + put := func(offset int64, data string) { + require.NoError(t, d.Put(ctx, &fs.UploadRequest{ + Props: props, + Mode: fs.ModeOverwrite, + Offset: offset, + File: io.NopCloser(strings.NewReader(data)), + })) + } + + // Later chunk lands first — must not be rejected by the offset check. + put(8, "IJXL") + put(4, "EFGH") + put(0, "ABCD") + + content, err := os.ReadFile(savePath) + require.NoError(t, err) + require.Equal(t, "ABCDEFGHIJXL", string(content)) + + // Completion verifies the assembled size. + require.NoError(t, d.CompleteUpload(ctx, &fs.UploadSession{Props: props})) + + // A lost chunk fails loudly at completion instead of silently corrupting. + require.NoError(t, os.WriteFile(savePath, []byte("short"), 0644)) + err = d.CompleteUpload(ctx, &fs.UploadSession{Props: props}) + require.Error(t, err) +} diff --git a/pkg/filemanager/driver/obs/media.go b/pkg/filemanager/driver/obs/media.go index fec894fd..596e27c9 100644 --- a/pkg/filemanager/driver/obs/media.go +++ b/pkg/filemanager/driver/obs/media.go @@ -4,9 +4,7 @@ import ( "context" "encoding/json" "fmt" - "math" "net/http" - "strconv" "strings" "time" @@ -62,76 +60,23 @@ func (d *Driver) MediaMeta(ctx context.Context, path, ext, language string) ([]d } func parseGpsInfo(imageInfo map[string]string) []driver.MediaMeta { - latitude := imageInfo["GPSLatitude"] // 31/1, 162680820/10000000, 0/1 - longitude := imageInfo["GPSLongitude"] // 120/1, 429103939/10000000, 0/1 - latRef := imageInfo["GPSLatitudeRef"] // N - lonRef := imageInfo["GPSLongitudeRef"] // E - - // Make sure all value exist in map - if latitude == "" || longitude == "" || latRef == "" || lonRef == "" { - return nil - } - - lat := parseRawGPS(latitude, latRef) - lon := parseRawGPS(longitude, lonRef) - if !math.IsNaN(lat) && !math.IsNaN(lon) { - lat, lng := mediameta.NormalizeGPS(lat, lon) - return []driver.MediaMeta{{ - Key: mediameta.GpsLat, - Value: fmt.Sprintf("%f", lat), - }, { - Key: mediameta.GpsLng, - Value: fmt.Sprintf("%f", lng), - }} - } - - return nil + return mediameta.GpsMeta(imageInfo["GPSLatitude"], imageInfo["GPSLongitude"], + imageInfo["GPSLatitudeRef"], imageInfo["GPSLongitudeRef"], parseRawGPS) } func parseRawGPS(gpsStr string, ref string) float64 { elem := strings.Split(gpsStr, ", ") - if len(elem) < 1 { - return 0 - } - - var ( - deg float64 - minutes float64 - seconds float64 - ) - deg = getGpsElemValue(elem[0]) + var deg, minutes, seconds float64 + if len(elem) >= 1 { + deg = mediameta.GpsRationalElem(elem[0]) + } if len(elem) >= 2 { - minutes = getGpsElemValue(elem[1]) + minutes = mediameta.GpsRationalElem(elem[1]) } if len(elem) >= 3 { - seconds = getGpsElemValue(elem[2]) - } - - decimal := deg + minutes/60.0 + seconds/3600.0 - - if ref == "S" || ref == "W" { - return -decimal - } - - return decimal -} - -func getGpsElemValue(elm string) float64 { - elements := strings.Split(elm, "/") - if len(elements) != 2 { - return 0 - } - - numerator, err := strconv.ParseFloat(elements[0], 64) - if err != nil { - return 0 - } - - denominator, err := strconv.ParseFloat(elements[1], 64) - if err != nil || denominator == 0 { - return 0 + seconds = mediameta.GpsRationalElem(elem[2]) } - return numerator / denominator + return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W") } diff --git a/pkg/filemanager/driver/oss/media.go b/pkg/filemanager/driver/oss/media.go index 11db9483..808ae12d 100644 --- a/pkg/filemanager/driver/oss/media.go +++ b/pkg/filemanager/driver/oss/media.go @@ -5,7 +5,6 @@ import ( "encoding/json" "encoding/xml" "fmt" - "math" "net/http" "strconv" "strings" @@ -292,45 +291,17 @@ func (handler *Driver) extractMediaInfo(ctx context.Context, path string, catego } func parseGpsInfo(imageInfo ImageInfo) []driver.MediaMeta { - latitude := imageInfo["GPSLatitude"] // 31deg 16.26808' - longitude := imageInfo["GPSLongitude"] // 120deg 42.91039' - latRef := imageInfo["GPSLatitudeRef"] // North - lonRef := imageInfo["GPSLongitudeRef"] // East - - // Make sure all value exist in map - if latitude.Value == "" || longitude.Value == "" || latRef.Value == "" || lonRef.Value == "" { - return nil - } - - lat := parseRawGPS(latitude.Value, latRef.Value) - lon := parseRawGPS(longitude.Value, lonRef.Value) - if !math.IsNaN(lat) && !math.IsNaN(lon) { - lat, lng := mediameta.NormalizeGPS(lat, lon) - return []driver.MediaMeta{{ - Key: mediameta.GpsLat, - Value: fmt.Sprintf("%f", lat), - }, { - Key: mediameta.GpsLng, - Value: fmt.Sprintf("%f", lng), - }} - } - - return nil + return mediameta.GpsMeta(imageInfo["GPSLatitude"].Value, imageInfo["GPSLongitude"].Value, + imageInfo["GPSLatitudeRef"].Value, imageInfo["GPSLongitudeRef"].Value, parseRawGPS) } func parseRawGPS(gpsStr string, ref string) float64 { elem := strings.Split(gpsStr, " ") - if len(elem) < 1 { - return 0 - } - var ( - deg float64 - minutes float64 - seconds float64 - ) - - deg, _ = strconv.ParseFloat(strings.TrimSuffix(elem[0], "deg"), 64) + var deg, minutes, seconds float64 + if len(elem) >= 1 { + deg, _ = strconv.ParseFloat(strings.TrimSuffix(elem[0], "deg"), 64) + } if len(elem) >= 2 { minutes, _ = strconv.ParseFloat(strings.TrimSuffix(elem[1], "'"), 64) } @@ -338,13 +309,7 @@ func parseRawGPS(gpsStr string, ref string) float64 { seconds, _ = strconv.ParseFloat(strings.TrimSuffix(elem[2], "\""), 64) } - decimal := deg + minutes/60.0 + seconds/3600.0 - - if ref == "South" || ref == "West" { - return -decimal - } - - return decimal + return mediameta.DMSDecimal(deg, minutes, seconds, ref == "South" || ref == "West") } func handleOssError(resp string, originErr error) error { diff --git a/pkg/filemanager/driver/oss/oss.go b/pkg/filemanager/driver/oss/oss.go index 1b382533..c0191e37 100644 --- a/pkg/filemanager/driver/oss/oss.go +++ b/pkg/filemanager/driver/oss/oss.go @@ -351,7 +351,7 @@ func (handler *Driver) Delete(ctx context.Context, files ...string) ([]string, e // 统计未删除的文件 failed = append( failed, - util.SliceDifference(files, + util.SliceDifference(group, lo.Map(delRes.DeletedObjects, func(v oss.DeletedInfo, i int) string { return *v.Key }), diff --git a/pkg/filemanager/driver/qiniu/media.go b/pkg/filemanager/driver/qiniu/media.go index 81b106cc..5875a5be 100644 --- a/pkg/filemanager/driver/qiniu/media.go +++ b/pkg/filemanager/driver/qiniu/media.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math" "net/http" "net/url" "strconv" @@ -131,45 +130,17 @@ func unmarshalError(resp string, originErr error) error { } func parseGpsInfo(imageInfo ImageInfo) []driver.MediaMeta { - latitude := imageInfo["GPSLatitude"] // 31, 16.2680820, 0 - longitude := imageInfo["GPSLongitude"] // 120, 42.9103939, 0 - latRef := imageInfo["GPSLatitudeRef"] // N - lonRef := imageInfo["GPSLongitudeRef"] // E - - // Make sure all value exist in map - if latitude.Value == "" || longitude.Value == "" || latRef.Value == "" || lonRef.Value == "" { - return nil - } - - lat := parseRawGPS(latitude.Value, latRef.Value) - lon := parseRawGPS(longitude.Value, lonRef.Value) - if !math.IsNaN(lat) && !math.IsNaN(lon) { - lat, lng := mediameta.NormalizeGPS(lat, lon) - return []driver.MediaMeta{{ - Key: mediameta.GpsLat, - Value: fmt.Sprintf("%f", lat), - }, { - Key: mediameta.GpsLng, - Value: fmt.Sprintf("%f", lng), - }} - } - - return nil + return mediameta.GpsMeta(imageInfo["GPSLatitude"].Value, imageInfo["GPSLongitude"].Value, + imageInfo["GPSLatitudeRef"].Value, imageInfo["GPSLongitudeRef"].Value, parseRawGPS) } func parseRawGPS(gpsStr string, ref string) float64 { elem := strings.Split(gpsStr, ", ") - if len(elem) < 1 { - return 0 - } - - var ( - deg float64 - minutes float64 - seconds float64 - ) - deg, _ = strconv.ParseFloat(elem[0], 64) + var deg, minutes, seconds float64 + if len(elem) >= 1 { + deg, _ = strconv.ParseFloat(elem[0], 64) + } if len(elem) >= 2 { minutes, _ = strconv.ParseFloat(elem[1], 64) } @@ -177,11 +148,5 @@ func parseRawGPS(gpsStr string, ref string) float64 { seconds, _ = strconv.ParseFloat(elem[2], 64) } - decimal := deg + minutes/60.0 + seconds/3600.0 - - if ref == "S" || ref == "W" { - return -decimal - } - - return decimal + return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W") } diff --git a/pkg/filemanager/driver/remote/client.go b/pkg/filemanager/driver/remote/client.go index 859abafa..6918c9f5 100644 --- a/pkg/filemanager/driver/remote/client.go +++ b/pkg/filemanager/driver/remote/client.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "strings" + "sync/atomic" "time" "github.com/cloudreve/Cloudreve/v4/application/constants" @@ -91,6 +92,37 @@ type remoteClient struct { httpClient request.Client settings setting.Provider l logging.Logger + // clockOffset compensates master/slave clock skew, learned from a + // signature-expired response's Date header (upstream #3242). + clockOffset atomic.Int64 +} + +// request sends a signed slave API request, applying the learned clock offset +// to the signature base time so skewed master/slave clocks do not produce +// expired signatures. +func (c *remoteClient) request(method, target string, body io.Reader, opts ...request.Option) *request.Response { + if off := c.clockOffset.Load(); off != 0 { + opts = append(opts, request.WithSignBaseTime(time.Now().Add(time.Duration(off)*time.Second))) + } + return c.httpClient.Request(method, target, body, opts...) +} + +// learnClockOffset stores the master-minus-local clock offset computed from +// the response Date header. +func (c *remoteClient) learnClockOffset(resp *http.Response) { + if resp == nil { + return + } + masterTime, err := http.ParseTime(resp.Header.Get("Date")) + if err != nil { + return + } + // Date has second precision; round up so truncation never under-compensates. + offset := int64(masterTime.Sub(time.Now()).Seconds()) + 1 + if offset != 0 { + c.l.Info("Compensating master clock offset of %ds after signature expiry", offset) + c.clockOffset.Store(offset) + } } func (c *remoteClient) Upload(ctx context.Context, file *fs.UploadRequest) error { @@ -133,7 +165,7 @@ func (c *remoteClient) Upload(ctx context.Context, file *fs.UploadRequest) error } func (c *remoteClient) DeleteUploadSession(ctx context.Context, sessionID string) error { - resp, err := c.httpClient.Request( + resp, err := c.request( "DELETE", "upload/"+sessionID, nil, @@ -161,7 +193,7 @@ func (c *remoteClient) DeleteFiles(ctx context.Context, files ...string) ([]stri return files, fmt.Errorf("failed to marshal delete request: %w", err) } - resp, err := c.httpClient.Request( + resp, err := c.request( "DELETE", "file", bytes.NewReader(reqStr), @@ -185,7 +217,7 @@ func (c *remoteClient) DeleteFiles(ctx context.Context, files ...string) ([]stri } func (c *remoteClient) MediaMeta(ctx context.Context, src, ext, language string) ([]driver.MediaMeta, error) { - resp, err := c.httpClient.Request( + resp, err := c.request( http.MethodGet, routes.SlaveMediaMetaRoute(src, ext, language), nil, @@ -214,14 +246,33 @@ func (c *remoteClient) CreateUploadSession(ctx context.Context, session *fs.Uplo return err } - bodyReader := strings.NewReader(string(reqBodyEncoded)) - resp, err := c.httpClient.Request( - "PUT", - "upload", - bodyReader, - request.WithContext(ctx), - request.WithLogger(c.l), - ).CheckHTTPResponse(200).DecodeResponse() + send := func() (*serializer.Response, error) { + raw := c.request( + "PUT", + "upload", + strings.NewReader(string(reqBodyEncoded)), + request.WithContext(ctx), + request.WithLogger(c.l), + ) + httpResp := raw.Response + resp, err := raw.CheckHTTPResponse(200).DecodeResponse() + if err == nil && resp.Code == serializer.CodeSignExpired { + // Master clock is ahead of ours beyond the sign TTL; learn the + // offset from the response Date header and retry once (#3242). + c.learnClockOffset(httpResp) + raw = c.request( + "PUT", + "upload", + strings.NewReader(string(reqBodyEncoded)), + request.WithContext(ctx), + request.WithLogger(c.l), + ) + resp, err = raw.CheckHTTPResponse(200).DecodeResponse() + } + return resp, err + } + + resp, err := send() if err != nil { return err } @@ -234,7 +285,7 @@ func (c *remoteClient) CreateUploadSession(ctx context.Context, session *fs.Uplo } func (c *remoteClient) List(ctx context.Context, path string, recursive bool) ([]fs.PhysicalObject, error) { - resp, err := c.httpClient.Request( + resp, err := c.request( http.MethodGet, routes.SlaveFileListRoute(path, recursive), nil, @@ -271,7 +322,7 @@ func (c *remoteClient) GetUploadURL(ctx context.Context, expires time.Time, sess } func (c *remoteClient) uploadChunk(ctx context.Context, sessionID string, index int, chunk io.Reader, overwrite bool, size int64) error { - resp, err := c.httpClient.Request( + resp, err := c.request( "POST", fmt.Sprintf("upload/%s?chunk=%d", sessionID, index), chunk, diff --git a/pkg/filemanager/driver/remote/client_test.go b/pkg/filemanager/driver/remote/client_test.go new file mode 100644 index 00000000..6688c5f7 --- /dev/null +++ b/pkg/filemanager/driver/remote/client_test.go @@ -0,0 +1,76 @@ +package remote + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/request" + "github.com/stretchr/testify/require" +) + +// TestCreateUploadSessionClockSkewRetry reproduces upstream #3242: when the +// slave's clock is behind the master's, the upload-session signature arrives +// already expired. The client must learn the offset from the response Date +// header and retry with a compensated base time. +func TestCreateUploadSessionClockSkewRetry(t *testing.T) { + const skewSeconds = 3600 // master is 1h ahead of slave + masterNow := func() time.Time { return time.Now().Add(skewSeconds * time.Second) } + + var calls atomic.Int32 + var lastExpires int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + authHeader := r.Header.Get("Authorization") + parts := strings.Split(authHeader, ":") + expires, _ := strconv.ParseInt(parts[len(parts)-1], 10, 64) + lastExpires = expires + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Date", masterNow().UTC().Format(http.TimeFormat)) + if expires < masterNow().Unix() { + w.Write([]byte(`{"code":40005,"msg":"signature expired"}`)) + return + } + w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + + c := &remoteClient{ + l: logging.NewConsoleLogger(logging.LevelError), + httpClient: request.NewClient( + nil, + request.WithEndpoint(srv.URL), + request.WithCredential(auth.HMACAuth{SecretKey: []byte("test")}, 60), + ), + } + + err := c.CreateUploadSession(context.Background(), &fs.UploadSession{}, false) + require.NoError(t, err) + require.Equal(t, int32(2), calls.Load(), "expected one expired attempt plus one compensated retry") + require.InDelta(t, skewSeconds, c.clockOffset.Load(), 1) + require.GreaterOrEqual(t, lastExpires, masterNow().Unix()) +} + +// TestLearnClockOffsetIgnoresBadHeaders ensures malformed or missing Date +// headers leave the offset untouched. +func TestLearnClockOffsetIgnoresBadHeaders(t *testing.T) { + c := &remoteClient{l: logging.NewConsoleLogger(logging.LevelError)} + + c.learnClockOffset(nil) + require.Zero(t, c.clockOffset.Load()) + + c.learnClockOffset(&http.Response{Header: http.Header{}}) + require.Zero(t, c.clockOffset.Load()) + + c.learnClockOffset(&http.Response{Header: http.Header{"Date": {"garbage"}}}) + require.Zero(t, c.clockOffset.Load()) +} diff --git a/pkg/filemanager/driver/remote/remote.go b/pkg/filemanager/driver/remote/remote.go index b7a762df..878d4352 100644 --- a/pkg/filemanager/driver/remote/remote.go +++ b/pkg/filemanager/driver/remote/remote.go @@ -98,7 +98,7 @@ func (handler *Driver) Thumb(ctx context.Context, expire *time.Time, ext string, return "", fmt.Errorf("parse server url failed: %w", err) } - thumbURL := routes.SlaveThumbUrl(serverURL, e.Source(), ext) + thumbURL := routes.SlaveThumbUrl(serverURL, e.Source(), ext, e.ID()) signedThumbURL, err := auth.SignURI(ctx, handler.AuthInstance, thumbURL.String(), expire) if err != nil { return "", err diff --git a/pkg/filemanager/driver/upyun/media.go b/pkg/filemanager/driver/upyun/media.go index bf36acb1..7ec0971d 100644 --- a/pkg/filemanager/driver/upyun/media.go +++ b/pkg/filemanager/driver/upyun/media.go @@ -8,9 +8,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/mediameta" "github.com/cloudreve/Cloudreve/v4/pkg/request" "github.com/samber/lo" - "math" "net/http" - "strconv" "strings" "time" ) @@ -31,8 +29,6 @@ func (handler *Driver) extractImageMeta(ctx context.Context, path string) ([]dri return nil, err } - fmt.Println(resp) - var imageInfo ImageInfo if err := json.Unmarshal([]byte(resp), &imageInfo); err != nil { return nil, fmt.Errorf("failed to unmarshal image info: %w", err) @@ -79,76 +75,23 @@ func unmarshalError(resp string, err error) error { } func parseGpsInfo(imageInfo map[string]string) []driver.MediaMeta { - latitude := imageInfo["GPSLatitude"] // 31/1, 162680820/10000000, 0/1 - longitude := imageInfo["GPSLongitude"] // 120/1, 429103939/10000000, 0/1 - latRef := imageInfo["GPSLatitudeRef"] // N - lonRef := imageInfo["GPSLongitudeRef"] // E - - // Make sure all value exist in map - if latitude == "" || longitude == "" || latRef == "" || lonRef == "" { - return nil - } - - lat := parseRawGPS(latitude, latRef) - lon := parseRawGPS(longitude, lonRef) - if !math.IsNaN(lat) && !math.IsNaN(lon) { - lat, lng := mediameta.NormalizeGPS(lat, lon) - return []driver.MediaMeta{{ - Key: mediameta.GpsLat, - Value: fmt.Sprintf("%f", lat), - }, { - Key: mediameta.GpsLng, - Value: fmt.Sprintf("%f", lng), - }} - } - - return nil + return mediameta.GpsMeta(imageInfo["GPSLatitude"], imageInfo["GPSLongitude"], + imageInfo["GPSLatitudeRef"], imageInfo["GPSLongitudeRef"], parseRawGPS) } func parseRawGPS(gpsStr string, ref string) float64 { elem := strings.Split(gpsStr, ",") - if len(elem) < 1 { - return 0 - } - - var ( - deg float64 - minutes float64 - seconds float64 - ) - deg = getGpsElemValue(elem[0]) + var deg, minutes, seconds float64 + if len(elem) >= 1 { + deg = mediameta.GpsRationalElem(elem[0]) + } if len(elem) >= 2 { - minutes = getGpsElemValue(elem[1]) + minutes = mediameta.GpsRationalElem(elem[1]) } if len(elem) >= 3 { - seconds = getGpsElemValue(elem[2]) - } - - decimal := deg + minutes/60.0 + seconds/3600.0 - - if ref == "S" || ref == "W" { - return -decimal - } - - return decimal -} - -func getGpsElemValue(elm string) float64 { - elements := strings.Split(elm, "/") - if len(elements) != 2 { - return 0 - } - - numerator, err := strconv.ParseFloat(elements[0], 64) - if err != nil { - return 0 - } - - denominator, err := strconv.ParseFloat(elements[1], 64) - if err != nil || denominator == 0 { - return 0 + seconds = mediameta.GpsRationalElem(elem[2]) } - return numerator / denominator + return mediameta.DMSDecimal(deg, minutes, seconds, ref == "S" || ref == "W") } diff --git a/pkg/filemanager/fs/dbfs/dbfs.go b/pkg/filemanager/fs/dbfs/dbfs.go index ef2d4581..81e9d81e 100644 --- a/pkg/filemanager/fs/dbfs/dbfs.go +++ b/pkg/filemanager/fs/dbfs/dbfs.go @@ -22,10 +22,9 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/setting" - "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gofrs/uuid" "github.com/samber/lo" - "golang.org/x/tools/container/intsets" + "math" ) const ( @@ -43,8 +42,44 @@ type ( // IsDownloadCtxKey marks the request as an explicit file download (as // opposed to an inline preview fetch). Navigator hooks consult it. IsDownloadCtxKey struct{} + // ExpectedSourceIDsCtxKey carries source-file identity preconditions + // for move/copy/rename: a []int positionally aligned with the source + // URI list. An entry of 0 disables the check for that position (#3565). + ExpectedSourceIDsCtxKey struct{} + // MoveConflictCtxKey carries the on-conflict policy for move/copy: + // "skip" drops colliding entries, "overwrite" deletes the colliding + // destination first. Empty keeps the fail-fast default (#3159). + MoveConflictCtxKey struct{} ) +const ( + MoveConflictSkip = "skip" + MoveConflictOverwrite = "overwrite" +) + +// moveConflictMode returns the configured on-conflict policy. +func moveConflictMode(ctx context.Context) string { + mode, _ := ctx.Value(MoveConflictCtxKey{}).(string) + return mode +} + +// WithExpectedSourceIDs records the expected database IDs of the source +// files so a delayed/retried request cannot silently operate on a new +// file that reused the same path. +func WithExpectedSourceIDs(ctx context.Context, ids []int) context.Context { + return context.WithValue(ctx, ExpectedSourceIDsCtxKey{}, ids) +} + +// sourceIDMismatch reports whether the resolved file violates the +// expected-source precondition at the given position. +func sourceIDMismatch(ctx context.Context, pos int, actual int) bool { + expected, ok := ctx.Value(ExpectedSourceIDsCtxKey{}).([]int) + if !ok || pos >= len(expected) || expected[pos] == 0 { + return false + } + return expected[pos] != actual +} + // writePermitted reports whether the user may mutate file under the given // capability. File owners are always permitted; non-owners (e.g. share // visitors) require the capability in the file's resolved capability set, @@ -57,26 +92,41 @@ func (f *DBFS) writePermitted(file *File, capability NavigatorCapability) bool { return caps != nil && caps.Enabled(int(capability)) } -func NewDatabaseFS(u *ent.User, fileClient inventory.FileClient, shareClient inventory.ShareClient, - l logging.Logger, ls lock.LockSystem, settingClient setting.Provider, - storagePolicyClient inventory.StoragePolicyClient, hasher hashid.Encoder, userClient inventory.UserClient, - cache, stateKv cache.Driver, directLinkClient inventory.DirectLinkClient, encryptorFactory encrypt.CryptorFactory, eventHub eventhub.EventHub) fs.FileSystem { +// DBFSDependencies groups the collaborators wired into a DBFS instance so the +// constructor call site names each dependency. +type DBFSDependencies struct { + FileClient inventory.FileClient + ShareClient inventory.ShareClient + UserClient inventory.UserClient + StoragePolicyClient inventory.StoragePolicyClient + DirectLinkClient inventory.DirectLinkClient + Logger logging.Logger + LockSystem lock.LockSystem + SettingProvider setting.Provider + Hasher hashid.Encoder + Cache cache.Driver + StateKV cache.Driver + EncryptorFactory encrypt.CryptorFactory + EventHub eventhub.EventHub +} + +func NewDatabaseFS(u *ent.User, deps DBFSDependencies) fs.FileSystem { return &DBFS{ user: u, navigators: make(map[string]Navigator), - fileClient: fileClient, - shareClient: shareClient, - l: l, - ls: ls, - settingClient: settingClient, - storagePolicyClient: storagePolicyClient, - hasher: hasher, - userClient: userClient, - cache: cache, - stateKv: stateKv, - directLinkClient: directLinkClient, - encryptorFactory: encryptorFactory, - eventHub: eventHub, + fileClient: deps.FileClient, + shareClient: deps.ShareClient, + l: deps.Logger, + ls: deps.LockSystem, + settingClient: deps.SettingProvider, + storagePolicyClient: deps.StoragePolicyClient, + hasher: deps.Hasher, + userClient: deps.UserClient, + cache: deps.Cache, + stateKv: deps.StateKV, + directLinkClient: deps.DirectLinkClient, + encryptorFactory: deps.EncryptorFactory, + eventHub: deps.EventHub, } } @@ -485,7 +535,7 @@ func (f *DBFS) Get(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.Fil // disable load metadata to speed up ctxWalk := context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, false) - if err := navigator.Walk(ctxWalk, []*File{target}, limit, intsets.MaxInt, func(files []*File, l int) error { + if err := navigator.Walk(ctxWalk, []*File{target}, limit, math.MaxInt, func(files []*File, l int) error { for _, file := range files { if file.ID() == target.ID() { continue @@ -804,7 +854,14 @@ func (f *DBFS) navigatorId(path *fs.URI) string { func generateSavePath(policy *ent.StoragePolicy, req *fs.UploadRequest, user *ent.User) string { currentTime := time.Now() dynamicReplace := func(rule string, pathAvailable bool) string { - return util.ReplaceMagicVar(rule, fs.Separator, pathAvailable, false, currentTime, user.ID, req.Props.Uri.Name(), req.Props.Uri.Dir(), "") + return fs.ReplaceMagicVar(rule, fs.MagicVarProps{ + FsSeparator: fs.Separator, + PathAvailable: pathAvailable, + Time: currentTime, + UserID: user.ID, + OriginName: req.Props.Uri.Name(), + OriginPath: req.Props.Uri.Dir(), + }) } dirRule := policy.DirNameRule diff --git a/pkg/filemanager/fs/dbfs/manage.go b/pkg/filemanager/fs/dbfs/manage.go index 43940787..80871529 100644 --- a/pkg/filemanager/fs/dbfs/manage.go +++ b/pkg/filemanager/fs/dbfs/manage.go @@ -16,7 +16,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/samber/lo" - "golang.org/x/tools/container/intsets" + "math" ) func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType, opts ...fs.Option) (fs.File, error) { @@ -83,8 +83,9 @@ func (f *DBFS) Create(ctx context.Context, path *fs.URI, fileType types.FileType return nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("parent must be a valid folder")) } - // Validate object name - if err := validateFileName(desired[i]); err != nil { + // Validate object name — folder segments keep the strict rule set + // since their governing policy is ambiguous. + if err := validateFileName(desired[i], nil); err != nil { return nil, fs.ErrIllegalObjectName.WithError(err) } @@ -160,6 +161,9 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil if err != nil { return nil, nil, fmt.Errorf("failed to get target file: %w", err) } + if sourceIDMismatch(ctx, 0, target.Model.ID) { + return nil, nil, fs.ErrModified.WithError(fmt.Errorf("source file no longer matches the expected file")) + } oldName := target.Name() if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(target, NavigatorCapabilityRenameFile) { @@ -171,16 +175,17 @@ func (f *DBFS) Rename(ctx context.Context, path *fs.URI, newName string) (fs.Fil return nil, nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("cannot modify root folder")) } + policy, err := f.getPreferredPolicy(ctx, target) + if err != nil { + return nil, nil, err + } + // Validate new name - if err := validateFileName(newName); err != nil { + if err := validateFileName(newName, policy); err != nil { return nil, nil, fs.ErrIllegalObjectName.WithError(err) } // If target is a file, validate file extension - policy, err := f.getPreferredPolicy(ctx, target) - if err != nil { - return nil, nil, err - } if target.Type() == types.FileTypeFile { if err := validateExtension(newName, policy); err != nil { @@ -305,11 +310,16 @@ func (f *DBFS) SoftDelete(ctx context.Context, path ...*fs.URI) error { return serializer.NewError(serializer.CodeDBError, "failed to soft-delete file", err) } - // Save restore uri into metadata + // Save restore uri into metadata. A user-level retention overrides + // the group default when set. + retention := target.Owner().Edges.Group.Settings.TrashRetention + if us := target.Owner().Settings; us != nil && us.TrashRetention > 0 { + retention = us.TrashRetention + } if err := fc.UpsertMetadata(ctx, target.Model, map[string]string{ MetadataRestoreUri: target.Uri(true).String(), MetadataExpectedCollectTime: strconv.FormatInt( - time.Now().Add(time.Duration(target.Owner().Edges.Group.Settings.TrashRetention)*time.Second).Unix(), + time.Now().Add(time.Duration(retention)*time.Second).Unix(), 10), }, nil); err != nil { _ = inventory.Rollback(tx) @@ -557,11 +567,12 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo ae := serializer.NewAggregateError() fileNavGroup := make(map[Navigator][]*File) + navOf := make(map[*File]Navigator) dstRootPath := destination.Uri(true) ctx = context.WithValue(ctx, inventory.LoadFileEntity{}, true) ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true) - for _, p := range path { + for i, p := range path { // Get navigator navigator, err := f.getNavigator(ctx, p, NavigatorCapabilityLockFile) if err != nil { @@ -582,6 +593,11 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo continue } + if sourceIDMismatch(ctx, i, target.Model.ID) { + ae.Add(p.String(), fs.ErrModified.WithError(fmt.Errorf("source file no longer matches the expected file"))) + continue + } + // Copy reads the source, move deletes it. requiredSrcCap := NavigatorCapabilityDownloadFile if !isCopy { @@ -607,6 +623,7 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo } targets = append(targets, target) + navOf[target] = navigator if isCopy { if _, ok := fileNavGroup[navigator]; !ok { fileNavGroup[navigator] = make([]*File, 0) @@ -615,6 +632,13 @@ func (f *DBFS) MoveOrCopy(ctx context.Context, path []*fs.URI, dst *fs.URI, isCo } } + // Resolve name conflicts against existing destination children before + // locking: skip drops the colliding target, overwrite deletes the + // colliding destination object first (#3159). + if mode := moveConflictMode(ctx); mode != "" && len(targets) > 0 { + targets, fileNavGroup = f.resolveMoveConflicts(ctx, targets, navOf, destination, isCopy, mode, ae) + } + indexDiff := &fs.IndexDiff{} if len(targets) > 0 { // Lock all targets @@ -855,7 +879,7 @@ func (f *DBFS) deleteFiles(ctx context.Context, targets map[Navigator][]*File, f // listing resolves via HasParentWith, so parent rows must stay // alive until their level has been fetched. folderModels := make([]*ent.File, 0, 64) - if err := n.Walk(ctx, files, intsets.MaxInt, intsets.MaxInt, func(targets []*File, level int) error { + if err := n.Walk(ctx, files, math.MaxInt, math.MaxInt, func(targets []*File, level int) error { indexToDelete = append(indexToDelete, lo.Map(targets, func(item *File, index int) int { return item.ID() })...) @@ -924,7 +948,7 @@ func (f *DBFS) copyFiles(ctx context.Context, targets map[Navigator][]*File, des initialDstMap[file.Model.FileChildren] = dstAncestors } - if err := n.Walk(ctx, files, limit, intsets.MaxInt, func(targets []*File, level int) error { + if err := n.Walk(ctx, files, limit, math.MaxInt, func(targets []*File, level int) error { // check capacity for each file sizeTotal := int64(0) for _, file := range targets { @@ -1043,3 +1067,48 @@ func (f *DBFS) moveFiles(ctx context.Context, targets []*File, destination *File return storageDiff, nil, nil } + +// resolveMoveConflicts filters or replaces targets whose names collide +// with existing destination children (#3159). "skip" drops the colliding +// target with a per-file ErrFileExisted; "overwrite" deletes the +// colliding destination object through the regular Delete path first. +func (f *DBFS) resolveMoveConflicts(ctx context.Context, targets []*File, navOf map[*File]Navigator, destination *File, isCopy bool, mode string, ae *serializer.AggregateError) ([]*File, map[Navigator][]*File) { + surviving := make([]*File, 0, len(targets)) + group := make(map[Navigator][]*File) + dstBase := destination.Uri(true) + + for _, target := range targets { + dstName := target.Name() + if !isCopy { + if _, ok := target.Metadata()[MetadataRestoreUri]; ok { + dstName = target.DisplayName() + } + } + + _, err := f.fileClient.GetChildFile(ctx, destination.Model, destination.OwnerID(), dstName, false) + if err != nil && !ent.IsNotFound(err) { + ae.Add(target.Uri(true).String(), fmt.Errorf("failed to check destination conflict: %w", err)) + continue + } + + if err == nil { + // Destination already holds an object under the same name. + if mode == MoveConflictSkip { + ae.Add(target.Uri(true).String(), fs.ErrFileExisted) + continue + } + if _, _, err := f.Delete(ctx, []*fs.URI{dstBase.Join(dstName)}); err != nil { + ae.Add(target.Uri(true).String(), err) + continue + } + } + + surviving = append(surviving, target) + if isCopy { + nav := navOf[target] + group[nav] = append(group[nav], target) + } + } + + return surviving, group +} diff --git a/pkg/filemanager/fs/dbfs/manage_conflict_test.go b/pkg/filemanager/fs/dbfs/manage_conflict_test.go new file mode 100644 index 00000000..bed145a1 --- /dev/null +++ b/pkg/filemanager/fs/dbfs/manage_conflict_test.go @@ -0,0 +1,119 @@ +package dbfs + +import ( + "context" + "fmt" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/stretchr/testify/require" +) + +// conflictTestFixture creates user -> root -> dst folder, optionally with +// dstChildren file rows inside dst. Returns the user, the dst folder +// wrapped as *File and a real FileClient backed by the enttest client. +func conflictTestFixture(t *testing.T, client *ent.Client, dstChildren ...string) (*ent.User, *ent.File, *File, inventory.FileClient) { + t.Helper() + ctx := context.Background() + group := client.Group.Create().SetName("conflict").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + user := client.User.Create().SetEmail("c@example.com").SetNick("c").SetGroup(group).SaveX(ctx) + root := client.File.Create().SetName(inventory.RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(user).SaveX(ctx) + dst := client.File.Create().SetName("dst").SetType(int(types.FileTypeFolder)).SetOwner(user).SetParent(root).SaveX(ctx) + for _, name := range dstChildren { + client.File.Create().SetName(name).SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(dst).SaveX(ctx) + } + + dstUri, err := fs.NewUriFromString(fmt.Sprintf("%s/dst", fs.NewMyUri(""))) + require.NoError(t, err) + dstFile := newFile(nil, dst) + dstFile.OwnerModel = user + dstFile.Path[0] = dstUri + + return user, root, dstFile, inventory.NewFileClient(client, conf.SQLiteDB, nil) +} + +func conflictTestTarget(t *testing.T, model *ent.File, user *ent.User) *File { + t.Helper() + u, err := fs.NewUriFromString(fmt.Sprintf("%s/%s", fs.NewMyUri(""), model.Name)) + require.NoError(t, err) + f := newFile(nil, model) + f.OwnerModel = user + f.Path[0] = u + return f +} + +func TestResolveMoveConflictsSkipsColliding(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() + + user, root, dstFile, fc := conflictTestFixture(t, client, "a.txt") + + srcA := client.File.Create().SetName("a.txt").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx) + srcB := client.File.Create().SetName("b.txt").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx) + targetA := conflictTestTarget(t, srcA, user) + targetB := conflictTestTarget(t, srcB, user) + targets := []*File{targetA, targetB} + + f := &DBFS{fileClient: fc} + nav := Navigator(&myNavigator{}) + navOf := map[*File]Navigator{targetA: nav, targetB: nav} + ae := serializer.NewAggregateError() + + surviving, group := f.resolveMoveConflicts(ctx, targets, navOf, dstFile, true, MoveConflictSkip, ae) + + require.Equal(t, []*File{targetB}, surviving) + require.Equal(t, []*File{targetB}, group[nav]) + require.NotNil(t, ae.Aggregate()) +} + +func TestResolveMoveConflictsKeepsNonColliding(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() + + user, root, dstFile, fc := conflictTestFixture(t, client, "other.txt") + + srcA := client.File.Create().SetName("a.txt").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx) + targetA := conflictTestTarget(t, srcA, user) + + f := &DBFS{fileClient: fc} + ae := serializer.NewAggregateError() + + surviving, group := f.resolveMoveConflicts(ctx, []*File{targetA}, map[*File]Navigator{targetA: &myNavigator{}}, dstFile, true, MoveConflictSkip, ae) + + require.Equal(t, []*File{targetA}, surviving) + require.Len(t, group, 1) + require.Nil(t, ae.Aggregate()) +} + +func TestResolveMoveConflictsUsesRestoreDisplayName(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() + + // Destination holds a file under the restored display name, not the + // trash-internal UUID name. + user, root, dstFile, fc := conflictTestFixture(t, client, "original.txt") + + trashed := client.File.Create().SetName("uuid-name").SetType(int(types.FileTypeFile)).SetOwner(user).SetParent(root).SaveX(ctx) + trashed.Edges.Metadata = []*ent.Metadata{ + {Name: MetadataRestoreUri, Value: fmt.Sprintf("%s/original.txt", fs.NewMyUri(""))}, + } + target := conflictTestTarget(t, trashed, user) + + f := &DBFS{fileClient: fc} + ae := serializer.NewAggregateError() + + surviving, _ := f.resolveMoveConflicts(ctx, []*File{target}, map[*File]Navigator{}, dstFile, false, MoveConflictSkip, ae) + + require.Empty(t, surviving) + require.NotNil(t, ae.Aggregate()) +} diff --git a/pkg/filemanager/fs/dbfs/my_navigator.go b/pkg/filemanager/fs/dbfs/my_navigator.go index b607ba21..db271a9f 100644 --- a/pkg/filemanager/fs/dbfs/my_navigator.go +++ b/pkg/filemanager/fs/dbfs/my_navigator.go @@ -134,12 +134,7 @@ func (n *myNavigator) walkNext(ctx context.Context, root *File, next string, isL } func (n *myNavigator) Capabilities(isSearching bool) *fs.NavigatorProps { - res := &fs.NavigatorProps{ - Capability: myNavigatorCapability, - OrderDirectionOptions: fullOrderDirectionOption, - OrderByOptions: fullOrderByOption, - MaxPageSize: n.config.MaxPageSize, - } + res := baseNavigatorProps(myNavigatorCapability, n.config.MaxPageSize) if isSearching { res.OrderByOptions = nil res.OrderDirectionOptions = nil @@ -153,27 +148,24 @@ func (n *myNavigator) Walk(ctx context.Context, levelFiles []*File, limit, depth } func (n *myNavigator) FollowTx(ctx context.Context) (func(), error) { - if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { - return nil, fmt.Errorf("navigator: no inherited transaction found in context") - } - newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient) + oldBase := n.baseNavigator.fileClient + revertFile, err := followTxClients(ctx, &n.fileClient) if err != nil { return nil, err } - newUserClient, _, _, err := inventory.WithTx(ctx, n.userClient) - - oldFileClient, oldUserClient := n.fileClient, n.userClient - revert := func() { - n.fileClient = oldFileClient - n.userClient = oldUserClient - n.baseNavigator.fileClient = oldFileClient + revertUser, err := followTxClients(ctx, &n.userClient) + if err != nil { + revertFile() + return nil, err } - n.fileClient = newFileClient - n.userClient = newUserClient - n.baseNavigator.fileClient = newFileClient - return revert, nil + n.baseNavigator.fileClient = n.fileClient + return func() { + revertUser() + revertFile() + n.baseNavigator.fileClient = oldBase + }, nil } func (n *myNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error { diff --git a/pkg/filemanager/fs/dbfs/navigator.go b/pkg/filemanager/fs/dbfs/navigator.go index c2ac0be1..9e1ebc49 100644 --- a/pkg/filemanager/fs/dbfs/navigator.go +++ b/pkg/filemanager/fs/dbfs/navigator.go @@ -19,6 +19,31 @@ import ( "github.com/samber/lo" ) +// followTxClients swaps each client field to its tx-bound instance from the +// transaction in ctx, returning a revert closure that restores the originals. +func followTxClients[T inventory.TxOperator](ctx context.Context, fields ...*T) (func(), error) { + if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { + return nil, fmt.Errorf("navigator: no inherited transaction found in context") + } + + olds := make([]T, len(fields)) + for i, f := range fields { + newClient, _, _, err := inventory.WithTx(ctx, *f) + if err != nil { + return nil, err + } + + olds[i] = *f + *f = newClient + } + + return func() { + for i, f := range fields { + *f = olds[i] + } + }, nil +} + var ( ErrFsNotInitialized = fmt.Errorf("fs not initialized") ErrPermissionDenied = serializer.NewError(serializer.CodeNoPermissionErr, "Permission denied", nil) @@ -33,6 +58,17 @@ var ( fullOrderDirectionOption = []string{"asc", "desc"} ) +// baseNavigatorProps builds the shared NavigatorProps skeleton; navigators +// adjust search options on top. +func baseNavigatorProps(capability *boolset.BooleanSet, maxPageSize int) *fs.NavigatorProps { + return &fs.NavigatorProps{ + Capability: capability, + OrderDirectionOptions: fullOrderDirectionOption, + OrderByOptions: fullOrderByOption, + MaxPageSize: maxPageSize, + } +} + type ( // Navigator is a navigator for database file system. Navigator interface { diff --git a/pkg/filemanager/fs/dbfs/precondition_test.go b/pkg/filemanager/fs/dbfs/precondition_test.go new file mode 100644 index 00000000..115c97b4 --- /dev/null +++ b/pkg/filemanager/fs/dbfs/precondition_test.go @@ -0,0 +1,28 @@ +package dbfs + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSourceIDMismatch(t *testing.T) { + ctx := context.Background() + + // No expectations -> never mismatches. + require.False(t, sourceIDMismatch(ctx, 0, 42)) + require.False(t, sourceIDMismatch(ctx, 5, 42)) + + ctx = WithExpectedSourceIDs(ctx, []int{11, 0, 33}) + + // Matching positions pass. + require.False(t, sourceIDMismatch(ctx, 0, 11)) + // Zero entries disable the check for that position. + require.False(t, sourceIDMismatch(ctx, 1, 999)) + // Positions beyond the expectation list pass. + require.False(t, sourceIDMismatch(ctx, 3, 999)) + // Mismatches are caught per position. + require.True(t, sourceIDMismatch(ctx, 0, 12)) + require.True(t, sourceIDMismatch(ctx, 2, 34)) +} diff --git a/pkg/filemanager/fs/dbfs/share_navigator.go b/pkg/filemanager/fs/dbfs/share_navigator.go index 0eb36de6..38c0fb27 100644 --- a/pkg/filemanager/fs/dbfs/share_navigator.go +++ b/pkg/filemanager/fs/dbfs/share_navigator.go @@ -278,12 +278,7 @@ func (n *shareNavigator) latestSharedSingleFile(ctx context.Context) (*File, err } func (n *shareNavigator) Capabilities(isSearching bool) *fs.NavigatorProps { - res := &fs.NavigatorProps{ - Capability: shareNavigatorCapability, - OrderDirectionOptions: fullOrderDirectionOption, - OrderByOptions: fullOrderByOption, - MaxPageSize: n.config.MaxPageSize, - } + res := baseNavigatorProps(shareNavigatorCapability, n.config.MaxPageSize) // Once the share is resolved, narrow capabilities to what its props grant. // This set is stamped onto resolved files and consulted by writePermitted. @@ -351,27 +346,24 @@ func (n *shareNavigator) shareCapabilities() *boolset.BooleanSet { } func (n *shareNavigator) FollowTx(ctx context.Context) (func(), error) { - if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { - return nil, fmt.Errorf("navigator: no inherited transaction found in context") - } - newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient) + oldBase := n.baseNavigator.fileClient + revertFile, err := followTxClients(ctx, &n.fileClient) if err != nil { return nil, err } - newSharClient, _, _, err := inventory.WithTx(ctx, n.shareClient) - - oldFileClient, oldShareClient := n.fileClient, n.shareClient - revert := func() { - n.fileClient = oldFileClient - n.shareClient = oldShareClient - n.baseNavigator.fileClient = oldFileClient + revertShare, err := followTxClients(ctx, &n.shareClient) + if err != nil { + revertFile() + return nil, err } - n.fileClient = newFileClient - n.shareClient = newSharClient - n.baseNavigator.fileClient = newFileClient - return revert, nil + n.baseNavigator.fileClient = n.fileClient + return func() { + revertShare() + revertFile() + n.baseNavigator.fileClient = oldBase + }, nil } func (n *shareNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error { diff --git a/pkg/filemanager/fs/dbfs/sharewithme_navigator.go b/pkg/filemanager/fs/dbfs/sharewithme_navigator.go index 6c42a619..2e2bd64e 100644 --- a/pkg/filemanager/fs/dbfs/sharewithme_navigator.go +++ b/pkg/filemanager/fs/dbfs/sharewithme_navigator.go @@ -101,13 +101,7 @@ func (t *sharedWithMeNavigator) Children(ctx context.Context, parent *File, args } func (t *sharedWithMeNavigator) Capabilities(isSearching bool) *fs.NavigatorProps { - res := &fs.NavigatorProps{ - Capability: sharedWithMeNavigatorCapability, - OrderDirectionOptions: fullOrderDirectionOption, - OrderByOptions: fullOrderByOption, - MaxPageSize: t.config.MaxPageSize, - } - + res := baseNavigatorProps(sharedWithMeNavigatorCapability, t.config.MaxPageSize) if isSearching { res.OrderByOptions = searchLimitedOrderByOption } @@ -120,23 +114,17 @@ func (t *sharedWithMeNavigator) Walk(ctx context.Context, levelFiles []*File, li } func (n *sharedWithMeNavigator) FollowTx(ctx context.Context) (func(), error) { - if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { - return nil, fmt.Errorf("navigator: no inherited transaction found in context") - } - newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient) + oldBase := n.baseNavigator.fileClient + revert, err := followTxClients(ctx, &n.fileClient) if err != nil { return nil, err } - oldFileClient := n.fileClient - revert := func() { - n.fileClient = oldFileClient - n.baseNavigator.fileClient = oldFileClient - } - - n.fileClient = newFileClient - n.baseNavigator.fileClient = newFileClient - return revert, nil + n.baseNavigator.fileClient = n.fileClient + return func() { + revert() + n.baseNavigator.fileClient = oldBase + }, nil } func (n *sharedWithMeNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error { diff --git a/pkg/filemanager/fs/dbfs/trash_navigator.go b/pkg/filemanager/fs/dbfs/trash_navigator.go index ede265ed..acfbcd48 100644 --- a/pkg/filemanager/fs/dbfs/trash_navigator.go +++ b/pkg/filemanager/fs/dbfs/trash_navigator.go @@ -116,13 +116,7 @@ func (t *trashNavigator) Children(ctx context.Context, parent *File, args *ListA } func (t *trashNavigator) Capabilities(isSearching bool) *fs.NavigatorProps { - res := &fs.NavigatorProps{ - Capability: trashNavigatorCapability, - OrderDirectionOptions: fullOrderDirectionOption, - OrderByOptions: fullOrderByOption, - MaxPageSize: t.config.MaxPageSize, - } - + res := baseNavigatorProps(trashNavigatorCapability, t.config.MaxPageSize) if isSearching { res.OrderByOptions = searchLimitedOrderByOption } @@ -135,23 +129,17 @@ func (t *trashNavigator) Walk(ctx context.Context, levelFiles []*File, limit, de } func (n *trashNavigator) FollowTx(ctx context.Context) (func(), error) { - if _, ok := ctx.Value(inventory.TxCtx{}).(*inventory.Tx); !ok { - return nil, fmt.Errorf("navigator: no inherited transaction found in context") - } - newFileClient, _, _, err := inventory.WithTx(ctx, n.fileClient) + oldBase := n.baseNavigator.fileClient + revert, err := followTxClients(ctx, &n.fileClient) if err != nil { return nil, err } - oldFileClient := n.fileClient - revert := func() { - n.fileClient = oldFileClient - n.baseNavigator.fileClient = oldFileClient - } - - n.fileClient = newFileClient - n.baseNavigator.fileClient = newFileClient - return revert, nil + n.baseNavigator.fileClient = n.fileClient + return func() { + revert() + n.baseNavigator.fileClient = oldBase + }, nil } func (n *trashNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error { diff --git a/pkg/filemanager/fs/dbfs/upload.go b/pkg/filemanager/fs/dbfs/upload.go index b871ddd3..f6d1839b 100644 --- a/pkg/filemanager/fs/dbfs/upload.go +++ b/pkg/filemanager/fs/dbfs/upload.go @@ -9,6 +9,7 @@ import ( "time" "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" @@ -138,6 +139,10 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts .. return nil, err } + if policy.Status == storagepolicy.StatusSuspended { + return nil, serializer.NewError(serializer.CodePolicyNotAllowed, "Storage policy is suspended", nil) + } + // Encryption setting var ( encryptMetadata *types.EncryptMetadata @@ -173,7 +178,16 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts .. if req.Props.SavePath == "" || isThumbnailAndPolicyNotAvailable { req.Props.SavePath = generateSavePath(policy, req, f.user) if isThumbnailAndPolicyNotAvailable { - req.Props.SavePath = path.Clean(util.ReplaceMagicVar(f.settingClient.ThumbEntitySuffix(ctx), fs.Separator, true, true, time.Now(), f.user.ID, req.Props.Uri.Name(), req.Props.Uri.Path(), req.Props.SavePath)) + req.Props.SavePath = path.Clean(fs.ReplaceMagicVar(f.settingClient.ThumbEntitySuffix(ctx), fs.MagicVarProps{ + FsSeparator: fs.Separator, + PathAvailable: true, + BlobAvailable: true, + Time: time.Now(), + UserID: f.user.ID, + OriginName: req.Props.Uri.Name(), + OriginPath: req.Props.Uri.Path(), + CompleteBlobPath: req.Props.SavePath, + })) } } diff --git a/pkg/filemanager/fs/dbfs/validator.go b/pkg/filemanager/fs/dbfs/validator.go index 34fde54d..f38b57bf 100644 --- a/pkg/filemanager/fs/dbfs/validator.go +++ b/pkg/filemanager/fs/dbfs/validator.go @@ -13,13 +13,20 @@ import ( const MaxFileNameLength = 256 -// validateFileName validates the file name. -func validateFileName(name string) error { +// validateFileName validates the file name. When the policy allows native +// names, characters only forbidden on Windows filesystems are accepted; +// path separators and dot-names stay illegal regardless (#3065). +func validateFileName(name string, policy *ent.StoragePolicy) error { if len(name) >= MaxFileNameLength || len(name) == 0 { return fmt.Errorf("length of name must be between 1 and 255") } - if strings.ContainsAny(name, "\\/:*?\"<>|") { + if strings.ContainsAny(name, "\\/") { + return fmt.Errorf("name contains path separators") + } + + nativeAllowed := policy != nil && policy.Settings.AllowNativeName + if !nativeAllowed && strings.ContainsAny(name, ":*?\"<>|") { return fmt.Errorf("name contains illegal characters") } @@ -74,7 +81,7 @@ func validateFileSize(size int64, policy *ent.StoragePolicy) error { // validateNewFile validates the upload request. func validateNewFile(fileName string, size int64, policy *ent.StoragePolicy) error { - if err := validateFileName(fileName); err != nil { + if err := validateFileName(fileName, policy); err != nil { return fs.ErrIllegalObjectName.WithError(err) } diff --git a/pkg/filemanager/fs/dbfs/validator_test.go b/pkg/filemanager/fs/dbfs/validator_test.go new file mode 100644 index 00000000..3b381335 --- /dev/null +++ b/pkg/filemanager/fs/dbfs/validator_test.go @@ -0,0 +1,27 @@ +package dbfs + +import ( + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/stretchr/testify/require" +) + +func TestValidateFileNameNativeChars(t *testing.T) { + native := &ent.StoragePolicy{Settings: &types.PolicySetting{AllowNativeName: true}} + + // Windows-illegal characters accepted only under allow_native_name. + for _, name := range []string{"a:b.txt", "a.txt", "a|b?.txt", `say "hi".txt`} { + require.Error(t, validateFileName(name, nil)) + require.Error(t, validateFileName(name, &ent.StoragePolicy{Settings: &types.PolicySetting{}})) + require.NoError(t, validateFileName(name, native)) + } + + // Separators and dot-names stay illegal regardless. + for _, name := range []string{"a/b", `a\b`, ".", "..", "", string(make([]byte, 300))} { + require.Error(t, validateFileName(name, native)) + } + + require.NoError(t, validateFileName("normal file.txt", nil)) +} diff --git a/pkg/filemanager/fs/fs.go b/pkg/filemanager/fs/fs.go index c27a340e..0753ec72 100644 --- a/pkg/filemanager/fs/fs.go +++ b/pkg/filemanager/fs/fs.go @@ -327,6 +327,7 @@ type ( SysSkipSoftDelete bool Metadata map[string]string ArchiveCompression bool + ArchiveWorkers int ProgressFunc MaxArchiveSize int64 DryRun CreateArchiveDryRunFunc @@ -586,6 +587,15 @@ func WithArchiveCompression(b bool) Option { }) } +// WithArchiveWorkers sets the number of parallel compression workers for +// archive creation. <=0 selects an adaptive default, 1 keeps the +// sequential single-writer path. +func WithArchiveWorkers(n int) Option { + return OptionFunc(func(o *FsOption) { + o.ArchiveWorkers = n + }) +} + // WithMaxArchiveSize sets maximum size of to be archived file or to-be decompressed // size, 0 for unlimited. func WithMaxArchiveSize(s int64) Option { diff --git a/pkg/filemanager/fs/magicvar.go b/pkg/filemanager/fs/magicvar.go new file mode 100644 index 00000000..d3d1126a --- /dev/null +++ b/pkg/filemanager/fs/magicvar.go @@ -0,0 +1,101 @@ +package fs + +import ( + "math/rand" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gofrs/uuid" +) + +// MagicVarProps carries the context available to magic-variable expansion in +// path templates. +type MagicVarProps struct { + FsSeparator string + PathAvailable bool + BlobAvailable bool + Time time.Time + UserID int + OriginName string + OriginPath string + CompleteBlobPath string +} + +var magicVarRe = regexp.MustCompile(`\{[^{}]+\}`) + +// ReplaceMagicVar expands {var} placeholders in rawString using p. +func ReplaceMagicVar(rawString string, p MagicVarProps) string { + return magicVarRe.ReplaceAllStringFunc(rawString, func(match string) string { + switch match { + case "{randomkey16}": + return util.RandStringRunes(16) + case "{randomkey8}": + return util.RandStringRunes(8) + case "{timestamp}": + return strconv.FormatInt(p.Time.Unix(), 10) + case "{timestamp_nano}": + return strconv.FormatInt(p.Time.UnixNano(), 10) + case "{randomnum2}": + return strconv.Itoa(rand.Intn(2)) + case "{randomnum3}": + return strconv.Itoa(rand.Intn(3)) + case "{randomnum4}": + return strconv.Itoa(rand.Intn(4)) + case "{randomnum8}": + return strconv.Itoa(rand.Intn(8)) + case "{uid}": + return strconv.Itoa(p.UserID) + case "{datetime}": + return p.Time.Format("20060102150405") + case "{date}": + return p.Time.Format("20060102") + case "{year}": + return p.Time.Format("2006") + case "{month}": + return p.Time.Format("01") + case "{day}": + return p.Time.Format("02") + case "{hour}": + return p.Time.Format("15") + case "{minute}": + return p.Time.Format("04") + case "{second}": + return p.Time.Format("05") + case "{uuid}": + return uuid.Must(uuid.NewV4()).String() + case "{ext}": + return filepath.Ext(p.OriginName) + case "{originname}": + return p.OriginName + case "{originname_without_ext}": + return strings.TrimSuffix(p.OriginName, filepath.Ext(p.OriginName)) + case "{path}": + if p.PathAvailable { + return p.OriginPath + p.FsSeparator + } + return match + case "{blob_name}": + if p.BlobAvailable { + return filepath.Base(p.CompleteBlobPath) + } + return match + case "{blob_name_without_ext}": + if p.BlobAvailable { + return strings.TrimSuffix(filepath.Base(p.CompleteBlobPath), filepath.Ext(p.CompleteBlobPath)) + } + return match + case "{blob_path}": + if p.BlobAvailable { + return path.Dir(p.CompleteBlobPath) + p.FsSeparator + } + return match + default: + return match + } + }) +} diff --git a/pkg/filemanager/fs/magicvar_test.go b/pkg/filemanager/fs/magicvar_test.go new file mode 100644 index 00000000..06d681c8 --- /dev/null +++ b/pkg/filemanager/fs/magicvar_test.go @@ -0,0 +1,48 @@ +package fs + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestReplaceMagicVar(t *testing.T) { + a := assert.New(t) + now := time.Date(2024, 3, 5, 6, 7, 8, 0, time.UTC) + props := MagicVarProps{ + FsSeparator: "/", + PathAvailable: true, + BlobAvailable: true, + Time: now, + UserID: 42, + OriginName: "photo.jpg", + OriginPath: "album/2024", + CompleteBlobPath: "blobs/abc123.bin", + } + + a.Equal("u42", ReplaceMagicVar("u{uid}", props)) + a.Equal("20240305", ReplaceMagicVar("{date}", props)) + a.Equal("20240305060708", ReplaceMagicVar("{datetime}", props)) + a.Equal("2024/03/05", ReplaceMagicVar("{year}/{month}/{day}", props)) + a.Equal("photo.jpg", ReplaceMagicVar("{originname}", props)) + a.Equal("photo", ReplaceMagicVar("{originname_without_ext}", props)) + a.Equal(".jpg", ReplaceMagicVar("{ext}", props)) + a.Equal("album/2024/", ReplaceMagicVar("{path}", props)) + a.Equal("abc123.bin", ReplaceMagicVar("{blob_name}", props)) + a.Equal("abc123", ReplaceMagicVar("{blob_name_without_ext}", props)) + a.Equal("blobs/", ReplaceMagicVar("{blob_path}", props)) + a.Equal("{unknown}", ReplaceMagicVar("{unknown}", props)) + + // Unavailable context leaves the placeholder untouched. + noPath := props + noPath.PathAvailable = false + a.Equal("{path}", ReplaceMagicVar("{path}", noPath)) + noBlob := props + noBlob.BlobAvailable = false + a.Equal("{blob_name}", ReplaceMagicVar("{blob_name}", noBlob)) + + // Random vars produce non-empty output. + a.Len(ReplaceMagicVar("{randomkey8}", props), 8) + a.Len(ReplaceMagicVar("{uuid}", props), 36) +} diff --git a/pkg/filemanager/fs/uri.go b/pkg/filemanager/fs/uri.go index 3f5bb147..84868a23 100644 --- a/pkg/filemanager/fs/uri.go +++ b/pkg/filemanager/fs/uri.go @@ -356,9 +356,9 @@ func SearchCategoryFromString(s string) setting.SearchCategory { func NewShareUri(id, password string) string { if password != "" { - return fmt.Sprintf("%s://%s:%s@%s", constants.CloudreveScheme, id, password, constants.FileSystemShare) + return fmt.Sprintf("%s://%s@%s", constants.CloudreveScheme, url.UserPassword(id, password), constants.FileSystemShare) } - return fmt.Sprintf("%s://%s@%s", constants.CloudreveScheme, id, constants.FileSystemShare) + return fmt.Sprintf("%s://%s@%s", constants.CloudreveScheme, url.User(id), constants.FileSystemShare) } func NewMyUri(id string) string { diff --git a/pkg/filemanager/fs/uri_test.go b/pkg/filemanager/fs/uri_test.go new file mode 100644 index 00000000..fc95af86 --- /dev/null +++ b/pkg/filemanager/fs/uri_test.go @@ -0,0 +1,21 @@ +package fs + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShareUriPasswordRoundTrip(t *testing.T) { + for _, password := range []string{"plain", "with space", "a:b@c/d?e", "😀😴😭", "$%^&*"} { + u, err := NewUriFromString(NewShareUri("abc123", password)) + require.NoError(t, err, "password %q", password) + require.Equal(t, "abc123", u.ID("")) + require.Equal(t, password, u.Password(), "password %q", password) + } + + u, err := NewUriFromString(NewShareUri("abc123", "")) + require.NoError(t, err) + require.Equal(t, "abc123", u.ID("")) + require.Equal(t, "", u.Password()) +} diff --git a/pkg/filemanager/manager/archive.go b/pkg/filemanager/manager/archive.go index daded9cf..6e1473a1 100644 --- a/pkg/filemanager/manager/archive.go +++ b/pkg/filemanager/manager/archive.go @@ -2,13 +2,18 @@ package manager import ( "archive/zip" + "compress/flate" "context" "encoding/gob" "fmt" + "hash/crc32" "io" + "os" "path" "path/filepath" + "runtime" "strings" + "sync" "time" "github.com/bodgit/sevenzip" @@ -24,7 +29,7 @@ import ( "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/encoding/traditionalchinese" "golang.org/x/text/encoding/unicode" - "golang.org/x/tools/container/intsets" + "math" ) type ( @@ -172,48 +177,48 @@ func (m *manager) CreateArchive(ctx context.Context, uris []*fs.URI, writer io.W zipWriter := zip.NewWriter(writer) defer zipWriter.Close() - var compressed int64 + // Enumerate all archive entries up front; compression itself then runs + // either sequentially or fanned out to workers (#3316). + jobs := make([]createArchiveJob, 0, len(files)) for _, file := range files { if file.Type() == types.FileTypeFile { - if err := m.compressFileToArchive(ctx, "/", file, zipWriter, o.ArchiveCompression, o.DryRun); err != nil { - failed++ - m.l.Warning("Failed to compress file %s: %s, skipping it...", file.Uri(false), err) - } - - compressed += file.Size() - if o.ProgressFunc != nil { - o.ProgressFunc(compressed, file.Size(), 0) - } + jobs = append(jobs, createArchiveJob{parent: "/", file: file}) + continue + } - if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize { - return 0, fs.ErrArchiveSrcSizeTooBig + if err := m.Walk(ctx, file.Uri(false), math.MaxInt, func(f fs.File, level int) error { + if f.Type() == types.FileTypeFolder || f.IsSymbolic() { + return nil } + jobs = append(jobs, createArchiveJob{ + parent: strings.TrimPrefix(f.Uri(false).Dir(), file.Uri(false).Dir()), + file: f, + }) + return nil + }); err != nil { + m.l.Warning("Failed to walk folder %s: %s, skipping it...", file.Uri(false), err) + failed++ + } + } - } else { - if err := m.Walk(ctx, file.Uri(false), intsets.MaxInt, func(f fs.File, level int) error { - if f.Type() == types.FileTypeFolder || f.IsSymbolic() { - return nil - } - if err := m.compressFileToArchive(ctx, strings.TrimPrefix(f.Uri(false).Dir(), - file.Uri(false).Dir()), f, zipWriter, o.ArchiveCompression, o.DryRun); err != nil { - failed++ - m.l.Warning("Failed to compress file %s: %s, skipping it...", f.Uri(false), err) - } + if o.DryRun == nil && o.ArchiveCompression && o.ArchiveWorkers != 1 && len(jobs) > 1 { + return m.createArchiveParallel(ctx, jobs, zipWriter, o) + } - compressed += f.Size() - if o.ProgressFunc != nil { - o.ProgressFunc(compressed, f.Size(), 0) - } + var compressed int64 + for _, job := range jobs { + if err := m.compressFileToArchive(ctx, job.parent, job.file, zipWriter, o.ArchiveCompression, o.DryRun); err != nil { + failed++ + m.l.Warning("Failed to compress file %s: %s, skipping it...", job.file.Uri(false), err) + } - if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize { - return fs.ErrArchiveSrcSizeTooBig - } + compressed += job.file.Size() + if o.ProgressFunc != nil { + o.ProgressFunc(compressed, job.file.Size(), 0) + } - return nil - }); err != nil { - m.l.Warning("Failed to walk folder %s: %s, skipping it...", file.Uri(false), err) - failed++ - } + if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize { + return 0, fs.ErrArchiveSrcSizeTooBig } } @@ -257,6 +262,163 @@ func (m *manager) compressFileToArchive(ctx context.Context, parent string, file } +// maxArchiveWorkers caps the adaptive default so disk spooling stays sane. +const maxArchiveWorkers = 8 + +type ( + // createArchiveJob is a single file scheduled into an archive. + createArchiveJob struct { + parent string + file fs.File + } + // archiveEntryResult carries a pre-compressed entry ready for a raw + // zip write, along with the temp file holding its deflate stream. + archiveEntryResult struct { + header *zip.FileHeader + tmpPath string + file fs.File + err error + } +) + +// countingWriter tracks bytes written for zip header sizes. +type countingWriter struct { + w io.Writer + n int64 +} + +func (c *countingWriter) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + c.n += int64(n) + return n, err +} + +// createArchiveParallel deflates archive entries on a worker pool while a +// single writer drains results in completion order via CreateRaw (#3316). +// Compressed data is spooled to temp files so zip's sequential layout is +// preserved without holding entries in memory. +func (m *manager) createArchiveParallel(ctx context.Context, jobs []createArchiveJob, zipWriter *zip.Writer, o *fs.FsOption) (int, error) { + workers := o.ArchiveWorkers + if workers <= 0 { + workers = min(runtime.GOMAXPROCS(0), maxArchiveWorkers) + } + + jobCh := make(chan createArchiveJob) + resCh := make(chan archiveEntryResult, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for job := range jobCh { + resCh <- m.spoolEntry(ctx, job) + } + }() + } + go func() { + for _, job := range jobs { + jobCh <- job + } + close(jobCh) + wg.Wait() + close(resCh) + }() + + failed := 0 + var compressed int64 + for res := range resCh { + if res.err != nil { + failed++ + m.l.Warning("Failed to compress file %s: %s, skipping it...", res.file.Uri(false), res.err) + } else { + if err := writeRawEntry(zipWriter, res); err != nil { + failed++ + m.l.Warning("Failed to write archive entry %s: %s, skipping it...", res.file.Uri(false), err) + } + } + + compressed += res.file.Size() + if o.ProgressFunc != nil { + o.ProgressFunc(compressed, res.file.Size(), 0) + } + if o.MaxArchiveSize > 0 && compressed > o.MaxArchiveSize { + return 0, fs.ErrArchiveSrcSizeTooBig + } + } + + return failed, nil +} + +// spoolEntry deflates one file into a temp file and returns a header with +// precomputed CRC32 and sizes for zip.Writer.CreateRaw. +func (m *manager) spoolEntry(ctx context.Context, job createArchiveJob) archiveEntryResult { + res := archiveEntryResult{file: job.file} + es, err := m.GetEntitySource(ctx, job.file.PrimaryEntityID()) + if err != nil { + res.err = fmt.Errorf("failed to get entity source: %w", err) + return res + } + defer es.Close() + + tmp, err := os.CreateTemp("", "cloudreve-zip-*") + if err != nil { + res.err = err + return res + } + + cw := &countingWriter{w: tmp} + crc := crc32.NewIEEE() + fw, err := flate.NewWriter(cw, flate.DefaultCompression) + if err != nil { + tmp.Close() + os.Remove(tmp.Name()) + res.err = err + return res + } + es.Apply(entitysource.WithContext(ctx)) + _, copyErr := io.Copy(fw, io.TeeReader(es, crc)) + closeErr := fw.Close() + tmp.Close() + if copyErr != nil { + os.Remove(tmp.Name()) + res.err = copyErr + return res + } + if closeErr != nil { + os.Remove(tmp.Name()) + res.err = closeErr + return res + } + + res.header = &zip.FileHeader{ + Name: filepath.FromSlash(path.Join(job.parent, job.file.DisplayName())), + Method: zip.Deflate, + Modified: job.file.UpdatedAt(), + CRC32: crc.Sum32(), + CompressedSize64: uint64(cw.n), + UncompressedSize64: uint64(job.file.Size()), + } + res.tmpPath = tmp.Name() + return res +} + +// writeRawEntry writes a pre-compressed entry's local header and streams +// its spooled deflate data, then removes the temp file. +func writeRawEntry(zipWriter *zip.Writer, res archiveEntryResult) error { + defer os.Remove(res.tmpPath) + raw, err := zipWriter.CreateRaw(res.header) + if err != nil { + return err + } + tmp, err := os.Open(res.tmpPath) + if err != nil { + return err + } + defer tmp.Close() + _, err = io.Copy(raw, tmp) + return err +} + func getZipFileList(ctx context.Context, file io.ReaderAt, size int64, textEncoding encoding.Encoding) ([]ArchivedFile, error) { zr, err := zip.NewReader(file, size) if err != nil { @@ -283,7 +445,7 @@ func getZipFileList(ctx context.Context, file io.ReaderAt, size int64, textEncod info := f.FileInfo() modTime := info.ModTime() fileList = append(fileList, ArchivedFile{ - Name: util.FormSlash(hdr.Name), + Name: util.FormSlash(strings.ToValidUTF8(hdr.Name, "�")), Size: info.Size(), UpdatedAt: &modTime, IsDirectory: info.IsDir(), @@ -303,7 +465,7 @@ func get7zFileList(ctx context.Context, file io.ReaderAt, size int64, extEncodin info := f.FileInfo() modTime := info.ModTime() fileList = append(fileList, ArchivedFile{ - Name: util.FormSlash(f.Name), + Name: util.FormSlash(strings.ToValidUTF8(f.Name, "�")), Size: info.Size(), UpdatedAt: &modTime, IsDirectory: info.IsDir(), diff --git a/pkg/filemanager/manager/archive_parallel_test.go b/pkg/filemanager/manager/archive_parallel_test.go new file mode 100644 index 00000000..e0d6bfbc --- /dev/null +++ b/pkg/filemanager/manager/archive_parallel_test.go @@ -0,0 +1,65 @@ +package manager + +import ( + "archive/zip" + "bytes" + "compress/flate" + "hash/crc32" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestWriteRawEntryRoundTrip verifies that a deflate stream spooled to a +// temp file with precomputed CRC32/sizes produces a valid zip entry via +// CreateRaw — the invariant the parallel path relies on (#3316). +func TestWriteRawEntryRoundTrip(t *testing.T) { + payload := []byte("hello cloudreve parallel zip payload, repeat repeat repeat") + + var compBuf bytes.Buffer + cw := &countingWriter{w: &compBuf} + crc := crc32.NewIEEE() + fw, err := flate.NewWriter(cw, flate.DefaultCompression) + require.NoError(t, err) + _, err = io.Copy(fw, io.TeeReader(bytes.NewReader(payload), crc)) + require.NoError(t, err) + require.NoError(t, fw.Close()) + + tmp := filepath.Join(t.TempDir(), "entry.deflate") + require.NoError(t, os.WriteFile(tmp, compBuf.Bytes(), 0o600)) + + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + err = writeRawEntry(zw, archiveEntryResult{ + header: &zip.FileHeader{ + Name: "dir/a.txt", + Method: zip.Deflate, + CRC32: crc.Sum32(), + CompressedSize64: uint64(cw.n), + UncompressedSize64: uint64(len(payload)), + }, + tmpPath: tmp, + }) + require.NoError(t, err) + require.NoError(t, zw.Close()) + + // Temp file is consumed and removed. + _, statErr := os.Stat(tmp) + require.True(t, os.IsNotExist(statErr)) + + zr, err := zip.NewReader(bytes.NewReader(zipBuf.Bytes()), int64(zipBuf.Len())) + require.NoError(t, err) + require.Len(t, zr.File, 1) + require.Equal(t, "dir/a.txt", zr.File[0].Name) + require.Equal(t, uint64(len(payload)), zr.File[0].UncompressedSize64) + + rc, err := zr.File[0].Open() + require.NoError(t, err) + got, err := io.ReadAll(rc) + require.NoError(t, err) + require.NoError(t, rc.Close()) + require.Equal(t, payload, got) +} diff --git a/pkg/filemanager/manager/manager.go b/pkg/filemanager/manager/manager.go index 2f022f7a..39a39085 100644 --- a/pkg/filemanager/manager/manager.go +++ b/pkg/filemanager/manager/manager.go @@ -127,6 +127,7 @@ type ( AllowEdit bool PreviewOnly bool UploadOnly bool + Note string } FullTextSearchResults struct { @@ -164,9 +165,21 @@ func NewFileManager(dep dependency.Dep, u *ent.User) FileManager { l: dep.Logger(), user: u, settings: dep.SettingProvider(), - fs: dbfs.NewDatabaseFS(u, dep.FileClient(), dep.ShareClient(), dep.Logger(), dep.LockSystem(), - dep.SettingProvider(), dep.StoragePolicyClient(), dep.HashIDEncoder(), dep.UserClient(), dep.KV(), dep.NavigatorStateKV(), - dep.DirectLinkClient(), dep.EncryptorFactory(context.TODO()), dep.EventHub()), + fs: dbfs.NewDatabaseFS(u, dbfs.DBFSDependencies{ + FileClient: dep.FileClient(), + ShareClient: dep.ShareClient(), + UserClient: dep.UserClient(), + StoragePolicyClient: dep.StoragePolicyClient(), + DirectLinkClient: dep.DirectLinkClient(), + Logger: dep.Logger(), + LockSystem: dep.LockSystem(), + SettingProvider: dep.SettingProvider(), + Hasher: dep.HashIDEncoder(), + Cache: dep.KV(), + StateKV: dep.NavigatorStateKV(), + EncryptorFactory: dep.EncryptorFactory(context.TODO()), + EventHub: dep.EventHub(), + }), kv: dep.KV(), config: config, auth: dep.GeneralAuth(), diff --git a/pkg/filemanager/manager/operation.go b/pkg/filemanager/manager/operation.go index ca87f280..7e2ce99f 100644 --- a/pkg/filemanager/manager/operation.go +++ b/pkg/filemanager/manager/operation.go @@ -140,7 +140,15 @@ func (m *manager) Create(ctx context.Context, path *fs.URI, fileType types.FileT func (m *manager) Rename(ctx context.Context, path *fs.URI, newName string) (fs.File, error) { file, indexDiff, err := m.fs.Rename(ctx, path, newName) m.processIndexDiff(ctx, indexDiff) - return file, err + if err != nil { + return file, err + } + + // Keep WebDAV mounts bound under the renamed path pointing at its new URI. + // Scope to the file's owner — the URI authority carries the owner's hash, + // which may differ from the requester for admin-on-behalf operations. + m.rewriteDavMounts(ctx, file.OwnerID(), path, file.Uri(false)) + return file, nil } func (m *manager) MoveOrCopy(ctx context.Context, src []*fs.URI, dst *fs.URI, isCopy bool) error { @@ -150,9 +158,28 @@ func (m *manager) MoveOrCopy(ctx context.Context, src []*fs.URI, dst *fs.URI, is } m.processIndexDiff(ctx, indexDiff) + + // Keep WebDAV mounts bound under moved paths pointing at their new URIs. + if !isCopy && m.user != nil { + for _, srcURI := range src { + m.rewriteDavMounts(ctx, m.user.ID, srcURI, dst.JoinRaw(srcURI.Name())) + } + } return nil } +// rewriteDavMounts repoints the given user's dav account mount URIs that +// reference oldURI (or any path under it) to newURI. Failures are logged but +// never fail the file operation itself. +func (m *manager) rewriteDavMounts(ctx context.Context, ownerID int, oldURI, newURI *fs.URI) { + if ownerID == 0 || oldURI == nil || newURI == nil { + return + } + if err := m.dep.DavAccountClient().UpdateURIPrefix(ctx, ownerID, oldURI.String(), newURI.String()); err != nil { + m.l.Warning("failed to update dav account URIs after file operation: %s", err) + } +} + func (m *manager) SoftDelete(ctx context.Context, path ...*fs.URI) error { return m.fs.SoftDelete(ctx, path...) } @@ -328,6 +355,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, path *fs.URI, args *C AllowEdit: args.AllowEdit, PreviewOnly: args.PreviewOnly, UploadOnly: args.UploadOnly, + Note: args.Note, } share, err := shareClient.Upsert(ctx, &inventory.CreateShareParams{ diff --git a/pkg/filemanager/manager/recycle.go b/pkg/filemanager/manager/recycle.go index e01abfbd..7929c05f 100644 --- a/pkg/filemanager/manager/recycle.go +++ b/pkg/filemanager/manager/recycle.go @@ -40,6 +40,10 @@ type ( } ) +// RecycleFailThreshold is the number of consecutive sweeps an entity may fail +// driver deletion before its DB row is force-removed (blob orphaned). +const RecycleFailThreshold = 5 + func init() { queue.RegisterResumableTaskFactory(queue.ExplicitEntityRecycleTaskType, NewExplicitEntityRecycleTaskFromModel) queue.RegisterResumableTaskFactory(queue.EntityRecycleRoutineTaskType, NewEntityRecycleRoutineTaskFromModel) @@ -209,12 +213,19 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ... m.l.Info("Start to recycle batch #%d, %d entities", batch, len(chunk)) mapSrcToId := make(map[string]int, len(chunk)) _, d, err := m.getEntityPolicyDriver(ctx, chunk[0], nil) - if err != nil { + if err != nil && !force { for _, entity := range chunk { ae.Add(strconv.Itoa(entity.ID()), err) } continue } + if err != nil { + // Force delete: the policy/driver is broken (e.g. deleted or + // misconfigured), so physical blobs are unreachable anyway — + // drop the DB rows instead of deadlocking the policy. + m.l.Warning("Skipping driver init failure under force recycle: %s", err) + d = nil + } for _, entity := range chunk { mapSrcToId[entity.Source()] = entity.ID() @@ -226,14 +237,19 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ... }), func(entity fs.Entity, index int) string { return entity.Source() }) - if len(toBeDeletedSrc) > 0 { - res, err := d.Delete(ctx, toBeDeletedSrc...) - if err != nil { - for _, src := range res { - ae.Add(strconv.Itoa(mapSrcToId[src]), err) + var failedSrcs []string + if len(toBeDeletedSrc) > 0 && d != nil { + var deleteErr error + failedSrcs, deleteErr = d.Delete(ctx, toBeDeletedSrc...) + if deleteErr != nil { + for _, src := range failedSrcs { + ae.Add(strconv.Itoa(mapSrcToId[src]), deleteErr) } } } + // A failure covering every source signals an infrastructure outage + // (driver/bucket down) — per-entity badness only counts on partial failure. + partialFailure := len(failedSrcs) < len(toBeDeletedSrc) // Delete upload session if it's still valid for _, entity := range chunk { @@ -244,8 +260,10 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ... if session, ok := m.kv.Get(UploadSessionCachePrefix + sid.String()); ok { session := session.(fs.UploadSession) - if err := d.CancelToken(ctx, &session); err != nil { - m.l.Warning("Failed to cancel upload session for %q: %s, this is expected if it's remote policy.", session.Props.Uri.String(), err) + if d != nil { + if err := d.CancelToken(ctx, &session); err != nil { + m.l.Warning("Failed to cancel upload session for %q: %s, this is expected if it's remote policy.", session.Props.Uri.String(), err) + } } _ = m.kv.Delete(UploadSessionCachePrefix, sid.String()) } @@ -253,25 +271,53 @@ func (m *manager) RecycleEntities(ctx context.Context, force bool, entityIDs ... // Filtering out entities that are successfully deleted rawAe := ae.Raw() - successEntities := lo.FilterMap(chunk, func(entity fs.Entity, index int) (int, bool) { + successEntities := make([]int, 0, len(chunk)) + var dirtyProps []*ent.Entity + for _, entity := range chunk { entityIdStr := fmt.Sprintf("%d", entity.ID()) - _, ok := rawAe[entityIdStr] - if !ok { - // No error, deleted - return entity.ID(), true + if _, ok := rawAe[entityIdStr]; !ok { + successEntities = append(successEntities, entity.ID()) + continue } if force { ae.Remove(entityIdStr) + successEntities = append(successEntities, entity.ID()) + continue } - return entity.ID(), force - }) + + // Count consecutive per-entity delete failures; past the + // threshold the row is force-removed so one un-deletable blob + // (e.g. an illegal over-long path) cannot stall every sweep. + if partialFailure { + model := entity.Model() + if model.Props == nil { + model.Props = &types.EntityProps{} + } + model.Props.RecycleFailCount++ + if model.Props.RecycleFailCount >= RecycleFailThreshold { + m.l.Warning( + "Entity %d failed blob deletion %d times; removing DB row and orphaning blob %q", + entity.ID(), model.Props.RecycleFailCount, entity.Source()) + ae.Remove(entityIdStr) + successEntities = append(successEntities, entity.ID()) + continue + } + dirtyProps = append(dirtyProps, model) + } + } // Remove entities from DB fc, tx, ctx, err := inventory.WithTx(ctx, m.dep.FileClient()) if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } + if len(dirtyProps) > 0 { + if err := fc.UpdateEntityProps(ctx, dirtyProps...); err != nil { + _ = inventory.Rollback(tx) + return fmt.Errorf("failed to persist recycle failure counts: %w", err) + } + } storageReduced, err := fc.RemoveEntitiesByID(ctx, successEntities...) if err != nil { _ = inventory.Rollback(tx) diff --git a/pkg/filemanager/manager/recycle_force_test.go b/pkg/filemanager/manager/recycle_force_test.go new file mode 100644 index 00000000..bf8f44db --- /dev/null +++ b/pkg/filemanager/manager/recycle_force_test.go @@ -0,0 +1,83 @@ +package manager + +import ( + "context" + "path/filepath" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/stretchr/testify/require" +) + +// TestRecycleEntitiesForceBypassesBrokenPolicy covers upstream #2909: when a +// storage policy is deleted or too broken to initialize a driver, its blob +// rows used to be undeletable even with force=true, which also deadlocked +// removal of the policy itself. +func TestRecycleEntitiesForceBypassesBrokenPolicy(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + + logger := logging.NewConsoleLogger(logging.LevelError) + cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger) + require.NoError(t, err) + hasher, err := hashid.New("test-salt") + require.NoError(t, err) + + dep := dependency.NewDependency( + dependency.WithDbClient(client), + dependency.WithConfigProvider(cfg), + dependency.WithKV(cache.NewMemoStore("", logger)), + dependency.WithLogger(logger), + dependency.WithHashIDEncoder(hasher), + ) + ctx := context.WithValue(context.Background(), dependency.DepCtx{}, dep) + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + user := client.User.Create().SetEmail("u@example.com").SetNick("u").SetGroup(group).SaveX(ctx) + + // Unknown policy type - GetStorageDriver rejects it with + // ErrUnknownPolicyType, simulating a misconfigured policy. + brokenPolicy := client.StoragePolicy.Create(). + SetName("broken"). + SetType("bogus"). + SetAccessKey("x"). + SetSecretKey("x"). + SetMaxSize(1). + SetDirNameRule("x"). + SetFileNameRule("x"). + SaveX(ctx) + + newStaleEntity := func() int { + return client.Entity.Create(). + SetType(0). + SetSource("uploads/blob.bin"). + SetSize(123). + SetStoragePolicyEntities(brokenPolicy.ID). + SetReferenceCount(0). + SetCreatedBy(user.ID). + SaveX(ctx).ID + } + + fm := NewFileManager(dep, user) + + t.Run("without force the row is retained and error returned", func(t *testing.T) { + id := newStaleEntity() + err := fm.RecycleEntities(ctx, false, id) + require.Error(t, err) + require.NotNil(t, client.Entity.GetX(ctx, id)) + }) + + t.Run("with force the row is dropped despite driver failure", func(t *testing.T) { + id := newStaleEntity() + err := fm.RecycleEntities(ctx, true, id) + require.NoError(t, err) + _, getErr := client.Entity.Get(ctx, id) + require.Error(t, getErr) + }) +} diff --git a/pkg/filemanager/manager/thumbnail.go b/pkg/filemanager/manager/thumbnail.go index 1b979382..bb30d0bb 100644 --- a/pkg/filemanager/manager/thumbnail.go +++ b/pkg/filemanager/manager/thumbnail.go @@ -184,9 +184,18 @@ func (m *manager) generateThumb(ctx context.Context, uri *fs.URI, ext string, es entityType := types.EntityTypeThumbnail req := &fs.UploadRequest{ Props: &fs.UploadProps{ - Uri: uri, - Size: fileInfo.Size(), - SavePath: path.Clean(util.ReplaceMagicVar(m.settings.ThumbEntitySuffix(ctx), fs.Separator, true, true, time.Now(), m.user.ID, uri.Name(), uri.Path(), es.Entity().Source())), + Uri: uri, + Size: fileInfo.Size(), + SavePath: path.Clean(fs.ReplaceMagicVar(m.settings.ThumbEntitySuffix(ctx), fs.MagicVarProps{ + FsSeparator: fs.Separator, + PathAvailable: true, + BlobAvailable: true, + Time: time.Now(), + UserID: m.user.ID, + OriginName: uri.Name(), + OriginPath: uri.Path(), + CompleteBlobPath: es.Entity().Source(), + })), MimeType: m.dep.MimeDetector(ctx).TypeByName("thumb.jpg"), EntityType: &entityType, }, diff --git a/pkg/filemanager/workflows/archive.go b/pkg/filemanager/workflows/archive.go index 53fe21b1..2bef70b5 100644 --- a/pkg/filemanager/workflows/archive.go +++ b/pkg/filemanager/workflows/archive.go @@ -125,6 +125,11 @@ func (m *CreateArchiveTask) Do(ctx context.Context) (task.Status, error) { } m.state = state + // Prefer the node hosting the first source file's storage policy. + if len(m.state.Uris) > 0 { + preferPolicyNode(ctx, dep, &m.state.NodeState, m.state.Uris[0]) + } + // select node node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityCreateArchive) if err != nil { diff --git a/pkg/filemanager/workflows/blob_audit.go b/pkg/filemanager/workflows/blob_audit.go new file mode 100644 index 00000000..e1a54541 --- /dev/null +++ b/pkg/filemanager/workflows/blob_audit.go @@ -0,0 +1,301 @@ +package workflows + +import ( + "context" + "encoding/json" + "fmt" + "path" + "strings" + "sync/atomic" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" + "github.com/cloudreve/Cloudreve/v4/ent/task" + "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/filemanager/manager" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/queue" +) + +type ( + BlobAuditTask struct { + *queue.DBTask + state *BlobAuditTaskState + l logging.Logger + progress queue.Progresses + } + BlobAuditTaskPhase string + BlobAuditTaskState struct { + Phase BlobAuditTaskPhase `json:"phase"` + PolicyID int `json:"policy_id"` + Delete bool `json:"delete"` + Scanned int `json:"scanned"` + Orphans []string `json:"orphans"` + OrphanCount int `json:"orphan_count"` + Deleted int `json:"deleted"` + Failed int `json:"failed"` + Truncated bool `json:"truncated"` + } +) + +const ( + BlobAuditPhaseScan BlobAuditTaskPhase = "scan" + BlobAuditPhaseDelete BlobAuditTaskPhase = "delete" + + BlobAuditDeleteBatch = 200 + BlobAuditMaxOrphanList = 5000 + + ProgressTypeBlobAudit = "blob_audit" + SummaryKeyOrphanCount = "orphan_count" + SummaryKeyDeleted = "deleted" + SummaryKeyScannedObject = "scanned" +) + +func init() { + queue.RegisterResumableTaskFactory(queue.BlobAuditTaskType, NewBlobAuditTaskFromModel) +} + +// NewBlobAuditTask creates a task that lists all physical objects under a +// storage policy and diffs them against the entities table, reporting (and +// optionally deleting) blobs no entity references (upstream #3395). +func NewBlobAuditTask(ctx context.Context, u *ent.User, policyID int, delete bool) (queue.Task, error) { + state := &BlobAuditTaskState{ + Phase: BlobAuditPhaseScan, + PolicyID: policyID, + Delete: delete, + } + stateBytes, err := json.Marshal(state) + if err != nil { + return nil, fmt.Errorf("failed to marshal state: %w", err) + } + + return &BlobAuditTask{ + DBTask: &queue.DBTask{ + Task: &ent.Task{ + Type: queue.BlobAuditTaskType, + CorrelationID: logging.CorrelationID(ctx), + PrivateState: string(stateBytes), + PublicState: &types.TaskPublicState{}, + }, + DirectOwner: u, + }, + }, nil +} + +func NewBlobAuditTaskFromModel(t *ent.Task) queue.Task { + return &BlobAuditTask{ + DBTask: &queue.DBTask{ + Task: t, + }, + } +} + +func (m *BlobAuditTask) Do(ctx context.Context) (task.Status, error) { + dep := dependency.FromContext(ctx) + m.l = dep.Logger() + + m.Lock() + if m.progress == nil { + m.progress = make(queue.Progresses) + } + m.progress[ProgressTypeBlobAudit] = &queue.Progress{} + m.Unlock() + + state := &BlobAuditTaskState{} + if err := json.Unmarshal([]byte(m.State()), state); err != nil { + return task.StatusError, fmt.Errorf("failed to unmarshal state: %s (%w)", err, queue.CriticalErr) + } + m.state = state + + var ( + next = task.StatusCompleted + err error + ) + switch m.state.Phase { + case BlobAuditPhaseScan, "": + next, err = m.scan(ctx, dep) + case BlobAuditPhaseDelete: + next, err = m.deleteOrphans(ctx, dep) + default: + next, err = task.StatusError, fmt.Errorf("unknown phase %q: %w", m.state.Phase, queue.CriticalErr) + } + + newStateStr, marshalErr := json.Marshal(m.state) + if marshalErr != nil { + return task.StatusError, fmt.Errorf("failed to marshal state: %w", marshalErr) + } + + m.Lock() + m.Task.PrivateState = string(newStateStr) + m.Unlock() + return next, err +} + +// scan lists all physical objects under the policy's blob prefix and diffs +// them against the entity sources recorded for that policy. +func (m *BlobAuditTask) scan(ctx context.Context, dep dependency.Dep) (task.Status, error) { + policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, m.state.PolicyID) + if err != nil { + return task.StatusError, fmt.Errorf("failed to get storage policy %d: %w", m.state.PolicyID, err) + } + if policy.Status == storagepolicy.StatusSuspended { + return task.StatusError, fmt.Errorf("storage policy %d is suspended: %w", m.state.PolicyID, queue.CriticalErr) + } + + fm := manager.NewFileManager(dep, nil) + defer fm.Recycle() + d, err := fm.GetStorageDriver(ctx, policy) + if err != nil { + return task.StatusError, fmt.Errorf("failed to get storage driver: %w", err) + } + + // Literal prefix of the dir name rule (e.g. "uploads/{uid}/{path}" -> + // "uploads") scopes the listing so non-blob areas (temp, avatars) are + // never touched. + base := literalDirPrefix(policy.DirNameRule) + m.l.Info("Blob audit on policy %d: listing objects under %q", policy.ID, base) + + var objects []fs.PhysicalObject + objects, err = d.List(ctx, base, func(i int) { + atomic.AddInt64(&m.progress[ProgressTypeBlobAudit].Current, int64(i)) + }, true) + if err != nil { + return task.StatusError, fmt.Errorf("failed to list objects on policy %d: %w", m.state.PolicyID, err) + } + m.state.Scanned = len(objects) + + // Full entity source set for this policy, paginated. + known := make(map[string]struct{}) + page := 0 + for { + res, err := dep.FileClient().ListEntities(ctx, &inventory.ListEntityParameters{ + PaginationArgs: &inventory.PaginationArgs{Page: page, PageSize: 1000}, + StoragePolicyID: policy.ID, + }) + if err != nil { + return task.StatusError, fmt.Errorf("failed to list entities: %w", err) + } + for _, e := range res.Entities { + known[e.Source] = struct{}{} + } + if len(res.Entities) < 1000 { + break + } + page++ + } + + orphanSet := make(map[string]struct{}) + for _, obj := range objects { + if obj.IsDir { + continue + } + key := obj.RelativePath + if base != "" { + key = path.Join(base, obj.RelativePath) + } + if _, ok := known[key]; ok { + continue + } + if _, dup := orphanSet[key]; dup { + continue + } + orphanSet[key] = struct{}{} + if len(m.state.Orphans) < BlobAuditMaxOrphanList { + m.state.Orphans = append(m.state.Orphans, key) + } else { + m.state.Truncated = true + } + } + + m.l.Info("Blob audit on policy %d: %d objects scanned, %d orphans found.", policy.ID, m.state.Scanned, len(orphanSet)) + m.state.OrphanCount = len(orphanSet) + + if m.state.Delete && len(m.state.Orphans) > 0 { + m.state.Phase = BlobAuditPhaseDelete + m.ResumeAfter(0) + return task.StatusSuspending, nil + } + return task.StatusCompleted, nil +} + +// deleteOrphans removes the recorded orphan blobs in batches. +func (m *BlobAuditTask) deleteOrphans(ctx context.Context, dep dependency.Dep) (task.Status, error) { + if len(m.state.Orphans) == 0 { + return task.StatusCompleted, nil + } + + policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, m.state.PolicyID) + if err != nil { + return task.StatusError, fmt.Errorf("failed to get storage policy %d: %w", m.state.PolicyID, err) + } + + fm := manager.NewFileManager(dep, nil) + defer fm.Recycle() + d, err := fm.GetStorageDriver(ctx, policy) + if err != nil { + return task.StatusError, fmt.Errorf("failed to get storage driver: %w", err) + } + + batch := m.state.Orphans + if len(batch) > BlobAuditDeleteBatch { + batch = batch[:BlobAuditDeleteBatch] + } + + failed, err := d.Delete(ctx, batch...) + if err != nil { + m.l.Warning("Blob audit delete partially failed on policy %d: %s", policy.ID, err) + } + m.state.Deleted += len(batch) - len(failed) + m.state.Failed += len(failed) + // Failed paths are dropped from the list — a re-run of the audit flags + // them again if they are still orphaned. + m.state.Orphans = m.state.Orphans[len(batch):] + + atomic.StoreInt64(&m.progress[ProgressTypeBlobAudit].Current, int64(m.state.Deleted)) + + if len(m.state.Orphans) > 0 { + m.ResumeAfter(0) + return task.StatusSuspending, nil + } + return task.StatusCompleted, nil +} + +// literalDirPrefix returns the static path prefix before the first magic +// variable, e.g. "uploads/{uid}/{path}" -> "uploads". An empty result means +// the rule starts with a variable and the policy root cannot be narrowed. +func literalDirPrefix(rule string) string { + if i := strings.Index(rule, "{"); i >= 0 { + rule = rule[:i] + } + return strings.Trim(rule, "/") +} + +func (m *BlobAuditTask) Progress(ctx context.Context) queue.Progresses { + m.Lock() + defer m.Unlock() + return m.progress +} + +func (m *BlobAuditTask) Summarize(hasher hashid.Encoder) *queue.Summary { + if m.state == nil { + if err := json.Unmarshal([]byte(m.State()), &m.state); err != nil { + return nil + } + } + + return &queue.Summary{ + Phase: string(m.state.Phase), + Props: map[string]any{ + SummaryKeyOrphanCount: m.state.OrphanCount, + SummaryKeyScannedObject: m.state.Scanned, + SummaryKeyDeleted: m.state.Deleted, + SummaryKeyFailed: m.state.Failed, + "truncated": m.state.Truncated, + "policy_id": m.state.PolicyID, + }, + } +} diff --git a/pkg/filemanager/workflows/blob_audit_test.go b/pkg/filemanager/workflows/blob_audit_test.go new file mode 100644 index 00000000..7e29164c --- /dev/null +++ b/pkg/filemanager/workflows/blob_audit_test.go @@ -0,0 +1,23 @@ +package workflows + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLiteralDirPrefix(t *testing.T) { + cases := []struct { + rule string + want string + }{ + {"uploads/{uid}/{path}", "uploads"}, + {"uploads/{uid}", "uploads"}, + {"{uid}/{path}", ""}, + {"blob/store/{uid}", "blob/store"}, + {"", ""}, + } + for _, c := range cases { + require.Equal(t, c.want, literalDirPrefix(c.rule), "rule %q", c.rule) + } +} diff --git a/pkg/filemanager/workflows/extract.go b/pkg/filemanager/workflows/extract.go index 9fd2dce4..b145a0aa 100644 --- a/pkg/filemanager/workflows/extract.go +++ b/pkg/filemanager/workflows/extract.go @@ -126,6 +126,9 @@ func (m *ExtractArchiveTask) Do(ctx context.Context) (task.Status, error) { } m.state = state + // Prefer the node hosting the source file's storage policy. + preferPolicyNode(ctx, dep, &m.state.NodeState, m.state.Uri) + // select node node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityExtractArchive) if err != nil { @@ -375,7 +378,7 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen return nil } - rawPath := util.FormSlash(f.NameInArchive) + rawPath := util.FormSlash(strings.ToValidUTF8(f.NameInArchive, "�")) savePath := dst.JoinRaw(rawPath) // If file mask is not empty, check if the path is in the mask @@ -751,7 +754,7 @@ func (m *SlaveExtractArchiveTask) Do(ctx context.Context) (task.Status, error) { return nil } - rawPath := util.FormSlash(f.NameInArchive) + rawPath := util.FormSlash(strings.ToValidUTF8(f.NameInArchive, "�")) savePath := dst.JoinRaw(rawPath) // If file mask is not empty, check if the path is in the mask diff --git a/pkg/filemanager/workflows/remote_download.go b/pkg/filemanager/workflows/remote_download.go index 4b78afc8..c02ae566 100644 --- a/pkg/filemanager/workflows/remote_download.go +++ b/pkg/filemanager/workflows/remote_download.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "net/url" "os" "path" "path/filepath" @@ -42,9 +44,16 @@ type ( } RemoteDownloadTaskPhase string RemoteDownloadTaskState struct { - SrcFileUri string `json:"src_file_uri,omitempty"` - SrcUri string `json:"src_uri,omitempty"` - Dst string `json:"dst,omitempty"` + SrcFileUri string `json:"src_file_uri,omitempty"` + SrcUri string `json:"src_uri,omitempty"` + Dst string `json:"dst,omitempty"` + FileName string `json:"file_name,omitempty"` + // Provider pins the task to a node offering this downloader provider + // (e.g. "aria2"/"qbittorrent"). Empty = pool picks any capable node. + Provider string `json:"provider,omitempty"` + HTTPUsername string `json:"http_username,omitempty"` + HTTPPassword string `json:"http_password,omitempty"` + HTTPHeaders []string `json:"http_headers,omitempty"` Handle *downloader.TaskHandle `json:"handle,omitempty"` Status *downloader.TaskStatus `json:"status,omitempty"` NodeState `json:",inline"` @@ -54,6 +63,10 @@ type ( GetTaskStatusTried int `json:"get_task_status_tried,omitempty"` Transferred map[int]interface{} `json:"transferred,omitempty"` Failed int `json:"failed,omitempty"` + // ResumeMonitorAfterTransfer marks an early transfer batch taken while + // the download is still running: after it completes, return to Monitor + // instead of awaiting seeding. + ResumeMonitorAfterTransfer bool `json:"resume_monitor_after_transfer,omitempty"` } ) @@ -81,14 +94,32 @@ func init() { queue.RegisterResumableTaskFactory(queue.RemoteDownloadTaskType, NewRemoteDownloadTaskFromModel) } +// RemoteDownloadTaskOption carries optional per-task parameters supplied by +// the user: a custom output file name and HTTP basic-auth credentials for +// plain HTTP(S) sources. +type RemoteDownloadTaskOption struct { + FileName string + Provider string + HTTPUsername string + HTTPPassword string + HTTPHeaders []string +} + // NewRemoteDownloadTask creates a new RemoteDownloadTask -func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string) (queue.Task, error) { +func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string, opts *RemoteDownloadTaskOption) (queue.Task, error) { state := &RemoteDownloadTaskState{ SrcUri: src, SrcFileUri: srcFile, Dst: dst, NodeState: NodeState{}, } + if opts != nil { + state.FileName = sanitizeFileName(opts.FileName) + state.Provider = opts.Provider + state.HTTPUsername = opts.HTTPUsername + state.HTTPPassword = opts.HTTPPassword + state.HTTPHeaders = opts.HTTPHeaders + } stateBytes, err := json.Marshal(state) if err != nil { return nil, fmt.Errorf("failed to marshal state: %w", err) @@ -127,6 +158,12 @@ func (m *RemoteDownloadTask) Do(ctx context.Context) (task.Status, error) { } m.state = state + // Resolve a user-picked downloader provider to a preferred node. Runs only + // until a node is locked in; falls back to any capable node if no match. + if m.state.NodeID == 0 && m.state.Provider != "" { + m.state.NodeID = providerNodeID(ctx, dep, m.state.Provider) + } + // select node node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityRemoteDownload) if err != nil { @@ -214,8 +251,10 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende torrentUrl = torrentUrls[0].Url } + options, taskUrl := m.buildDownloadOptions(ctx, user.Edges.Group.Settings.RemoteDownloadOptions, torrentUrl) + // Create download task - handle, err := m.d.CreateTask(ctx, torrentUrl, user.Edges.Group.Settings.RemoteDownloadOptions) + handle, err := m.d.CreateTask(ctx, taskUrl, options) if err != nil { return task.StatusError, fmt.Errorf("failed to create download task: %w", err) } @@ -225,6 +264,58 @@ func (m *RemoteDownloadTask) createDownloadTask(ctx context.Context, dep depende return task.StatusSuspending, nil } +// buildDownloadOptions overlays per-task options (custom file name, HTTP +// credentials) onto the group's remote-download options. Custom name and +// credentials only apply to plain HTTP(S) source URLs on aria2; qBittorrent +// accepts a torrent rename and carries HTTP auth in the URL userinfo. +func (m *RemoteDownloadTask) buildDownloadOptions(ctx context.Context, base map[string]interface{}, srcUrl string) (map[string]interface{}, string) { + if m.state.FileName == "" && m.state.HTTPUsername == "" && len(m.state.HTTPHeaders) == 0 { + return base, srcUrl + } + + options := maps.Clone(base) + if options == nil { + options = map[string]interface{}{} + } + + isHttpSrc := m.state.SrcFileUri == "" && (strings.HasPrefix(m.state.SrcUri, "http://") || strings.HasPrefix(m.state.SrcUri, "https://")) + switch m.node.Settings(ctx).Provider { + case types.DownloaderProviderQBittorrent: + if m.state.FileName != "" { + options["rename"] = m.state.FileName + } + if isHttpSrc { + if m.state.HTTPUsername != "" { + if u, err := url.Parse(srcUrl); err == nil { + u.User = url.UserPassword(m.state.HTTPUsername, m.state.HTTPPassword) + srcUrl = u.String() + } + } + // qBittorrent's add API only accepts a cookie field, not arbitrary + // headers — pass through any Cookie: line the user supplied. + for _, h := range m.state.HTTPHeaders { + if k, v, ok := strings.Cut(h, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "cookie") { + options["cookie"] = strings.TrimSpace(v) + } + } + } + default: + if isHttpSrc { + if m.state.FileName != "" { + options["out"] = m.state.FileName + } + if m.state.HTTPUsername != "" { + options["http-user"] = m.state.HTTPUsername + options["http-passwd"] = m.state.HTTPPassword + } + if len(m.state.HTTPHeaders) > 0 { + options["header"] = m.state.HTTPHeaders + } + } + } + return options, srcUrl +} + // buildSSRFOptions composes the SSRF policy for a download: the assigned // node's URLValidation settings, plus the operator-configured site URL hosts // (always allowlisted so users can fetch files served by Cloudreve itself). @@ -313,6 +404,13 @@ func (m *RemoteDownloadTask) monitor(ctx context.Context, dep dependency.Dep) (t m.l.Info("Download task seeding completed") return task.StatusCompleted, nil case downloader.StatusDownloading: + if m.hasEarlyTransferCandidates(status) { + m.l.Info("Some files already completed, starting early transfer.") + m.state.Phase = RemoteDownloadTaskPhaseTransfer + m.state.ResumeMonitorAfterTransfer = true + m.ResumeAfter(0) + return task.StatusSuspending, nil + } m.ResumeAfter(resumeAfter) return task.StatusSuspending, nil case downloader.StatusUnknown, downloader.StatusError: @@ -323,6 +421,19 @@ func (m *RemoteDownloadTask) monitor(ctx context.Context, dep dependency.Dep) (t return task.StatusSuspending, nil } +// hasEarlyTransferCandidates reports whether any selected file finished +// downloading but has not been transferred to the user's storage yet. +func (m *RemoteDownloadTask) hasEarlyTransferCandidates(status *downloader.TaskStatus) bool { + for _, f := range status.Files { + if f.Selected && f.Progress >= 1 { + if _, ok := m.state.Transferred[f.Index]; !ok { + return true + } + } + } + return false +} + func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.Dep) (task.Status, error) { u := inventory.UserFromContext(ctx) if m.state.Transferred == nil { @@ -353,6 +464,11 @@ func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.D continue } + // During early transfer batches only completed files are picked. + if m.state.ResumeMonitorAfterTransfer && f.Progress < 1 { + continue + } + dst := dstUri.JoinRaw(sanitizeFileName(f.Name)) src := path.Join(m.state.Status.SavePath, f.Name) payload.Files = append(payload.Files, SlaveUploadEntity{ @@ -404,12 +520,30 @@ func (m *RemoteDownloadTask) slaveTransfer(ctx context.Context, dep dependency.D } m.l.Warning("Slave task %d failed to transfer %d files, retrying...", slaveTaskId, len(m.state.SlaveUploadState.Files)-len(m.state.SlaveUploadState.Transferred)) + if m.state.ResumeMonitorAfterTransfer { + // Early transfer batch failed while the download continues - + // return to monitoring; the final transfer pass retries them. + m.state.ResumeMonitorAfterTransfer = false + m.state.Phase = RemoteDownloadTaskPhaseMonitor + m.ResumeAfter(0) + return task.StatusSuspending, nil + } return task.StatusError, fmt.Errorf( "slave task failed to transfer %d files, first 5 errors: %s", len(m.state.SlaveUploadState.Files)-len(m.state.SlaveUploadState.Transferred), m.state.SlaveUploadState.First5TransferErrors, ) } else { + if m.state.ResumeMonitorAfterTransfer { + for i := range m.state.SlaveUploadState.Transferred { + m.state.Transferred[m.state.SlaveUploadState.Files[i].Index] = struct{}{} + } + m.state.SlaveUploadTaskID = 0 + m.state.ResumeMonitorAfterTransfer = false + m.state.Phase = RemoteDownloadTaskPhaseMonitor + m.ResumeAfter(0) + return task.StatusSuspending, nil + } m.state.Phase = RemoteDownloadTaskPhaseAwaitSeeding m.ResumeAfter(0) return task.StatusSuspending, nil @@ -443,6 +577,11 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency. allFiles := make([]downloader.TaskFile, 0, len(m.state.Status.Files)) for _, f := range m.state.Status.Files { if f.Selected { + // Early transfer batches only pick completed files; files still + // downloading are left for the final transfer pass. + if m.state.ResumeMonitorAfterTransfer && f.Progress < 1 { + continue + } allFiles = append(allFiles, f) totalSize += f.Size totalCount++ @@ -546,12 +685,27 @@ func (m *RemoteDownloadTask) masterTransfer(ctx context.Context, dep dependency. wg.Wait() if failed > 0 { + if m.state.ResumeMonitorAfterTransfer { + // Early batch failed while the download continues - return to + // monitoring; the final transfer pass retries the failed files. + m.l.Warning("Early transfer batch failed for %d file(s), will retry after download completes.", failed) + m.state.ResumeMonitorAfterTransfer = false + m.state.Phase = RemoteDownloadTaskPhaseMonitor + m.ResumeAfter(0) + return task.StatusSuspending, nil + } m.state.Failed = int(failed) m.l.Error("Failed to transfer %d file(s).", failed) return task.StatusError, fmt.Errorf("failed to transfer %d file(s), first 5 errors: %s", failed, ae.FormatFirstN(5)) } m.l.Info("All files transferred.") + if m.state.ResumeMonitorAfterTransfer { + m.state.ResumeMonitorAfterTransfer = false + m.state.Phase = RemoteDownloadTaskPhaseMonitor + m.ResumeAfter(0) + return task.StatusSuspending, nil + } m.state.Phase = RemoteDownloadTaskPhaseAwaitSeeding return task.StatusSuspending, nil } @@ -679,6 +833,31 @@ func (m *RemoteDownloadTask) Progress(ctx context.Context) queue.Progresses { } func sanitizeFileName(name string) string { - r := strings.NewReplacer("\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") + r := strings.NewReplacer("\\", "_", "/", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_") return r.Replace(name) } + +// providerNodeID resolves a downloader provider name to the lowest-ID active +// node offering it for remote download. Returns 0 when no node matches, which +// makes the pool fall back to any capable node. +func providerNodeID(ctx context.Context, dep dependency.Dep, provider string) int { + nodes, err := dep.NodeClient().ListActiveNodes(ctx, nil) + if err != nil { + return 0 + } + return PickProviderNode(nodes, provider) +} + +func PickProviderNode(nodes []*ent.Node, provider string) int { + best := 0 + for _, n := range nodes { + if n.Capabilities == nil || !n.Capabilities.Enabled(int(types.NodeCapabilityRemoteDownload)) || + n.Settings == nil || string(n.Settings.Provider) != provider { + continue + } + if best == 0 || n.ID < best { + best = n.ID + } + } + return best +} diff --git a/pkg/filemanager/workflows/remote_download_test.go b/pkg/filemanager/workflows/remote_download_test.go new file mode 100644 index 00000000..d867ae46 --- /dev/null +++ b/pkg/filemanager/workflows/remote_download_test.go @@ -0,0 +1,181 @@ +package workflows + +import ( + "context" + "encoding/json" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster" + "github.com/cloudreve/Cloudreve/v4/pkg/downloader" + "github.com/stretchr/testify/assert" +) + +type stubNode struct { + cluster.Node + settings *types.NodeSetting +} + +func (s *stubNode) Settings(ctx context.Context) *types.NodeSetting { + return s.settings +} + +func newRemoteDownloadTaskForOptions(state *RemoteDownloadTaskState, provider types.DownloaderProvider) *RemoteDownloadTask { + return &RemoteDownloadTask{ + state: state, + node: &stubNode{settings: &types.NodeSetting{Provider: provider}}, + } +} + +func TestBuildDownloadOptionsAria2(t *testing.T) { + a := assert.New(t) + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/file.zip", + FileName: "renamed.zip", + HTTPUsername: "user", + HTTPPassword: "pass", + }, types.DownloaderProviderAria2) + + base := map[string]interface{}{"max-connection-per-server": "4"} + opts, taskUrl := m.buildDownloadOptions(context.Background(), base, "https://example.com/file.zip") + + a.Equal("renamed.zip", opts["out"]) + a.Equal("user", opts["http-user"]) + a.Equal("pass", opts["http-passwd"]) + a.Equal("4", opts["max-connection-per-server"]) + a.Equal("https://example.com/file.zip", taskUrl) + _, exists := base["out"] + a.False(exists, "base options must not be mutated") +} + +func TestBuildDownloadOptionsQBittorrent(t *testing.T) { + a := assert.New(t) + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/file.torrent", + FileName: "renamed", + HTTPUsername: "user", + HTTPPassword: "p@ss:word", + }, types.DownloaderProviderQBittorrent) + + opts, taskUrl := m.buildDownloadOptions(context.Background(), nil, "https://example.com/file.torrent") + + a.Equal("renamed", opts["rename"]) + _, exists := opts["out"] + a.False(exists) + a.Equal("https://user:p%40ss%3Aword@example.com/file.torrent", taskUrl) +} + +func TestBuildDownloadOptionsNonHttpSrc(t *testing.T) { + a := assert.New(t) + + // Torrent source file: aria2 "out" and HTTP credentials must not apply. + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcFileUri: "cloudreve://my/file.torrent", + FileName: "renamed", + HTTPUsername: "user", + HTTPPassword: "pass", + }, types.DownloaderProviderAria2) + + opts, taskUrl := m.buildDownloadOptions(context.Background(), nil, "https://slave.example.com/entity/1") + _, hasOut := opts["out"] + a.False(hasOut) + _, hasUser := opts["http-user"] + a.False(hasUser) + a.Equal("https://slave.example.com/entity/1", taskUrl) + + // Magnet link: same for aria2. + m = newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "magnet:?xt=urn:btih:abc", + FileName: "renamed", + HTTPUsername: "user", + }, types.DownloaderProviderAria2) + opts, _ = m.buildDownloadOptions(context.Background(), nil, "magnet:?xt=urn:btih:abc") + _, hasOut = opts["out"] + a.False(hasOut) + _, hasUser = opts["http-user"] + a.False(hasUser) +} + +func TestBuildDownloadOptionsNoExtras(t *testing.T) { + a := assert.New(t) + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/file.zip", + }, types.DownloaderProviderAria2) + + base := map[string]interface{}{"k": "v"} + opts, taskUrl := m.buildDownloadOptions(context.Background(), base, "https://example.com/file.zip") + a.Equal("v", opts["k"]) + a.Equal("https://example.com/file.zip", taskUrl) +} + +func TestBuildDownloadOptionsHeaders(t *testing.T) { + a := assert.New(t) + + // aria2: arbitrary headers pass through as a list. + m := newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/f.zip", + HTTPHeaders: []string{"Cookie: sid=abc", "Referer: https://example.com/"}, + }, types.DownloaderProviderAria2) + opts, _ := m.buildDownloadOptions(context.Background(), nil, "https://example.com/f.zip") + a.Equal([]string{"Cookie: sid=abc", "Referer: https://example.com/"}, opts["header"]) + + // qBittorrent: only the Cookie line maps onto the cookie field. + m = newRemoteDownloadTaskForOptions(&RemoteDownloadTaskState{ + SrcUri: "https://example.com/f.torrent", + HTTPHeaders: []string{"Referer: https://x/", "Cookie: sid=abc"}, + }, types.DownloaderProviderQBittorrent) + opts, _ = m.buildDownloadOptions(context.Background(), nil, "https://example.com/f.torrent") + a.Equal("sid=abc", opts["cookie"]) + _, hasHeader := opts["header"] + a.False(hasHeader) +} + +func TestHasEarlyTransferCandidates(t *testing.T) { + a := assert.New(t) + m := &RemoteDownloadTask{state: &RemoteDownloadTaskState{}} + + status := &downloader.TaskStatus{Files: []downloader.TaskFile{ + {Index: 0, Selected: true, Progress: 1}, + {Index: 1, Selected: true, Progress: 0.5}, + {Index: 2, Selected: false, Progress: 1}, + }} + a.True(m.hasEarlyTransferCandidates(status)) + + m.state.Transferred = map[int]interface{}{0: struct{}{}} + a.False(m.hasEarlyTransferCandidates(status)) + + status.Files[1].Progress = 1 + a.True(m.hasEarlyTransferCandidates(status)) +} + +func TestNewRemoteDownloadTaskSanitizesFileName(t *testing.T) { + a := assert.New(t) + tsk, err := NewRemoteDownloadTask(context.Background(), "https://example.com/f", "", "cloudreve://my/dst", &RemoteDownloadTaskOption{ + FileName: "../../etc/passwd", + }) + a.NoError(err) + + state := &RemoteDownloadTaskState{} + a.NoError(json.Unmarshal([]byte(tsk.(*RemoteDownloadTask).Task.PrivateState), state)) + a.Equal(".._.._etc_passwd", state.FileName) +} + +func TestPickProviderNode(t *testing.T) { + caps := &boolset.BooleanSet{} + boolset.Set(types.NodeCapabilityRemoteDownload, true, caps) + + nodes := []*ent.Node{ + {ID: 3, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderQBittorrent}}, + {ID: 1, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}}, + {ID: 2, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}}, + {ID: 4, Capabilities: nil, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}}, + {ID: 5, Capabilities: caps, Settings: nil}, + } + + assert.Equal(t, 1, PickProviderNode(nodes, string(types.DownloaderProviderAria2))) + assert.Equal(t, 3, PickProviderNode(nodes, string(types.DownloaderProviderQBittorrent))) + assert.Equal(t, 0, PickProviderNode(nodes, "deluge")) + assert.Equal(t, 0, PickProviderNode(nil, string(types.DownloaderProviderAria2))) +} diff --git a/pkg/filemanager/workflows/worfklows.go b/pkg/filemanager/workflows/workflows.go similarity index 62% rename from pkg/filemanager/workflows/worfklows.go rename to pkg/filemanager/workflows/workflows.go index 475dd9be..ddd35fbc 100644 --- a/pkg/filemanager/workflows/worfklows.go +++ b/pkg/filemanager/workflows/workflows.go @@ -8,8 +8,12 @@ import ( "time" "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/cluster" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" "github.com/cloudreve/Cloudreve/v4/pkg/queue" "github.com/cloudreve/Cloudreve/v4/pkg/util" ) @@ -60,3 +64,38 @@ func prepareTempFolder(ctx context.Context, dep dependency.Dep, t queue.Task) (s dep.Logger().Info("Temp folder created: %s", tempPath) return tempPath, nil } + +// preferPolicyNode seeds the preferred node with the node hosting the given +// file's storage policy, so data-heavy tasks run where the blob already +// lives. No-op when the URI is invalid, has no entity, or the policy is not +// bound to a node — the pool then falls back to weighted selection. +func preferPolicyNode(ctx context.Context, dep dependency.Dep, state *NodeState, uriStr string) { + if state.NodeID > 0 { + return + } + + uri, err := fs.NewUriFromString(uriStr) + if err != nil { + return + } + + fm := manager.NewFileManager(dep, inventory.UserFromContext(ctx)) + defer fm.Recycle() + + file, err := fm.Get(ctx, uri, dbfs.WithFileEntities()) + if err != nil || file == nil { + return + } + + entity := file.PrimaryEntity() + if entity == nil { + return + } + + policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, entity.PolicyID()) + if err != nil || policy == nil { + return + } + + state.NodeID = policy.NodeID +} diff --git a/pkg/hashid/hash.go b/pkg/hashid/hash.go index 9f93c6e6..d3017b61 100644 --- a/pkg/hashid/hash.go +++ b/pkg/hashid/hash.go @@ -75,76 +75,69 @@ func (e *hashEncoder) Decode(raw string, t int) (int, error) { return res[0], nil } +func encodeID(encoder Encoder, id, t int) string { + res, _ := encoder.Encode([]int{id, t}) + return res +} + // EncodeUserID encode user id to hash id func EncodeUserID(encoder Encoder, uid int) string { - res, _ := encoder.Encode([]int{uid, UserID}) - return res + return encodeID(encoder, uid, UserID) } // EncodeGroupID encode group id to hash id func EncodeGroupID(encoder Encoder, uid int) string { - res, _ := encoder.Encode([]int{uid, GroupID}) - return res + return encodeID(encoder, uid, GroupID) } // EncodePaymentID encode payment id to hash id func EncodePaymentID(encoder Encoder, uid int) string { - res, _ := encoder.Encode([]int{uid, PaymentID}) - return res + return encodeID(encoder, uid, PaymentID) } // EncodeFileID encode file id to hash id func EncodeFileID(encoder Encoder, uid int) string { - res, _ := encoder.Encode([]int{uid, FileID}) - return res + return encodeID(encoder, uid, FileID) } // EncodeAuditLogID encode audit log id to hash id func EncodeAuditLogID(encoder Encoder, uid int) string { - res, _ := encoder.Encode([]int{uid, AuditLogID}) - return res + return encodeID(encoder, uid, AuditLogID) } // EncodeTaskID encode task id to hash id func EncodeTaskID(encoder Encoder, uid int) string { - res, _ := encoder.Encode([]int{uid, TaskID}) - return res + return encodeID(encoder, uid, TaskID) } // EncodeEntityID encode entity id to hash id func EncodeEntityID(encoder Encoder, id int) string { - res, _ := encoder.Encode([]int{id, EntityID}) - return res + return encodeID(encoder, id, EntityID) } // EncodeNodeID encode node id to hash id func EncodeNodeID(encoder Encoder, id int) string { - res, _ := encoder.Encode([]int{id, NodeID}) - return res + return encodeID(encoder, id, NodeID) } -// EncodeEntityID encode policy id to hash id +// EncodePolicyID encode policy id to hash id func EncodePolicyID(encoder Encoder, id int) string { - res, _ := encoder.Encode([]int{id, PolicyID}) - return res + return encodeID(encoder, id, PolicyID) } -// EncodeEntityID encode share id to hash id +// EncodeShareID encode share id to hash id func EncodeShareID(encoder Encoder, id int) string { - res, _ := encoder.Encode([]int{id, ShareID}) - return res + return encodeID(encoder, id, ShareID) } // EncodeDavAccountID encode dav account id to hash id func EncodeDavAccountID(encoder Encoder, id int) string { - res, _ := encoder.Encode([]int{id, DavAccountID}) - return res + return encodeID(encoder, id, DavAccountID) } // EncodeSourceLinkID encode source link id to hash id func EncodeSourceLinkID(encoder Encoder, id int) string { - res, _ := encoder.Encode([]int{id, SourceLinkID}) - return res + return encodeID(encoder, id, SourceLinkID) } func FromContext(c context.Context) int { diff --git a/pkg/mediameta/exif.go b/pkg/mediameta/exif.go index df55c885..b682197e 100644 --- a/pkg/mediameta/exif.go +++ b/pkg/mediameta/exif.go @@ -263,90 +263,51 @@ func (e *exifExtractor) Extract(ctx context.Context, ext string, source entityso return metas, nil } -func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.MediaMeta { - metas := make([]driver.MediaMeta, 0) - if value, ok := exifMap["Artist"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: Artist, - Value: SanitizeMeta(value), - }) - } - - if value, ok := exifMap["Copyright"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: Copyright, - Value: SanitizeString(value), - }) - } - - cameraMode := "" - if value, ok := exifMap["CameraModel"]; ok && !IsUInt(value) { - cameraMode = SanitizeString(value) - } else if value, ok = exifMap["Model"]; ok && !IsUInt(value) { - cameraMode = SanitizeString(value) - } else if value, ok = exifMap["UniqueCameraModel"]; ok && !IsUInt(value) { - cameraMode = SanitizeString(value) - } - if cameraMode != "" { - metas = append(metas, driver.MediaMeta{ - Key: CameraModel, - Value: cameraMode, - }) - } - - cameraMake := "" - if value, ok := exifMap["CameraMake"]; ok && !IsUInt(value) { - cameraMake = SanitizeString(value) - } else if value, ok = exifMap["Make"]; ok && !IsUInt(value) { - cameraMake = SanitizeString(value) - } - if cameraMake != "" { - metas = append(metas, driver.MediaMeta{ - Key: CameraMake, - Value: cameraMake, - }) - } - - if value, ok := exifMap["CameraOwnerName"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: CameraOwnerName, - Value: SanitizeString(value), - }) - } - - if value, ok := exifMap["BodySerialNumber"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: BodySerialNumber, - Value: SanitizeString(value), - }) - } - - if value, ok := exifMap["LensMake"]; ok && !IsUInt(value) { - metas = append(metas, driver.MediaMeta{ - Key: LensMake, - Value: SanitizeString(value), - }) - } +// exifTagTable maps EXIF tag names (in fallback order) to MediaMeta keys. +// skipUint entries ignore pure-integer values, which vendor APIs sometimes +// return instead of the real string value. +var exifTagTable = []struct { + keys []string + metaKey string + clean func(string) string + skipUint bool +}{ + {[]string{"Artist"}, Artist, SanitizeMeta, false}, + {[]string{"Copyright"}, Copyright, SanitizeString, false}, + {[]string{"CameraModel", "Model", "UniqueCameraModel"}, CameraModel, SanitizeString, true}, + {[]string{"CameraMake", "Make"}, CameraMake, SanitizeString, true}, + {[]string{"CameraOwnerName"}, CameraOwnerName, SanitizeString, false}, + {[]string{"BodySerialNumber"}, BodySerialNumber, SanitizeString, false}, + {[]string{"LensMake"}, LensMake, SanitizeString, true}, + {[]string{"LensModel", "Lens"}, LensModel, SanitizeString, true}, + {[]string{"Software"}, Software, SanitizeString, false}, + {[]string{"ISOSpeedRatings"}, ISOSpeedRatings, nil, false}, + {[]string{"PixelXDimension", "ImageWidth"}, PixelXDimension, nil, false}, + {[]string{"PixelYDimension", "ImageLength"}, PixelYDimension, nil, false}, + {[]string{"ImageDescription"}, ImageDescription, SanitizeDescription, false}, + {[]string{"ProjectionType"}, ProjectionType, SanitizeString, false}, +} - lens := "" - if value, ok := exifMap["LensModel"]; ok && !IsUInt(value) { - lens = SanitizeString(value) - } else if value, ok = exifMap["Lens"]; ok && !IsUInt(value) { - lens = SanitizeString(value) - } - if lens != "" { - metas = append(metas, driver.MediaMeta{ - Key: LensModel, - Value: lens, - }) +func extractExifTags(metas []driver.MediaMeta, exifMap map[string]string, from, to int) []driver.MediaMeta { + for _, e := range exifTagTable[from:to] { + for _, k := range e.keys { + v, ok := exifMap[k] + if !ok || (e.skipUint && IsUInt(v)) { + continue + } + if e.clean != nil { + v = e.clean(v) + } + metas = append(metas, driver.MediaMeta{Key: e.metaKey, Value: v}) + break + } } + return metas +} - if value, ok := exifMap["Software"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: Software, - Value: SanitizeString(value), - }) - } +func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.MediaMeta { + metas := make([]driver.MediaMeta, 0) + metas = extractExifTags(metas, exifMap, 0, 9) if value, ok := exifMap["ExposureTime"]; ok { value = strings.TrimSuffix(value, " sec.") @@ -427,38 +388,9 @@ func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.Media }) } - if value, ok := exifMap["ISOSpeedRatings"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: ISOSpeedRatings, - Value: value, - }) - } + metas = extractExifTags(metas, exifMap, 9, 10) - width := "" - if value, ok := exifMap["PixelXDimension"]; ok { - width = value - } else if value, ok := exifMap["ImageWidth"]; ok { - width = value - } - if width != "" { - metas = append(metas, driver.MediaMeta{ - Key: PixelXDimension, - Value: width, - }) - } - - height := "" - if value, ok := exifMap["PixelYDimension"]; ok { - height = value - } else if value, ok := exifMap["ImageLength"]; ok { - height = value - } - if height != "" { - metas = append(metas, driver.MediaMeta{ - Key: PixelYDimension, - Value: height, - }) - } + metas = extractExifTags(metas, exifMap, 10, 12) orientation := "1" if value, ok := exifMap["Orientation"]; ok { @@ -498,19 +430,7 @@ func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.Media }) } - if value, ok := exifMap["ImageDescription"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: ImageDescription, - Value: SanitizeDescription(value), - }) - } - - if value, ok := exifMap["ProjectionType"]; ok { - metas = append(metas, driver.MediaMeta{ - Key: ProjectionType, - Value: SanitizeString(value), - }) - } + metas = extractExifTags(metas, exifMap, 12, 14) return metas } diff --git a/pkg/mediameta/exif_extract_test.go b/pkg/mediameta/exif_extract_test.go new file mode 100644 index 00000000..19a8cd27 --- /dev/null +++ b/pkg/mediameta/exif_extract_test.go @@ -0,0 +1,98 @@ +package mediameta + +import ( + "testing" + "time" + + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver" + "github.com/stretchr/testify/assert" +) + +func findMeta(metas []driver.MediaMeta, key string) (driver.MediaMeta, bool) { + for _, m := range metas { + if m.Key == key { + return m, true + } + } + return driver.MediaMeta{}, false +} + +func TestExtractExifMap(t *testing.T) { + a := assert.New(t) + metas := ExtractExifMap(map[string]string{ + "Artist": "ann", + "CameraModel": "12345", // pure-uint vendor garbage, must fall back + "Model": "X-T5", + "CameraMake": "Fuji", + "LensModel": "9999", // uint, falls back + "Lens": "XF35", + "Software": "darktable", + "PixelXDimension": "6000", + "ImageLength": "4000", + "ImageDescription": "desc", + "ProjectionType": "equirectangular", + "DateTimeOriginal": "2020:01:02 03:04:05", + "ISOSpeedRatings": "200", + "Flash": "1", + "FNumber": "28/10", + "Orientation": "6", + "ImageWidth": "9999", // shadowed by PixelXDimension + }, time.Time{}) + + v, ok := findMeta(metas, CameraModel) + a.True(ok) + a.Equal("X-T5", v.Value) + + v, ok = findMeta(metas, LensModel) + a.True(ok) + a.Equal("XF35", v.Value) + + v, ok = findMeta(metas, PixelXDimension) + a.True(ok) + a.Equal("6000", v.Value) + + v, ok = findMeta(metas, PixelYDimension) + a.True(ok) + a.Equal("4000", v.Value) + + v, ok = findMeta(metas, Orientation) + a.True(ok) + a.Equal("6", v.Value) + + v, ok = findMeta(metas, TakenAt) + a.True(ok) + a.Equal("2020-01-02T03:04:05Z", v.Value) + + v, ok = findMeta(metas, Flash) + a.True(ok) + a.Equal("1", v.Value) + + v, ok = findMeta(metas, FNumber) + a.True(ok) + a.Equal("2.800000", v.Value) + + v, ok = findMeta(metas, ImageDescription) + a.True(ok) + a.Equal("desc", v.Value) + + v, ok = findMeta(metas, ProjectionType) + a.True(ok) + a.Equal("equirectangular", v.Value) + + _, ok = findMeta(metas, Copyright) + a.False(ok) +} + +func TestExtractExifMapDefaults(t *testing.T) { + a := assert.New(t) + metas := ExtractExifMap(map[string]string{}, time.Time{}) + + // Orientation is always emitted, defaulting to "1". + v, ok := findMeta(metas, Orientation) + a.True(ok) + a.Equal("1", v.Value) + + // No date tags and zero GPS time -> no TakenAt. + _, ok = findMeta(metas, TakenAt) + a.False(ok) +} diff --git a/pkg/mediameta/gps.go b/pkg/mediameta/gps.go new file mode 100644 index 00000000..a2fb1b6f --- /dev/null +++ b/pkg/mediameta/gps.go @@ -0,0 +1,65 @@ +package mediameta + +import ( + "fmt" + "math" + "strconv" + "strings" + + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver" +) + +// DMSDecimal converts degree/minute/second GPS components to a signed +// decimal degree; negative flips the sign for south/west references. +func DMSDecimal(deg, minutes, seconds float64, negative bool) float64 { + decimal := deg + minutes/60.0 + seconds/3600.0 + if negative { + return -decimal + } + return decimal +} + +// GpsRationalElem parses one rational GPS element of the form "n/d" into a +// decimal value. Returns 0 for malformed or zero-denominator elements. +func GpsRationalElem(elm string) float64 { + elements := strings.Split(elm, "/") + if len(elements) != 2 { + return 0 + } + + numerator, err := strconv.ParseFloat(elements[0], 64) + if err != nil { + return 0 + } + + denominator, err := strconv.ParseFloat(elements[1], 64) + if err != nil || denominator == 0 { + return 0 + } + + return numerator / denominator +} + +// GpsMeta builds the latitude/longitude MediaMeta pair from raw EXIF parts. +// parse converts a raw coordinate string and its hemisphere reference into +// decimal degrees; drivers pass their vendor-specific parser. +func GpsMeta(latitude, longitude, latRef, lonRef string, parse func(raw, ref string) float64) []driver.MediaMeta { + if latitude == "" || longitude == "" || latRef == "" || lonRef == "" { + return nil + } + + lat := parse(latitude, latRef) + lon := parse(longitude, lonRef) + if math.IsNaN(lat) || math.IsNaN(lon) { + return nil + } + + latN, lngN := NormalizeGPS(lat, lon) + return []driver.MediaMeta{{ + Key: GpsLat, + Value: fmt.Sprintf("%f", latN), + }, { + Key: GpsLng, + Value: fmt.Sprintf("%f", lngN), + }} +} diff --git a/pkg/queue/queue.go b/pkg/queue/queue.go index b47a90c1..efe6189c 100644 --- a/pkg/queue/queue.go +++ b/pkg/queue/queue.go @@ -23,6 +23,10 @@ type ( Shutdown() // SubmitTask submits a Task to the queue. QueueTask(ctx context.Context, t Task) error + // CancelTask marks a queued or suspending task as canceled. The + // scheduler entry is left in place; workers skip canceled tasks at + // pickup. Returns false if the task is not in this queue's registry. + CancelTask(ctx context.Context, taskID int) bool // BusyWorkers returns the numbers of workers in the running process. BusyWorkers() int // BusyWorkers returns the numbers of success tasks. @@ -218,6 +222,22 @@ func (q *queue) newContext(t Task) context.Context { return ctx } +// CancelTask marks a queued or suspending task as canceled. The scheduler +// heap entry is not removed; workers skip canceled tasks at pickup. +func (q *queue) CancelTask(ctx context.Context, taskID int) bool { + if q.registry == nil { + return false + } + t, found := q.registry.Get(taskID) + if !found { + return false + } + if t.Status() != task.StatusQueued && t.Status() != task.StatusSuspending { + return false + } + return q.transitStatus(ctx, t, task.StatusCanceled) == nil +} + func (q *queue) work(t Task) { ctx := q.newContext(t) l := logging.FromContext(ctx) @@ -238,6 +258,11 @@ func (q *queue) work(t Task) { q.schedule() }() + // Canceled while waiting in the scheduler; nothing to run. + if t.Status() == task.StatusCanceled { + return + } + err = q.transitStatus(ctx, t, task.StatusProcessing) if err != nil { l.Error("failed to transit task %d to processing: %s", t.ID(), err.Error()) diff --git a/pkg/queue/task.go b/pkg/queue/task.go index 0a2d9fc9..a648efbe 100644 --- a/pkg/queue/task.go +++ b/pkg/queue/task.go @@ -104,6 +104,7 @@ const ( RelocateTaskType = "relocate" RemoteDownloadTaskType = "remote_download" ImportTaskType = "import" + BlobAuditTaskType = "blob_audit" FullTextIndexTaskType = "full_text_index" FullTextCopyTaskType = "full_text_copy" @@ -393,6 +394,12 @@ func init() { q.metric.IncFailureTask() return persistTask(ctx, task, newStatus, q) }, + task.StatusCanceled: func(ctx context.Context, task Task, newStatus task.Status, q *queue) error { + if q.registry != nil { + q.registry.Delete(task.ID()) + } + return persistTask(ctx, task, newStatus, q) + }, }, task.StatusProcessing: { task.StatusQueued: persistTask, @@ -468,6 +475,18 @@ func init() { q.metric.IncFailureTask() return persistTask(ctx, task, newStatus, q) }, + task.StatusCanceled: func(ctx context.Context, task Task, newStatus task.Status, q *queue) error { + q.metric.DecSuspendingTask() + if q.registry != nil { + q.registry.Delete(task.ID()) + } + return persistTask(ctx, task, newStatus, q) + }, + }, + task.StatusError: { + // Manual retry re-queues a failed task with its original args + // (#2823). RetryCount history is preserved in PublicState. + task.StatusQueued: persistTask, }, } diff --git a/pkg/request/options.go b/pkg/request/options.go index e5b5d844..5b1693f6 100644 --- a/pkg/request/options.go +++ b/pkg/request/options.go @@ -20,6 +20,7 @@ type options struct { header http.Header sign auth.Auth signTTL int64 + signBaseTime time.Time ctx context.Context contentLength int64 masterMeta bool @@ -88,6 +89,15 @@ func WithCredential(instance auth.Auth, ttl int64) Option { }) } +// WithSignBaseTime overrides the "now" used to compute the signature expiry +// timestamp. Slave clients use it to compensate clock skew against the master +// once the offset is learned from a signature-expired response (#3242). +func WithSignBaseTime(t time.Time) Option { + return optionFunc(func(o *options) { + o.signBaseTime = t + }) +} + // WithHeader 设置请求Header func WithHeader(header http.Header) Option { return optionFunc(func(o *options) { diff --git a/pkg/request/request.go b/pkg/request/request.go index d987814b..38c2e9cb 100644 --- a/pkg/request/request.go +++ b/pkg/request/request.go @@ -21,9 +21,6 @@ import ( "github.com/samber/lo" ) -// GeneralClient 通用 HTTP Client -var GeneralClient Client = NewClientDeprecated() - const ( CorrelationHeader = constants.CrHeaderPrefix + "Correlation-Id" SiteURLHeader = constants.CrHeaderPrefix + "Site-Url" @@ -72,20 +69,6 @@ func NewClient(config conf.ConfigProvider, opts ...Option) Client { return client } -// Deprecated -func NewClientDeprecated(opts ...Option) Client { - client := &HTTPClient{ - options: newDefaultOption(), - tpsLimiter: globalTPSLimiter, - } - - for _, o := range opts { - o.apply(client.options) - } - - return client -} - func (c *HTTPClient) Apply(opts ...Option) { for _, o := range opts { o.apply(c.options) @@ -176,7 +159,11 @@ func (c *HTTPClient) Request(method, target string, body io.Reader, opts ...Opti if options.ctx == nil { ctx = context.Background() } - expire := time.Now().Add(time.Second * time.Duration(options.signTTL)) + expireBase := options.signBaseTime + if expireBase.IsZero() { + expireBase = time.Now() + } + expire := expireBase.Add(time.Second * time.Duration(options.signTTL)) switch method { case "PUT", "POST", "PATCH": auth.SignRequest(ctx, options.sign, req, &expire) diff --git a/pkg/request/request_test.go b/pkg/request/request_test.go index ce507d09..07e28083 100644 --- a/pkg/request/request_test.go +++ b/pkg/request/request_test.go @@ -6,9 +6,12 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/auth" "github.com/stretchr/testify/assert" testMock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "io" "io/ioutil" "net/http" + "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -53,7 +56,7 @@ func TestWithContext(t *testing.T) { func TestHTTPClient_Request(t *testing.T) { asserts := assert.New(t) - client := NewClientDeprecated(WithSlaveMeta(1)) + client := NewClient(nil, WithSlaveMeta(1)) // 正常 { @@ -236,7 +239,7 @@ func TestBlackHole(t *testing.T) { func TestHTTPClient_TPSLimit(t *testing.T) { a := assert.New(t) - client := NewClientDeprecated() + client := NewClient(nil) finished := make(chan struct{}) go func() { @@ -271,3 +274,43 @@ func TestHTTPClient_TPSLimit(t *testing.T) { } } + +func TestWithSignBaseTime(t *testing.T) { + asserts := assert.New(t) + options := newDefaultOption() + asserts.True(options.signBaseTime.IsZero()) + + future := time.Now().Add(time.Hour) + WithSignBaseTime(future).apply(options) + asserts.Equal(future, options.signBaseTime) +} + +func TestSignBaseTimeShiftsExpiry(t *testing.T) { + asserts := assert.New(t) + + var authHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer srv.Close() + + client := NewClient(nil, WithEndpoint(srv.URL)) + base := time.Now().Add(2 * time.Hour) + resp := client.Request( + "POST", + "/upload", + strings.NewReader("{}"), + WithCredential(auth.HMACAuth{SecretKey: []byte("k")}, 60), + WithSignBaseTime(base), + ) + asserts.NoError(resp.Err) + + // Authorization: Cr : + parts := strings.Split(authHeader, ":") + require.GreaterOrEqual(t, len(parts), 2) + expires, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) + asserts.NoError(err) + asserts.InDelta(base.Add(60*time.Second).Unix(), expires, 5) +} diff --git a/pkg/serializer/error.go b/pkg/serializer/error.go index bb030a43..bccbfac5 100644 --- a/pkg/serializer/error.go +++ b/pkg/serializer/error.go @@ -285,7 +285,7 @@ const ( CodeNotSet = -1 ) -// DBErrDeprecated 数据库操作失败 +// DBErr 数据库操作失败 func DBErr(c context.Context, msg string, err error) Response { if msg == "" { msg = "Database operation failed." @@ -293,14 +293,6 @@ func DBErr(c context.Context, msg string, err error) Response { return ErrWithDetails(c, CodeDBError, msg, err) } -// DBErrDeprecated 数据库操作失败 -func DBErrDeprecated(msg string, err error) Response { - if msg == "" { - msg = "Database operation failed." - } - return ErrDeprecated(CodeDBError, msg, err) -} - // ParamErr 各种参数错误 func ParamErr(c context.Context, msg string, err error) Response { if msg == "" { @@ -309,36 +301,6 @@ func ParamErr(c context.Context, msg string, err error) Response { return ErrWithDetails(c, CodeParamErr, msg, err) } -// ParamErrDeprecated 各种参数错误 -// Deprecated -func ParamErrDeprecated(msg string, err error) Response { - if msg == "" { - msg = "Invalid parameters." - } - return ErrDeprecated(CodeParamErr, msg, err) -} - -// ErrDeprecated 通用错误处理 -func ErrDeprecated(errCode int, msg string, err error) Response { - // 底层错误是AppError,则尝试从AppError中获取详细信息 - var appError AppError - if errors.As(err, &appError) { - errCode = appError.Code - err = appError.RawError - msg = appError.Msg - } - - res := Response{ - Code: errCode, - Msg: msg, - } - // 生产环境隐藏底层报错 - if err != nil && gin.Mode() != gin.ReleaseMode { - res.Error = err.Error() - } - return res -} - // ErrWithDetails 通用错误处理 func ErrWithDetails(c context.Context, errCode int, msg string, err error) Response { res := Response{ diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index d0878a94..c547f9a8 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -246,6 +246,12 @@ type ( SSO(ctx context.Context) *SSO // EmailFilter returns the sign-up email restriction settings. EmailFilter(ctx context.Context) *EmailFilter + // ShareDefaults returns the site-wide share defaults applied when a + // user has not overridden them in their personal settings. + ShareDefaults(ctx context.Context) *ShareDefaults + // DownloadCDNRoutes returns the configured alternative download + // endpoints users can pick from (e.g. CDN mirrors of the site). + DownloadCDNRoutes(ctx context.Context) []CDNRoute } UseFirstSiteUrlCtxKey = struct{} ) @@ -905,6 +911,7 @@ func (s *settingProvider) SSO(ctx context.Context) *SSO { ClientSecret: s.getString(ctx, "sso_client_secret", ""), Scopes: strings.Join(scopeList, " "), RegisterEnabled: s.getBoolean(ctx, "sso_register_enabled", true), + AutoRedirect: s.getBoolean(ctx, "sso_auto_redirect", false), } } @@ -929,6 +936,7 @@ func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter { Mode: mode, List: list, DisableSubAddress: s.getBoolean(ctx, "email_disable_subaddress", false), + SubAddressChars: s.getString(ctx, "email_subaddress_chars", "+"), } } @@ -936,6 +944,58 @@ func (s *settingProvider) ExposeUserEmail(ctx context.Context) bool { return s.getBoolean(ctx, "expose_user_email", true) } +// ShareDefaults holds the site-wide share defaults (#3390). +type ShareDefaults struct { + // LinksInProfile is the default profile share visibility applied when a + // user's own share_links_in_profile is unset. + LinksInProfile types.ShareLinksInProfileLevel + // PrivateByDefault makes new shares default to private (random password). + PrivateByDefault bool +} + +// CDNRoute is an alternative download endpoint offered to users when +// downloading files, e.g. a CDN mirror fronting the site. +type CDNRoute struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func (s *settingProvider) DownloadCDNRoutes(ctx context.Context) []CDNRoute { + raw := s.getString(ctx, "download_cdn_routes", "") + routes := make([]CDNRoute, 0) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + name, u, found := strings.Cut(line, "=") + if !found { + continue + } + u = strings.TrimRight(strings.TrimSpace(u), "/") + parsed, err := url.Parse(u) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + continue + } + routes = append(routes, CDNRoute{Name: strings.TrimSpace(name), URL: u}) + } + return routes +} + +func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults { + level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", "")) + switch level { + case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare: + default: + level = types.ProfilePublicShareOnly + } + return &ShareDefaults{ + LinksInProfile: level, + PrivateByDefault: s.getBoolean(ctx, "share_default_private", false), + } +} + func (s *settingProvider) SiteBasic(ctx context.Context) *SiteBasic { return &SiteBasic{ Name: s.getString(ctx, "siteName", ""), diff --git a/pkg/setting/provider_test.go b/pkg/setting/provider_test.go new file mode 100644 index 00000000..9645b4bf --- /dev/null +++ b/pkg/setting/provider_test.go @@ -0,0 +1,37 @@ +package setting + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type stubAdapter map[string]any + +func (s stubAdapter) Get(_ context.Context, name string, defaultVal any) any { + if v, ok := s[name]; ok { + return v + } + return defaultVal +} + +func TestDownloadCDNRoutes(t *testing.T) { + ctx := context.Background() + + p := NewProvider(stubAdapter{}) + require.Empty(t, p.DownloadCDNRoutes(ctx)) + + p = NewProvider(stubAdapter{ + "download_cdn_routes": "Line 1=https://cdn1.example.com/\n\n" + + "not-a-route\n" + + "cdn2 = https://cdn2.example.com/base/\n" + + "bad=ftp://example.com\n" + + "also-bad=notaurl", + }) + routes := p.DownloadCDNRoutes(ctx) + require.Equal(t, []CDNRoute{ + {Name: "Line 1", URL: "https://cdn1.example.com"}, + {Name: "cdn2", URL: "https://cdn2.example.com/base"}, + }, routes) +} diff --git a/pkg/setting/types.go b/pkg/setting/types.go index 67d3aece..32fd7730 100644 --- a/pkg/setting/types.go +++ b/pkg/setting/types.go @@ -82,6 +82,7 @@ type SSO struct { ClientSecret string Scopes string RegisterEnabled bool + AutoRedirect bool } type EmailFilterMode int @@ -97,6 +98,9 @@ type EmailFilter struct { Mode EmailFilterMode List []string DisableSubAddress bool + // SubAddressChars are the characters treated as sub-address separators in + // the local part when DisableSubAddress is on. Empty falls back to "+". + SubAddressChars string } type DBFS struct { @@ -244,6 +248,9 @@ type CustomNavItem struct { Icon string `json:"icon"` Name string `json:"name"` URL string `json:"url"` + // Scope limits display: "" or "public" shows to everyone, "user" only + // to logged-in accounts (#3578). + Scope string `json:"scope,omitempty"` } type CustomHTML struct { diff --git a/pkg/thumb/builtin.go b/pkg/thumb/builtin.go index 0f6be179..2503769e 100644 --- a/pkg/thumb/builtin.go +++ b/pkg/thumb/builtin.go @@ -77,8 +77,8 @@ func NewThumbFromFile(file io.Reader, ext string) (*Thumb, error) { }, nil } -// GetThumb 生成给定最大尺寸的缩略图 -func (image *Thumb) GetThumb(width, height uint) { +// Resize 生成给定最大尺寸的缩略图(就地修改) +func (image *Thumb) Resize(width, height uint) { //image.src = resize.Thumbnail(width, height, image.src, resize.Lanczos3) image.src = Thumbnail(width, height, image.src) } @@ -170,7 +170,7 @@ func (b Builtin) Generate(ctx context.Context, es entitysource.EntitySource, ext } w, h := b.settings.ThumbSize(ctx) - img.GetThumb(uint(w), uint(h)) + img.Resize(uint(w), uint(h)) tempPath := filepath.Join( util.DataPath(b.settings.TempPath(ctx)), thumbTempFolder, diff --git a/pkg/thumb/ffmpeg.go b/pkg/thumb/ffmpeg.go index e0742509..a520572f 100644 --- a/pkg/thumb/ffmpeg.go +++ b/pkg/thumb/ffmpeg.go @@ -30,7 +30,7 @@ type FfmpegGenerator struct { } func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) { - if !util.IsInExtensionListExt(f.settings.FFMpegThumbExts(ctx), ext) { + if !util.IsExtInList(f.settings.FFMpegThumbExts(ctx), ext) { return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough) } @@ -94,7 +94,7 @@ func (f *FfmpegGenerator) Generate(ctx context.Context, es entitysource.EntitySo if err := cmd.Run(); err != nil { f.l.Warning("Failed to invoke ffmpeg: %s", stdErr.String()) - return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke ffmpeg: %w, raw output: %s", err, stdErr.String()) + return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke ffmpeg: %w, raw output: %s: %w", err, stdErr.String(), ErrPassThrough) } return &Result{Path: tempOutputPath}, nil diff --git a/pkg/thumb/libraw.go b/pkg/thumb/libraw.go index e43ba8b3..fa2bab64 100644 --- a/pkg/thumb/libraw.go +++ b/pkg/thumb/libraw.go @@ -30,7 +30,7 @@ type LibRawGenerator struct { } func (l *LibRawGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) { - if !util.IsInExtensionListExt(l.settings.LibRawThumbExts(ctx), ext) { + if !util.IsExtInList(l.settings.LibRawThumbExts(ctx), ext) { return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough) } diff --git a/pkg/thumb/libreoffice.go b/pkg/thumb/libreoffice.go index 89fbdef4..d73c10ee 100644 --- a/pkg/thumb/libreoffice.go +++ b/pkg/thumb/libreoffice.go @@ -27,7 +27,7 @@ type LibreOfficeGenerator struct { } func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) { - if !util.IsInExtensionListExt(l.settings.LibreOfficeThumbExts(ctx), ext) { + if !util.IsExtInList(l.settings.LibreOfficeThumbExts(ctx), ext) { return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough) } @@ -79,7 +79,7 @@ func (l *LibreOfficeGenerator) Generate(ctx context.Context, es entitysource.Ent if err := cmd.Run(); err != nil { l.l.Warning("Failed to invoke LibreOffice: %s", stdErr.String()) - return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke LibreOffice: %w, raw output: %s", err, stdErr.String()) + return &Result{Path: tempOutputPath}, fmt.Errorf("failed to invoke LibreOffice: %w, raw output: %s: %w", err, stdErr.String(), ErrPassThrough) } return &Result{ diff --git a/pkg/thumb/music.go b/pkg/thumb/music.go index 4947336c..6520d814 100644 --- a/pkg/thumb/music.go +++ b/pkg/thumb/music.go @@ -23,7 +23,7 @@ type MusicCoverGenerator struct { } func (v *MusicCoverGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) { - if !util.IsInExtensionListExt(v.settings.MusicCoverThumbExts(ctx), ext) { + if !util.IsExtInList(v.settings.MusicCoverThumbExts(ctx), ext) { return nil, fmt.Errorf("unsupported music format: %w", ErrPassThrough) } diff --git a/pkg/thumb/vips.go b/pkg/thumb/vips.go index bd344fd2..d2a3e443 100644 --- a/pkg/thumb/vips.go +++ b/pkg/thumb/vips.go @@ -28,7 +28,7 @@ type VipsGenerator struct { } func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) { - if !util.IsInExtensionListExt(v.settings.VipsThumbExts(ctx), ext) { + if !util.IsExtInList(v.settings.VipsThumbExts(ctx), ext) { return nil, fmt.Errorf("unsupported video format: %w", ErrPassThrough) } @@ -105,7 +105,10 @@ func (v *VipsGenerator) Generate(ctx context.Context, es entitysource.EntitySour if err := cmd.Run(); err != nil { v.l.Warning("Failed to invoke vips: %s", vipsErr.String()) - return &Result{Path: tempPath}, fmt.Errorf("failed to invoke vips: %w, raw output: %s", err, vipsErr.String()) + // A non-zero exit usually means vips cannot decode the format (e.g. + // psd, html). Pass through so later generators can try instead of + // failing the whole pipeline. + return &Result{Path: tempPath}, fmt.Errorf("failed to invoke vips: %w, raw output: %s: %w", err, vipsErr.String(), ErrPassThrough) } return &Result{Path: tempPath}, nil diff --git a/pkg/util/common.go b/pkg/util/common.go index 3791d7de..50dd56ca 100644 --- a/pkg/util/common.go +++ b/pkg/util/common.go @@ -3,19 +3,12 @@ package util import ( "context" cryptoRand "crypto/rand" - "fmt" "math/big" "math/rand" - "path" - "path/filepath" - "regexp" - "strconv" "strings" "time" - "unicode/utf8" "github.com/gin-gonic/gin" - "github.com/gofrs/uuid" ) func init() { @@ -85,7 +78,7 @@ func IsInExtensionList(extList []string, fileName string) bool { } // IsInExtensionList 返回文件的扩展名是否在给定的列表范围内 -func IsInExtensionListExt(extList []string, ext string) bool { +func IsExtInList(extList []string, ext string) bool { // 无扩展名时 if len(ext) == 0 { return false @@ -116,102 +109,6 @@ func Replace(table map[string]string, s string) string { return s } -// ReplaceMagicVar 动态替换字符串中的魔法变量 -func ReplaceMagicVar(rawString string, fsSeparator string, pathAvailable bool, blobAvailable bool, - timeConst time.Time, userId int, originName string, originPath string, completeBlobPath string) string { - re := regexp.MustCompile(`\{[^{}]+\}`) - return re.ReplaceAllStringFunc(rawString, func(match string) string { - switch match { - case "{randomkey16}": - return RandStringRunes(16) - case "{randomkey8}": - return RandStringRunes(8) - case "{timestamp}": - return strconv.FormatInt(timeConst.Unix(), 10) - case "{timestamp_nano}": - return strconv.FormatInt(timeConst.UnixNano(), 10) - case "{randomnum2}": - return strconv.Itoa(rand.Intn(2)) - case "{randomnum3}": - return strconv.Itoa(rand.Intn(3)) - case "{randomnum4}": - return strconv.Itoa(rand.Intn(4)) - case "{randomnum8}": - return strconv.Itoa(rand.Intn(8)) - case "{uid}": - return strconv.Itoa(userId) - case "{datetime}": - return timeConst.Format("20060102150405") - case "{date}": - return timeConst.Format("20060102") - case "{year}": - return timeConst.Format("2006") - case "{month}": - return timeConst.Format("01") - case "{day}": - return timeConst.Format("02") - case "{hour}": - return timeConst.Format("15") - case "{minute}": - return timeConst.Format("04") - case "{second}": - return timeConst.Format("05") - case "{uuid}": - return uuid.Must(uuid.NewV4()).String() - case "{ext}": - return filepath.Ext(originName) - case "{originname}": - return originName - case "{originname_without_ext}": - return strings.TrimSuffix(originName, filepath.Ext(originName)) - case "{path}": - if pathAvailable { - return originPath + fsSeparator - } - return match - case "{blob_name}": - if blobAvailable { - return filepath.Base(completeBlobPath) - } - return match - case "{blob_name_without_ext}": - if blobAvailable { - return strings.TrimSuffix(filepath.Base(completeBlobPath), filepath.Ext(completeBlobPath)) - } - return match - case "{blob_path}": - if blobAvailable { - return path.Dir(completeBlobPath) + fsSeparator - } - return match - default: - return match - } - }) -} - -// BuildRegexp 构建用于SQL查询用的多条件正则 -func BuildRegexp(search []string, prefix, suffix, condition string) string { - var res string - for key, value := range search { - res += prefix + regexp.QuoteMeta(value) + suffix - if key < len(search)-1 { - res += condition - } - } - return res -} - -// BuildConcat 根据数据库类型构建字符串连接表达式 -func BuildConcat(str1, str2 string, DBType string) string { - switch DBType { - case "mysql": - return "CONCAT(" + str1 + "," + str2 + ")" - default: - return str1 + "||" + str2 - } -} - // SliceIntersect 求两个切片交集 func SliceIntersect(slice1, slice2 []string) []string { m := make(map[string]int) @@ -260,101 +157,6 @@ func BoolToString(b bool) string { return "0" } -func EncodeTimeFlowString(str string, timeNow int64) string { - timeNow = timeNow / 1000 - timeDigits := []int{} - timeDigitIndex := 0 - - if len(str) == 0 { - return "" - } - - str = fmt.Sprintf("%d|%s", timeNow, str) - - res := make([]int32, 0, utf8.RuneCountInString(str)) - for timeNow > 0 { - timeDigits = append(timeDigits, int(timeNow%int64(10))) - timeNow = timeNow / 10 - } - - add := false - for pos, rune := range str { - // take single digit with index timeDigitIndex from timeNow - newIndex := pos - if add { - newIndex = pos + timeDigits[timeDigitIndex]*timeDigitIndex - } else { - newIndex = 2*timeDigitIndex*timeDigits[timeDigitIndex] - pos - } - - if newIndex < 0 { - newIndex = newIndex * -1 - } - - res = append(res, rune) - newIndex = newIndex % len(res) - - res[newIndex], res[len(res)-1] = res[len(res)-1], res[newIndex] - - add = !add - // Add timeDigitIndex by 1, but does not exceed total digits in timeNow - timeDigitIndex++ - timeDigitIndex = timeDigitIndex % len(timeDigits) - } - - return string(res) -} - -func DecodeTimeFlowStringTime(str string, timeNow int64) string { - timeNow = timeNow / 1000 - timeDigits := []int{} - - if len(str) == 0 { - return "" - } - - for timeNow > 0 { - timeDigits = append(timeDigits, int(timeNow%int64(10))) - timeNow = timeNow / 10 - } - - res := make([]int32, utf8.RuneCountInString(str)) - secret := []rune(str) - add := false - if len(secret)%2 == 0 { - add = true - } - timeDigitIndex := (len(secret) - 1) % len(timeDigits) - for pos := range secret { - // take single digit with index timeDigitIndex from timeNow - newIndex := len(res) - 1 - pos - if add { - newIndex = newIndex + timeDigits[timeDigitIndex]*timeDigitIndex - } else { - newIndex = 2*timeDigitIndex*timeDigits[timeDigitIndex] - newIndex - } - - if newIndex < 0 { - newIndex = newIndex * -1 - } - - newIndex = newIndex % len(secret) - - res[len(res)-1-pos] = secret[newIndex] - secret[newIndex], secret[len(res)-1-pos] = secret[len(res)-1-pos], secret[newIndex] - secret = secret[:len(secret)-1] - - add = !add - // Add timeDigitIndex by 1, but does not exceed total digits in timeNow - timeDigitIndex-- - if timeDigitIndex < 0 { - timeDigitIndex = len(timeDigits) - 1 - } - } - - return string(res) -} - func ToPtr[T any](v T) *T { return &v } diff --git a/pkg/util/common_test.go b/pkg/util/common_test.go index ae4c47fd..68905879 100644 --- a/pkg/util/common_test.go +++ b/pkg/util/common_test.go @@ -56,19 +56,6 @@ func TestReplace(t *testing.T) { }, "123origin123")) } -func TestBuildRegexp(t *testing.T) { - asserts := assert.New(t) - - asserts.Equal("^/dir/", BuildRegexp([]string{"/dir"}, "^", "/", "|")) - asserts.Equal("^/dir/|^/dir/di\\*r/", BuildRegexp([]string{"/dir", "/dir/di*r"}, "^", "/", "|")) -} - -func TestBuildConcat(t *testing.T) { - asserts := assert.New(t) - asserts.Equal("CONCAT(1,2)", BuildConcat("1", "2", "mysql")) - asserts.Equal("1||2", BuildConcat("1", "2", "sqlite")) -} - func TestSliceDifference(t *testing.T) { asserts := assert.New(t) diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go index 78ec8027..0e3ab4b6 100644 --- a/pkg/webdav/webdav.go +++ b/pkg/webdav/webdav.go @@ -33,7 +33,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-gonic/gin" "github.com/samber/lo" - "golang.org/x/tools/container/intsets" + "math" ) const ( @@ -41,7 +41,13 @@ const ( ) func stripPrefix(p string, u *ent.User) (string, *fs.URI, int, error) { - base, err := fs.NewUriFromString(u.Edges.DavAccounts[0].URI) + // Bearer-authenticated users carry no dav account; mount them at their + // "my" root. Basic-auth users keep their configured mount point (#3548). + baseUri := fs.NewMyUri("") + if len(u.Edges.DavAccounts) > 0 { + baseUri = u.Edges.DavAccounts[0].URI + } + base, err := fs.NewUriFromString(baseUri) if err != nil { return "", nil, http.StatusInternalServerError, err } @@ -190,7 +196,29 @@ func confirmLock(c *gin.Context, fm manager.FileManager, user *ent.User, srcAnc, return nil, nil, http.StatusPreconditionFailed, ErrLocked } +// davWriteForbidden reports whether the user is confined to read-only WebDAV +// access, either by a group-level restriction or a read-only dav account. +// Read-only access allows GET/HEAD/OPTIONS/PROPFIND and rejects every +// state-changing method. +func davWriteForbidden(user *ent.User) bool { + if user == nil { + return false + } + if len(user.Edges.DavAccounts) > 0 && + user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) { + return true + } + if user.Edges.Group != nil && + user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) { + return true + } + return false +} + func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -227,6 +255,9 @@ func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status } func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -237,7 +268,8 @@ func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status i return purposeStatusCodeFromError(err), err } - if user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountDisableSysFiles)) { + if len(user.Edges.DavAccounts) > 0 && + user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountDisableSysFiles)) { if strings.HasPrefix(reqPath.Name(), ".") { return http.StatusMethodNotAllowed, nil } @@ -439,6 +471,12 @@ func handleRangedPut(ctx context.Context, c *gin.Context, user *ent.User, m mana return http.StatusInternalServerError, err } if !allReceived { + // If the session record vanished mid-upload, coverage can never + // complete — fail so the client retries rather than leaving a stuck + // placeholder. + if _, ok := kv.Get(manager.UploadSessionCachePrefix + sessionKey); !ok { + return http.StatusConflict, errors.New("upload session expired") + } return http.StatusCreated, nil } @@ -466,7 +504,7 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat if target, _, err := fm.SharedAddressTranslation(c, reqPath); err == nil { allow = allow[:1] read, update, del, create := true, true, true, true - if target.OwnerID() != user.ID { + if target.OwnerID() != user.ID || davWriteForbidden(user) { update = false del = false create = false @@ -475,7 +513,10 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat allow = append(allow, "DELETE", "MOVE") } if read { - allow = append(allow, "COPY", "PROPFIND") + if !davWriteForbidden(user) { + allow = append(allow, "COPY") + } + allow = append(allow, "PROPFIND") if target.Type() == types.FileTypeFile { allow = append(allow, "GET", "HEAD", "POST") } @@ -526,7 +567,8 @@ func handleGetHeadPost(c *gin.Context, user *ent.User, fm manager.FileManager) ( es.Apply(entitysource.WithSpeedLimit(int64(user.Edges.Group.SpeedLimit))) if es.ShouldInternalProxy() || - (user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountProxy)) && + (len(user.Edges.DavAccounts) > 0 && + user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountProxy)) && user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVProxy))) { es.Serve(c.Writer, c.Request) } else { @@ -559,6 +601,9 @@ func handleUnlock(c *gin.Context, user *ent.User, fm manager.FileManager) (retSt } func handleLock(c *gin.Context, user *ent.User, fm manager.FileManager) (retStatus int, retErr error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } duration, err := parseTimeout(c.Request.Header.Get("Timeout")) if err != nil { return http.StatusBadRequest, err @@ -732,6 +777,9 @@ func handlePropfind(c *gin.Context, user *ent.User, fm manager.FileManager) (sta } func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -759,6 +807,9 @@ func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (statu } func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } hdr := c.Request.Header.Get("Destination") if hdr == "" { return http.StatusBadRequest, errInvalidDestination @@ -832,7 +883,7 @@ func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (sta } } - return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, true, overwrite, dstExists) + return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, true, overwrite, dstExists, hashid.EncodeUserID(hasher, user.ID)) } release, ls, status, err := confirmLock(c, fm, user, srcTarget, dstTarget, srcUri, dstUri) @@ -850,13 +901,14 @@ func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (sta return http.StatusBadRequest, errInvalidDepth } } - return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, false, overwrite, dstExists) + return performCopyMove(ctx, fm, srcUri, dstUri, dstFolderUri, false, overwrite, dstExists, hashid.EncodeUserID(hasher, user.ID)) } type copyMoveOperations interface { Delete(ctx context.Context, path []*fs.URI, opts ...fs.Option) error MoveOrCopy(ctx context.Context, src []*fs.URI, dst *fs.URI, isCopy bool) error Rename(ctx context.Context, path *fs.URI, newName string) (fs.File, error) + SharedAddressTranslation(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.File, *fs.URI, error) } func parseOverwrite(value string) (bool, error) { @@ -875,7 +927,20 @@ func performCopyMove( fm copyMoveOperations, srcUri, dstUri, dstFolderUri *fs.URI, isCopy, overwrite, dstExists bool, + uid string, ) (int, error) { + if srcUri.Name() != dstUri.Name() { + // A renamed move/copy lands at dstFolder/srcName before the rename; + // a different resource already occupying that path would hit the + // UNIQUE(parent, name) constraint mid-operation. + _, intermediateUri, err := fm.SharedAddressTranslation(ctx, dstFolderUri.Join(srcUri.Name())) + if err == nil && !intermediateUri.IsSame(srcUri, uid) { + return http.StatusPreconditionFailed, errDestinationExists + } else if err != nil && !ent.IsNotFound(err) { + return purposeStatusCodeFromError(err), err + } + } + if dstExists { if !overwrite { return http.StatusPreconditionFailed, errDestinationExists @@ -902,6 +967,9 @@ func performCopyMove( } func handleProppatch(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) { + if davWriteForbidden(user) { + return http.StatusForbidden, nil + } _, reqPath, status, err := stripPrefix(c.Request.URL.Path, user) if err != nil { return status, err @@ -993,7 +1061,7 @@ func makePropstatResponse(href string, pstats []Propstat) *response { } const ( - infiniteDepth = intsets.MaxInt + infiniteDepth = math.MaxInt invalidDepth = -2 ) diff --git a/pkg/webdav/webdav_test.go b/pkg/webdav/webdav_test.go index 8c3682c6..80c9f251 100644 --- a/pkg/webdav/webdav_test.go +++ b/pkg/webdav/webdav_test.go @@ -6,15 +6,21 @@ import ( "net/http" "testing" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" ) type copyMoveOperationsStub struct { - calls []string - deleteErr error - moveErr error - renameErr error - moveIsCopy bool + calls []string + deleteErr error + moveErr error + renameErr error + moveIsCopy bool + translateURI *fs.URI + translateErr error + translateHits int } func (s *copyMoveOperationsStub) Delete(context.Context, []*fs.URI, ...fs.Option) error { @@ -33,6 +39,11 @@ func (s *copyMoveOperationsStub) Rename(context.Context, *fs.URI, string) (fs.Fi return nil, s.renameErr } +func (s *copyMoveOperationsStub) SharedAddressTranslation(context.Context, *fs.URI, ...fs.Option) (fs.File, *fs.URI, error) { + s.translateHits++ + return nil, s.translateURI, s.translateErr +} + func TestParseOverwrite(t *testing.T) { tests := []struct { value string @@ -64,8 +75,8 @@ func TestPerformCopyMove(t *testing.T) { dstFolder := dst.DirUri() t.Run("overwrite disabled", func(t *testing.T) { - operations := ©MoveOperationsStub{} - status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, false, true) + operations := ©MoveOperationsStub{translateErr: &ent.NotFoundError{}} + status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, false, true, "u1") if status != http.StatusPreconditionFailed || !errors.Is(err, errDestinationExists) { t.Fatalf("unexpected result: status=%d err=%v", status, err) } @@ -75,8 +86,8 @@ func TestPerformCopyMove(t *testing.T) { }) t.Run("overwrite existing move", func(t *testing.T) { - operations := ©MoveOperationsStub{} - status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true) + operations := ©MoveOperationsStub{translateErr: &ent.NotFoundError{}} + status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true, "u1") if err != nil || status != http.StatusNoContent { t.Fatalf("unexpected result: status=%d err=%v", status, err) } @@ -89,8 +100,8 @@ func TestPerformCopyMove(t *testing.T) { }) t.Run("new copy", func(t *testing.T) { - operations := ©MoveOperationsStub{} - status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, true, true, false) + operations := ©MoveOperationsStub{translateErr: &ent.NotFoundError{}} + status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, true, true, false, "u1") if err != nil || status != http.StatusCreated { t.Fatalf("unexpected result: status=%d err=%v", status, err) } @@ -101,6 +112,41 @@ func TestPerformCopyMove(t *testing.T) { t.Fatal("copy was executed as move") } }) + + t.Run("intermediate src name occupied", func(t *testing.T) { + // dstFolder already holds a different file named src.Name(): the + // intermediate dstFolder/srcName state would collide mid-operation. + operations := ©MoveOperationsStub{translateURI: mustWebDAVTestURI(t, "cloudreve://my/elsewhere")} + status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true, "u1") + if status != http.StatusPreconditionFailed || !errors.Is(err, errDestinationExists) { + t.Fatalf("unexpected result: status=%d err=%v", status, err) + } + if len(operations.calls) != 0 { + t.Fatalf("unexpected operations: %v", operations.calls) + } + }) + + t.Run("intermediate is src itself", func(t *testing.T) { + // Same-folder rename: dstFolder/srcName resolves back to the source. + operations := ©MoveOperationsStub{translateURI: src} + status, err := performCopyMove(context.Background(), operations, src, dst, dstFolder, false, true, true, "u1") + if err != nil || status != http.StatusNoContent { + t.Fatalf("unexpected result: status=%d err=%v", status, err) + } + }) + + t.Run("same-name move skips intermediate check", func(t *testing.T) { + sameNameDst := mustWebDAVTestURI(t, "cloudreve://my/other/source.temp") + sameNameFolder := sameNameDst.DirUri() + operations := ©MoveOperationsStub{translateURI: mustWebDAVTestURI(t, "cloudreve://my/other/source.temp")} + status, err := performCopyMove(context.Background(), operations, src, sameNameDst, sameNameFolder, false, true, true, "u1") + if err != nil || status != http.StatusNoContent { + t.Fatalf("unexpected result: status=%d err=%v", status, err) + } + if operations.translateHits != 0 { + t.Fatalf("intermediate check ran for same-name move") + } + }) } func mustWebDAVTestURI(t *testing.T, raw string) *fs.URI { @@ -156,3 +202,34 @@ func TestParseContentRange(t *testing.T) { } } } + +func TestDavWriteForbidden(t *testing.T) { + readOnlyAccount := &boolset.BooleanSet{} + boolset.Set(types.DavAccountReadOnly, true, readOnlyAccount) + readOnlyGroup := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionWebDAVReadOnly, true, readOnlyGroup) + + mkUser := func(group *boolset.BooleanSet, account *boolset.BooleanSet) *ent.User { + u := &ent.User{} + if group != nil { + u.SetGroup(&ent.Group{Permissions: group}) + } + if account != nil { + u.Edges.DavAccounts = []*ent.DavAccount{{Options: account}} + } + return u + } + + if !davWriteForbidden(mkUser(nil, readOnlyAccount)) { + t.Fatal("read-only dav account not blocked") + } + if !davWriteForbidden(mkUser(readOnlyGroup, &boolset.BooleanSet{})) { + t.Fatal("read-only group not blocked") + } + if davWriteForbidden(mkUser(&boolset.BooleanSet{}, &boolset.BooleanSet{})) { + t.Fatal("normal user blocked") + } + if davWriteForbidden(nil) { + t.Fatal("nil user blocked") + } +} diff --git a/routers/controllers/admin.go b/routers/controllers/admin.go index 37dfc5b8..852a2c83 100644 --- a/routers/controllers/admin.go +++ b/routers/controllers/admin.go @@ -10,8 +10,7 @@ import ( func AdminSummary(c *gin.Context) { service := ParametersFromContext[*admin.SummaryService](c, admin.SummaryParamCtx{}) res, err := service.Summary(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -22,8 +21,7 @@ func AdminSummary(c *gin.Context) { func AdminGetSettings(c *gin.Context) { service := ParametersFromContext[*admin.GetSettingService](c, admin.GetSettingParamCtx{}) res, err := service.GetSetting(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -33,8 +31,7 @@ func AdminGetSettings(c *gin.Context) { func AdminSetSettings(c *gin.Context) { service := ParametersFromContext[*admin.SetSettingService](c, admin.SetSettingParamCtx{}) res, err := service.SetSetting(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -45,8 +42,7 @@ func AdminSetSettings(c *gin.Context) { func AdminListGroups(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.List(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -56,8 +52,7 @@ func AdminListGroups(c *gin.Context) { func AdminFetchWopi(c *gin.Context) { service := ParametersFromContext[*admin.FetchWOPIDiscoveryService](c, admin.FetchWOPIDiscoveryParamCtx{}) res, err := service.Fetch(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -68,8 +63,7 @@ func AdminFetchWopi(c *gin.Context) { func AdminTestThumbGenerator(c *gin.Context) { service := ParametersFromContext[*admin.ThumbGeneratorTestService](c, admin.ThumbGeneratorTestParamCtx{}) res, err := service.Test(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -78,8 +72,7 @@ func AdminTestThumbGenerator(c *gin.Context) { func AdminGetQueueMetrics(c *gin.Context) { res, err := admin.GetQueueMetrics(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -88,8 +81,7 @@ func AdminGetQueueMetrics(c *gin.Context) { func AdminListPolicies(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Policies(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -98,8 +90,7 @@ func AdminListPolicies(c *gin.Context) { func AdminGetPolicy(c *gin.Context) { service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -109,8 +100,7 @@ func AdminGetPolicy(c *gin.Context) { func AdminSendTestMail(c *gin.Context) { service := ParametersFromContext[*admin.TestSMTPService](c, admin.TestSMTPParamCtx{}) err := service.Test(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -119,8 +109,7 @@ func AdminSendTestMail(c *gin.Context) { func AdminCreatePolicy(c *gin.Context) { service := ParametersFromContext[*admin.CreateStoragePolicyService](c, admin.CreateStoragePolicyParamCtx{}) res, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -129,8 +118,7 @@ func AdminCreatePolicy(c *gin.Context) { func AdminUpdatePolicy(c *gin.Context) { service := ParametersFromContext[*admin.UpdateStoragePolicyService](c, admin.UpdateStoragePolicyParamCtx{}) res, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -139,8 +127,7 @@ func AdminUpdatePolicy(c *gin.Context) { func AdminListNodes(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Nodes(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -149,8 +136,7 @@ func AdminListNodes(c *gin.Context) { func AdminGetNode(c *gin.Context) { service := ParametersFromContext[*admin.SingleNodeService](c, admin.SingleNodeParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -164,8 +150,7 @@ func AdminClearEntityUrlCache(c *gin.Context) { func AdminCreateStoragePolicyCors(c *gin.Context) { service := ParametersFromContext[*admin.CreateStoragePolicyCorsService](c, admin.CreateStoragePolicyCorsParamCtx{}) err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -175,8 +160,7 @@ func AdminCreateStoragePolicyCors(c *gin.Context) { func AdminOdOAuthURL(c *gin.Context) { service := ParametersFromContext[*admin.GetOauthRedirectService](c, admin.GetOauthRedirectParamCtx{}) res, err := service.GetOAuth(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -190,8 +174,7 @@ func AdminGetPolicyOAuthCallbackURL(c *gin.Context) { func AdminGetPolicyOAuthStatus(c *gin.Context) { service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{}) res, err := service.GetOauthCredentialStatus(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -200,8 +183,7 @@ func AdminGetPolicyOAuthStatus(c *gin.Context) { func AdminFinishOauthCallback(c *gin.Context) { service := ParametersFromContext[*admin.FinishOauthCallbackService](c, admin.FinishOauthCallbackParamCtx{}) err := service.Finish(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -210,8 +192,7 @@ func AdminFinishOauthCallback(c *gin.Context) { func AdminGetSharePointDriverRoot(c *gin.Context) { service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{}) res, err := service.GetSharePointDriverRoot(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -220,8 +201,7 @@ func AdminGetSharePointDriverRoot(c *gin.Context) { func AdminDeletePolicy(c *gin.Context) { service := ParametersFromContext[*admin.SingleStoragePolicyService](c, admin.GetStoragePolicyParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -230,8 +210,7 @@ func AdminDeletePolicy(c *gin.Context) { func AdminGetGroup(c *gin.Context) { service := ParametersFromContext[*admin.SingleGroupService](c, admin.SingleGroupParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -240,8 +219,7 @@ func AdminGetGroup(c *gin.Context) { func AdminCreateGroup(c *gin.Context) { service := ParametersFromContext[*admin.UpsertGroupService](c, admin.UpsertGroupParamCtx{}) res, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -250,8 +228,7 @@ func AdminCreateGroup(c *gin.Context) { func AdminUpdateGroup(c *gin.Context) { service := ParametersFromContext[*admin.UpsertGroupService](c, admin.UpsertGroupParamCtx{}) res, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -260,8 +237,7 @@ func AdminUpdateGroup(c *gin.Context) { func AdminListUsers(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Users(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -270,8 +246,7 @@ func AdminListUsers(c *gin.Context) { func AdminGetUser(c *gin.Context) { service := ParametersFromContext[*admin.SingleUserService](c, admin.SingleUserParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -280,8 +255,7 @@ func AdminGetUser(c *gin.Context) { func AdminUpdateUser(c *gin.Context) { service := ParametersFromContext[*admin.UpsertUserService](c, admin.UpsertUserParamCtx{}) res, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -290,8 +264,7 @@ func AdminUpdateUser(c *gin.Context) { func AdminCreateUser(c *gin.Context) { service := ParametersFromContext[*admin.UpsertUserService](c, admin.UpsertUserParamCtx{}) res, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -357,8 +330,7 @@ func AdminCreateUser(c *gin.Context) { func AdminDeleteGroup(c *gin.Context) { service := ParametersFromContext[*admin.SingleGroupService](c, admin.SingleGroupParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -368,8 +340,7 @@ func AdminDeleteGroup(c *gin.Context) { func AdminTestSlave(c *gin.Context) { service := ParametersFromContext[*admin.TestNodeService](c, admin.TestNodeParamCtx{}) err := service.Test(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -379,8 +350,7 @@ func AdminTestSlave(c *gin.Context) { func AdminTestDownloader(c *gin.Context) { service := ParametersFromContext[*admin.TestNodeDownloaderService](c, admin.TestNodeDownloaderParamCtx{}) res, err := service.Test(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -389,8 +359,7 @@ func AdminTestDownloader(c *gin.Context) { func AdminCreateNode(c *gin.Context) { service := ParametersFromContext[*admin.UpsertNodeService](c, admin.UpsertNodeParamCtx{}) res, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -399,8 +368,7 @@ func AdminCreateNode(c *gin.Context) { func AdminUpdateNode(c *gin.Context) { service := ParametersFromContext[*admin.UpsertNodeService](c, admin.UpsertNodeParamCtx{}) res, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -409,8 +377,7 @@ func AdminUpdateNode(c *gin.Context) { func AdminDeleteNode(c *gin.Context) { service := ParametersFromContext[*admin.SingleNodeService](c, admin.SingleNodeParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -420,8 +387,17 @@ func AdminDeleteNode(c *gin.Context) { func AdminDeleteUser(c *gin.Context) { service := ParametersFromContext[*admin.BatchUserService](c, admin.BatchUserParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { + return + } + c.JSON(200, serializer.Response{}) +} + +// AdminBatchUpdateUser 批量更新用户 +func AdminBatchUpdateUser(c *gin.Context) { + service := ParametersFromContext[*admin.BatchUserUpdateService](c, admin.BatchUserUpdateParamCtx{}) + err := service.Update(c) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -430,8 +406,7 @@ func AdminDeleteUser(c *gin.Context) { func AdminListFiles(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Files(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -440,8 +415,7 @@ func AdminListFiles(c *gin.Context) { func AdminGetFile(c *gin.Context) { service := ParametersFromContext[*admin.SingleFileService](c, admin.SingleFileParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -450,8 +424,7 @@ func AdminGetFile(c *gin.Context) { func AdminUpdateFile(c *gin.Context) { service := ParametersFromContext[*admin.UpsertFileService](c, admin.UpsertFileParamCtx{}) res, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -460,8 +433,7 @@ func AdminUpdateFile(c *gin.Context) { func AdminGetFileUrl(c *gin.Context) { service := ParametersFromContext[*admin.SingleFileService](c, admin.SingleFileParamCtx{}) res, err := service.Url(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -470,8 +442,7 @@ func AdminGetFileUrl(c *gin.Context) { func AdminBatchDeleteFile(c *gin.Context) { service := ParametersFromContext[*admin.BatchFileService](c, admin.BatchFileParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -481,8 +452,7 @@ func AdminBatchDeleteFile(c *gin.Context) { func AdminListEntities(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Entities(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -492,8 +462,7 @@ func AdminListEntities(c *gin.Context) { func AdminGetEntity(c *gin.Context) { service := ParametersFromContext[*admin.SingleEntityService](c, admin.SingleEntityParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -502,8 +471,7 @@ func AdminGetEntity(c *gin.Context) { func AdminGetEntityUrl(c *gin.Context) { service := ParametersFromContext[*admin.SingleEntityService](c, admin.SingleEntityParamCtx{}) res, err := service.Url(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -512,8 +480,7 @@ func AdminGetEntityUrl(c *gin.Context) { func AdminBatchDeleteEntity(c *gin.Context) { service := ParametersFromContext[*admin.BatchEntityService](c, admin.BatchEntityParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } } @@ -521,8 +488,7 @@ func AdminBatchDeleteEntity(c *gin.Context) { func AdminCleanupTask(c *gin.Context) { service := ParametersFromContext[*admin.CleanupTaskService](c, admin.CleanupTaskParameterCtx{}) err := service.CleanupTask(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -532,8 +498,7 @@ func AdminCleanupTask(c *gin.Context) { func AdminListTasks(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Tasks(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -542,8 +507,7 @@ func AdminListTasks(c *gin.Context) { func AdminGetTask(c *gin.Context) { service := ParametersFromContext[*admin.SingleTaskService](c, admin.SingleTaskParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -552,8 +516,7 @@ func AdminGetTask(c *gin.Context) { func AdminBatchDeleteTask(c *gin.Context) { service := ParametersFromContext[*admin.BatchTaskService](c, admin.BatchTaskParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -562,8 +525,7 @@ func AdminBatchDeleteTask(c *gin.Context) { func AdminListShares(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.Shares(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -572,8 +534,7 @@ func AdminListShares(c *gin.Context) { func AdminGetShare(c *gin.Context) { service := ParametersFromContext[*admin.SingleShareService](c, admin.SingleShareParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -582,8 +543,7 @@ func AdminGetShare(c *gin.Context) { func AdminBatchDeleteShare(c *gin.Context) { service := ParametersFromContext[*admin.BatchShareService](c, admin.BatchShareParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -592,8 +552,7 @@ func AdminBatchDeleteShare(c *gin.Context) { func AdminCalibrateStorage(c *gin.Context) { service := ParametersFromContext[*admin.SingleUserService](c, admin.SingleUserParamCtx{}) res, err := service.CalibrateStorage(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -603,8 +562,7 @@ func AdminCalibrateStorage(c *gin.Context) { func AdminListOAuthClients(c *gin.Context) { service := ParametersFromContext[*admin.AdminListService](c, admin.AdminListServiceParamsCtx{}) res, err := service.OAuthClients(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -614,8 +572,7 @@ func AdminListOAuthClients(c *gin.Context) { func AdminGetOAuthClient(c *gin.Context) { service := ParametersFromContext[*admin.SingleOAuthClientService](c, admin.SingleOAuthClientParamCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -625,8 +582,7 @@ func AdminGetOAuthClient(c *gin.Context) { func AdminCreateOAuthClient(c *gin.Context) { service := ParametersFromContext[*admin.UpsertOAuthClientService](c, admin.UpsertOAuthClientParamCtx{}) res, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -636,8 +592,7 @@ func AdminCreateOAuthClient(c *gin.Context) { func AdminUpdateOAuthClient(c *gin.Context) { service := ParametersFromContext[*admin.UpsertOAuthClientService](c, admin.UpsertOAuthClientParamCtx{}) res, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -647,8 +602,7 @@ func AdminUpdateOAuthClient(c *gin.Context) { func AdminDeleteOAuthClient(c *gin.Context) { service := ParametersFromContext[*admin.SingleOAuthClientService](c, admin.SingleOAuthClientParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -658,8 +612,7 @@ func AdminDeleteOAuthClient(c *gin.Context) { func AdminBatchDeleteOAuthClient(c *gin.Context) { service := ParametersFromContext[*admin.BatchOAuthClientService](c, admin.BatchOAuthClientParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } c.JSON(200, serializer.Response{}) diff --git a/routers/controllers/callback.go b/routers/controllers/callback.go index ef00b796..ef17a86d 100644 --- a/routers/controllers/callback.go +++ b/routers/controllers/callback.go @@ -71,7 +71,7 @@ func OSSCallbackValidate(c *gin.Context) { c.Next() } else { - c.JSON(401, ErrorResponse(err)) + c.JSON(401, ErrorResponse(c, err)) c.Abort() } } @@ -104,7 +104,7 @@ func OneDriveOAuth(c *gin.Context) { // redirect.RawQuery = queries.Encode() // c.Redirect(303, redirect.String()) //} else { - // c.JSON(200, ErrorResponse(err)) + // c.JSON(200, ErrorResponse(c, err)) //} } @@ -122,6 +122,6 @@ func GoogleDriveOAuth(c *gin.Context) { // redirect.RawQuery = queries.Encode() // c.Redirect(303, redirect.String()) //} else { - // c.JSON(200, ErrorResponse(err)) + // c.JSON(200, ErrorResponse(c, err)) //} } diff --git a/routers/controllers/file.go b/routers/controllers/file.go index a956c6b2..d1d2c7f4 100644 --- a/routers/controllers/file.go +++ b/routers/controllers/file.go @@ -10,9 +10,7 @@ import ( func DownloadArchive(c *gin.Context) { service := ParametersFromContext[*explorer.ArchiveService](c, explorer.ArchiveParamCtx{}) err := service.DownloadArchived(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } } @@ -21,9 +19,7 @@ func DownloadArchive(c *gin.Context) { func CreateArchive(c *gin.Context) { service := ParametersFromContext[*explorer.ArchiveWorkflowService](c, explorer.CreateArchiveParamCtx{}) resp, err := service.CreateCompressTask(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -38,9 +34,7 @@ func CreateArchive(c *gin.Context) { func ImportFiles(c *gin.Context) { service := ParametersFromContext[*explorer.ImportWorkflowService](c, explorer.CreateImportParamCtx{}) resp, err := service.CreateImportTask(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -55,9 +49,7 @@ func ImportFiles(c *gin.Context) { func CreateRemoteDownload(c *gin.Context) { service := ParametersFromContext[*explorer.DownloadWorkflowService](c, explorer.CreateDownloadParamCtx{}) resp, err := service.CreateDownloadTask(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -72,9 +64,20 @@ func CreateRemoteDownload(c *gin.Context) { func RebuildFTSIndex(c *gin.Context) { service := ParametersFromContext[*explorer.RebuildFTSIndexWorkflowService](c, explorer.CreateRebuildFTSIndexParamCtx{}) resp, err := service.CreateRebuildFTSIndexTask(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { + return + } + + c.JSON(200, serializer.Response{ + Data: resp, + }) +} + +// BlobAudit creates a blob-vs-database audit task +func BlobAudit(c *gin.Context) { + service := ParametersFromContext[*explorer.BlobAuditWorkflowService](c, explorer.BlobAuditParamCtx{}) + resp, err := service.CreateBlobAuditTask(c) + if respondErr(c, err) { return } @@ -87,9 +90,7 @@ func RebuildFTSIndex(c *gin.Context) { func ExtractArchive(c *gin.Context) { service := ParametersFromContext[*explorer.ArchiveWorkflowService](c, explorer.CreateArchiveParamCtx{}) resp, err := service.CreateExtractTask(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -135,8 +136,7 @@ func GetSource(c *gin.Context) { func DeleteDirectLink(c *gin.Context) { err := explorer.DeleteDirectLink(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -147,9 +147,7 @@ func DeleteDirectLink(c *gin.Context) { func Thumb(c *gin.Context) { service := ParametersFromContext[*explorer.FileThumbService](c, explorer.FileThumbParameterCtx{}) res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -160,9 +158,7 @@ func Thumb(c *gin.Context) { func FileURL(c *gin.Context) { service := ParametersFromContext[*explorer.FileURLService](c, explorer.FileURLParameterCtx{}) resp, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -177,9 +173,7 @@ func FileURL(c *gin.Context) { func ServeEntity(c *gin.Context) { service := ParametersFromContext[*explorer.EntityDownloadService](c, explorer.EntityDownloadParameterCtx{}) err := service.Serve(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } } @@ -188,9 +182,7 @@ func ServeEntity(c *gin.Context) { func CreateViewerSession(c *gin.Context) { service := ParametersFromContext[*explorer.CreateViewerSessionService](c, explorer.CreateViewerSessionParamCtx{}) resp, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -233,9 +225,7 @@ func FileUpload(c *gin.Context) { func DeleteUploadSession(c *gin.Context) { service := ParametersFromContext[*explorer.DeleteUploadSessionService](c, explorer.DeleteUploadSessionParameterCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -246,9 +236,7 @@ func DeleteUploadSession(c *gin.Context) { func CreateUploadSession(c *gin.Context) { service := ParametersFromContext[*explorer.CreateUploadSessionService](c, explorer.CreateUploadSessionParameterCtx{}) resp, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -261,9 +249,7 @@ func CreateUploadSession(c *gin.Context) { func CreateFile(c *gin.Context) { service := ParametersFromContext[*explorer.CreateFileService](c, explorer.CreateFileParameterCtx{}) resp, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -276,9 +262,7 @@ func CreateFile(c *gin.Context) { func RenameFile(c *gin.Context) { service := ParametersFromContext[*explorer.RenameFileService](c, explorer.RenameFileParameterCtx{}) resp, err := service.Rename(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -290,22 +274,14 @@ func RenameFile(c *gin.Context) { // MoveFile Moves or Copy files. func MoveFile(c *gin.Context) { service := ParametersFromContext[*explorer.MoveFileService](c, explorer.MoveFileParameterCtx{}) - if err := service.Move(c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, service.Move(c), serializer.Response{}) } // Delete 删除文件或目录 func Delete(c *gin.Context) { service := ParametersFromContext[*explorer.DeleteFileService](c, explorer.DeleteFileParameterCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -316,9 +292,7 @@ func Delete(c *gin.Context) { func Restore(c *gin.Context) { service := ParametersFromContext[*explorer.DeleteFileService](c, explorer.DeleteFileParameterCtx{}) err := service.Restore(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -328,9 +302,7 @@ func Restore(c *gin.Context) { // EmptyTrash hard-deletes every top-level item in the current user's trash bin. func EmptyTrash(c *gin.Context) { err := explorer.EmptyTrash(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -341,9 +313,7 @@ func EmptyTrash(c *gin.Context) { func Unlock(c *gin.Context) { service := ParametersFromContext[*explorer.UnlockFileService](c, explorer.UnlockFileParameterCtx{}) err := service.Unlock(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -354,8 +324,7 @@ func Unlock(c *gin.Context) { func Pin(c *gin.Context) { service := ParametersFromContext[*explorer.PinFileService](c, explorer.PinFileParameterCtx{}) err := service.PinFile(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -366,8 +335,7 @@ func Pin(c *gin.Context) { func Unpin(c *gin.Context) { service := ParametersFromContext[*explorer.PinFileService](c, explorer.PinFileParameterCtx{}) err := service.UnpinFile(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -378,9 +346,7 @@ func Unpin(c *gin.Context) { func PatchMetadata(c *gin.Context) { service := ParametersFromContext[*explorer.PatchMetadataService](c, explorer.PatchMetadataParameterCtx{}) err := service.Patch(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -391,9 +357,7 @@ func PatchMetadata(c *gin.Context) { func GetFileInfo(c *gin.Context) { service := ParametersFromContext[*explorer.GetFileInfoService](c, explorer.GetFileInfoParameterCtx{}) resp, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -406,9 +370,7 @@ func GetFileInfo(c *gin.Context) { func SetCurrentVersion(c *gin.Context) { service := ParametersFromContext[*explorer.SetCurrentVersionService](c, explorer.SetCurrentVersionParamCtx{}) err := service.Set(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -419,9 +381,7 @@ func SetCurrentVersion(c *gin.Context) { func DeleteVersion(c *gin.Context) { service := ParametersFromContext[*explorer.DeleteVersionService](c, explorer.DeleteVersionParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -431,9 +391,7 @@ func DeleteVersion(c *gin.Context) { func PatchView(c *gin.Context) { service := ParametersFromContext[*explorer.PatchViewService](c, explorer.PatchViewParameterCtx{}) err := service.Patch(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -443,9 +401,7 @@ func PatchView(c *gin.Context) { func ListArchiveFiles(c *gin.Context) { service := ParametersFromContext[*explorer.ArchiveListFilesService](c, explorer.ArchiveListFilesParamCtx{}) resp, err := service.List(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -457,9 +413,7 @@ func ListArchiveFiles(c *gin.Context) { func HandleExplorerEventsPush(c *gin.Context) { service := ParametersFromContext[*explorer.ExplorerEventService](c, explorer.ExplorerEventParamCtx{}) err := service.HandleExplorerEventsPush(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } } @@ -467,9 +421,7 @@ func HandleExplorerEventsPush(c *gin.Context) { func FulltextSearch(c *gin.Context) { service := ParametersFromContext[*explorer.FulltextSearchService](c, explorer.FulltextSearchParamCtx{}) resp, err := service.Search(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } diff --git a/routers/controllers/main.go b/routers/controllers/main.go index 48ac68f2..e5feb80e 100644 --- a/routers/controllers/main.go +++ b/routers/controllers/main.go @@ -9,6 +9,25 @@ import ( "github.com/go-playground/validator/v10" ) +// respondErr writes the standard serialized error body and aborts the chain +// when err is non-nil. Handlers use it as `if respondErr(c, err) { return }`. +func respondErr(c *gin.Context, err error) bool { + if err == nil { + return false + } + c.JSON(200, serializer.Err(c, err)) + c.Abort() + return true +} + +// respond writes err as an error body, or ok as the success body. +func respond(c *gin.Context, err error, ok any) { + if respondErr(c, err) { + return + } + c.JSON(200, ok) +} + // ParamErrorMsg 根据Validator返回的错误信息给出错误提示 func ParamErrorMsg(filed string, tag string) string { // 未通过验证的表单域与中文对应 @@ -40,11 +59,12 @@ func ParamErrorMsg(filed string, tag string) string { } // ErrorResponse 返回错误消息 -func ErrorResponse(err error) serializer.Response { +func ErrorResponse(c *gin.Context, err error) serializer.Response { // 处理 Validator 产生的错误 if ve, ok := err.(validator.ValidationErrors); ok { for _, e := range ve { - return serializer.ParamErrDeprecated( + return serializer.ParamErr( + c, ParamErrorMsg(e.Field(), e.Tag()), err, ) @@ -52,10 +72,10 @@ func ErrorResponse(err error) serializer.Response { } if _, ok := err.(*json.UnmarshalTypeError); ok { - return serializer.ParamErrDeprecated("JSON marshall error", err) + return serializer.ParamErr(c, "JSON marshall error", err) } - return serializer.ParamErrDeprecated("Parameter error", err) + return serializer.ParamErr(c, "Parameter error", err) } // FromJSON Parse and validate JSON from request body @@ -66,7 +86,7 @@ func FromJSON[T any](ctxKey any) gin.HandlerFunc { c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service)) c.Next() } else { - c.JSON(200, ErrorResponse(err)) + c.JSON(200, ErrorResponse(c, err)) c.Abort() } } @@ -80,7 +100,7 @@ func FromQuery[T any](ctxKey any) gin.HandlerFunc { c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service)) c.Next() } else { - c.JSON(200, ErrorResponse(err)) + c.JSON(200, ErrorResponse(c, err)) c.Abort() } } @@ -93,7 +113,7 @@ func FromForm[T any](ctxKey any) gin.HandlerFunc { c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service)) c.Next() } else { - c.JSON(200, ErrorResponse(err)) + c.JSON(200, ErrorResponse(c, err)) c.Abort() } } @@ -107,7 +127,7 @@ func FromUri[T any](ctxKey any) gin.HandlerFunc { c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxKey, &service)) c.Next() } else { - c.JSON(200, ErrorResponse(err)) + c.JSON(200, ErrorResponse(c, err)) c.Abort() } } diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go index bc196af2..621d90f3 100644 --- a/routers/controllers/oauth.go +++ b/routers/controllers/oauth.go @@ -26,9 +26,7 @@ func OpenIDJWKS(c *gin.Context) { func GetAppRegistration(c *gin.Context) { service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{}) app, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -37,10 +35,8 @@ func GetAppRegistration(c *gin.Context) { func GrantAppConsent(c *gin.Context) { service := ParametersFromContext[*oauth.GrantService](c, oauth.GrantParamCtx{}) - res, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + res, err := service.Issue(c) + if respondErr(c, err) { return } @@ -75,9 +71,7 @@ func ExchangeToken(c *gin.Context) { func OpenIDUserInfo(c *gin.Context) { service := ParametersFromContext[*oauth.UserInfoService](c, oauth.UserInfoParamCtx{}) res, err := service.GetUserInfo(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -87,9 +81,7 @@ func OpenIDUserInfo(c *gin.Context) { func DeleteOAuthGrant(c *gin.Context) { service := ParametersFromContext[*oauth.DeleteOAuthGrantService](c, oauth.DeleteOAuthGrantParamCtx{}) err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } diff --git a/routers/controllers/respond_test.go b/routers/controllers/respond_test.go new file mode 100644 index 00000000..235a1bfb --- /dev/null +++ b/routers/controllers/respond_test.go @@ -0,0 +1,49 @@ +package controllers + +import ( + "errors" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestRespondErr(t *testing.T) { + gin.SetMode(gin.TestMode) + a := assert.New(t) + + // nil err: no write, no abort, returns false. + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + a.False(respondErr(c, nil)) + a.False(c.IsAborted()) + a.Equal(200, w.Code) + a.Empty(w.Body.String()) + + // non-nil err: error body written, chain aborted, returns true. + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + a.True(respondErr(c, errors.New("boom"))) + a.True(c.IsAborted()) + a.Contains(w.Body.String(), "boom") +} + +func TestRespond(t *testing.T) { + gin.SetMode(gin.TestMode) + a := assert.New(t) + + // error path writes the error body, not the success body. + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + respond(c, errors.New("nope"), serializer.Response{}) + a.Contains(w.Body.String(), "nope") + + // success path writes the success body. + w = httptest.NewRecorder() + c, _ = gin.CreateTestContext(w) + respond(c, nil, serializer.Response{}) + a.Equal(200, w.Code) + a.NotContains(w.Body.String(), "nope") +} diff --git a/routers/controllers/share.go b/routers/controllers/share.go index 9899d98b..479d0eb9 100644 --- a/routers/controllers/share.go +++ b/routers/controllers/share.go @@ -12,8 +12,7 @@ import ( func CreateShare(c *gin.Context) { service := ParametersFromContext[*share.ShareCreateService](c, share.ShareCreateParamCtx{}) uri, err := service.Upsert(c, 0) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -24,8 +23,7 @@ func CreateShare(c *gin.Context) { func EditShare(c *gin.Context) { service := ParametersFromContext[*share.ShareCreateService](c, share.ShareCreateParamCtx{}) uri, err := service.Upsert(c, hashid.FromContext(c)) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -36,8 +34,7 @@ func EditShare(c *gin.Context) { func GetShare(c *gin.Context) { service := ParametersFromContext[*share.ShareInfoService](c, share.ShareInfoParamCtx{}) info, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -48,9 +45,7 @@ func GetShare(c *gin.Context) { func ListShare(c *gin.Context) { service := ParametersFromContext[*share.ListShareService](c, share.ListShareParamCtx{}) resp, err := service.List(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -64,8 +59,7 @@ func ListShare(c *gin.Context) { // DeleteShare 删除分享 func DeleteShare(c *gin.Context) { err := share.DeleteShare(c, hashid.FromContext(c)) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } diff --git a/routers/controllers/site.go b/routers/controllers/site.go index 38b803df..bb1f7b56 100644 --- a/routers/controllers/site.go +++ b/routers/controllers/site.go @@ -13,9 +13,7 @@ func SiteConfig(c *gin.Context) { service := ParametersFromContext[*basic.GetSettingService](c, basic.GetSettingParamCtx{}) resp, err := service.GetSiteConfig(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } diff --git a/routers/controllers/slave.go b/routers/controllers/slave.go index 82133b3b..3bdf710e 100644 --- a/routers/controllers/slave.go +++ b/routers/controllers/slave.go @@ -33,25 +33,13 @@ func SlaveUpload(c *gin.Context) { // SlaveGetUploadSession 从机创建上传会话 func SlaveGetUploadSession(c *gin.Context) { service := ParametersFromContext[*explorer.SlaveCreateUploadSessionService](c, explorer.SlaveCreateUploadSessionParamCtx{}) - if err := service.Create(c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, service.Create(c), serializer.Response{}) } // SlaveDeleteUploadSession 从机删除上传会话 func SlaveDeleteUploadSession(c *gin.Context) { service := ParametersFromContext[*explorer.SlaveDeleteUploadSessionService](c, explorer.SlaveDeleteUploadSessionParamCtx{}) - if err := service.Delete(c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, service.Delete(c), serializer.Response{}) } // SlaveServeEntity download entity content @@ -69,9 +57,7 @@ func SlaveServeEntity(c *gin.Context) { func SlaveMeta(c *gin.Context) { service := ParametersFromContext[*explorer.SlaveMetaService](c, explorer.SlaveMetaParamCtx{}) res, err := service.MediaMeta(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -82,9 +68,7 @@ func SlaveMeta(c *gin.Context) { func SlaveThumb(c *gin.Context) { service := ParametersFromContext[*explorer.SlaveThumbService](c, explorer.SlaveThumbParamCtx{}) err := service.Thumb(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } } @@ -107,22 +91,14 @@ func SlaveDelete(c *gin.Context) { // SlavePing 从机测试 func SlavePing(c *gin.Context) { service := ParametersFromContext[*admin.SlavePingService](c, admin.SlavePingParameterCtx{}) - if err := service.Test(c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, service.Test(c), serializer.Response{}) } // SlaveList 从机列出文件 func SlaveList(c *gin.Context) { service := ParametersFromContext[*explorer.SlaveListService](c, explorer.SlaveListParamCtx{}) objects, err := service.List(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -134,9 +110,7 @@ func SlaveDownloadTaskCreate(c *gin.Context) { service := ParametersFromContext[*slave.CreateSlaveDownload](c, node.CreateSlaveDownloadTaskParamCtx{}) d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader) handle, err := d.CreateTask(c, service.Url, service.Options) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -168,9 +142,7 @@ func SlaveCancelDownloadTask(c *gin.Context) { service := ParametersFromContext[*slave.CancelSlaveDownload](c, node.CancelSlaveDownloadTaskParamCtx{}) d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader) err := d.Cancel(c, service.Handle) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -182,9 +154,7 @@ func SlaveSelectFilesToDownload(c *gin.Context) { service := ParametersFromContext[*slave.SetSlaveFilesToDownload](c, node.SelectSlaveDownloadFilesParamCtx{}) d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader) err := d.SetFilesToDownload(c, service.Handle, service.Args...) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -195,9 +165,7 @@ func SlaveSelectFilesToDownload(c *gin.Context) { func SlaveTestDownloader(c *gin.Context) { d := c.MustGet(downloader.DownloaderCtxKey).(downloader.Downloader) res, err := d.Test(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -208,9 +176,7 @@ func SlaveTestDownloader(c *gin.Context) { func SlaveGetCredential(c *gin.Context) { service := ParametersFromContext[*node.OauthCredentialService](c, node.OauthCredentialParamCtx{}) cred, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -221,22 +187,18 @@ func SlaveGetCredential(c *gin.Context) { func SlaveCreateTask(c *gin.Context) { service := ParametersFromContext[*cluster.CreateSlaveTask](c, node.CreateSlaveTaskParamCtx{}) taskId, err := node.CreateTaskInSlave(service, c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } c.JSON(200, serializer.NewResponseWithGobData(c, taskId)) } -// SlaveCreateTask creates tasks and register it in registry +// SlaveGetTask returns the slave node's task info func SlaveGetTask(c *gin.Context) { service := ParametersFromContext[*node.GetSlaveTaskService](c, node.GetSlaveTaskParamCtx{}) task, err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -245,21 +207,13 @@ func SlaveGetTask(c *gin.Context) { func SlaveCleanupFolder(c *gin.Context) { service := ParametersFromContext[*cluster.FolderCleanup](c, node.FolderCleanupParamCtx{}) - if err := node.Cleanup(service, c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, node.Cleanup(service, c), serializer.Response{}) } func StatelessPrepareUpload(c *gin.Context) { service := ParametersFromContext[*fs.StatelessPrepareUploadService](c, node.StatelessPrepareUploadParamCtx{}) uploadSession, err := node.StatelessPrepareUpload(service, c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -269,9 +223,7 @@ func StatelessPrepareUpload(c *gin.Context) { func StatelessCompleteUpload(c *gin.Context) { service := ParametersFromContext[*fs.StatelessCompleteUploadService](c, node.StatelessCompleteUploadParamCtx{}) _, err := node.StatelessCompleteUpload(service, c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -280,22 +232,10 @@ func StatelessCompleteUpload(c *gin.Context) { func StatelessOnUploadFailed(c *gin.Context) { service := ParametersFromContext[*fs.StatelessOnUploadFailedService](c, node.StatelessOnUploadFailedParamCtx{}) - if err := node.StatelessOnUploadFailed(service, c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, node.StatelessOnUploadFailed(service, c), serializer.Response{}) } func StatelessCreateFile(c *gin.Context) { service := ParametersFromContext[*fs.StatelessCreateFileService](c, node.StatelessCreateFileParamCtx{}) - if err := node.StatelessCreateFile(service, c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() - return - } - - c.JSON(200, serializer.Response{}) + respond(c, node.StatelessCreateFile(service, c), serializer.Response{}) } diff --git a/routers/controllers/user.go b/routers/controllers/user.go index e4363e73..65f36419 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -16,8 +16,7 @@ import ( // StartLoginAuthn 开始注册WebAuthn登录 func StartLoginAuthn(c *gin.Context) { res, err := user.PreparePasskeyLogin(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -28,9 +27,7 @@ func StartLoginAuthn(c *gin.Context) { func FinishLoginAuthn(c *gin.Context) { service := ParametersFromContext[*user.FinishPasskeyLoginService](c, user.FinishPasskeyLoginParameterCtx{}) u, err := service.FinishPasskeyLogin(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -40,8 +37,7 @@ func FinishLoginAuthn(c *gin.Context) { // StartRegAuthn 开始注册WebAuthn信息 func StartRegAuthn(c *gin.Context) { res, err := user.PreparePasskeyRegister(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -52,8 +48,7 @@ func StartRegAuthn(c *gin.Context) { func FinishRegAuthn(c *gin.Context) { service := ParametersFromContext[*user.FinishPasskeyRegisterService](c, user.FinishPasskeyRegisterParameterCtx{}) res, err := service.FinishPasskeyRegister(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -64,8 +59,7 @@ func FinishRegAuthn(c *gin.Context) { func UserDeletePasskey(c *gin.Context) { service := ParametersFromContext[*user.DeletePasskeyService](c, user.DeletePasskeyParameterCtx{}) err := service.DeletePasskey(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) + if respondErr(c, err) { return } @@ -76,9 +70,7 @@ func UserDeletePasskey(c *gin.Context) { func UserLoginValidation(c *gin.Context) { service := ParametersFromContext[*user.UserLoginService](c, user.LoginParameterCtx{}) expectedUser, twoFaSession, err := service.Login(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -97,9 +89,7 @@ func UserLoginValidation(c *gin.Context) { func UserLogin2FAValidation(c *gin.Context) { service := ParametersFromContext[*user.OtpValidationService](c, user.OtpValidationParameterCtx{}) expectedUser, err := service.Verify2FA(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -110,9 +100,7 @@ func UserLogin2FAValidation(c *gin.Context) { // UserIssueToken generates new token pair for user func UserIssueToken(c *gin.Context) { resp, err := user.IssueToken(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -125,9 +113,7 @@ func UserIssueToken(c *gin.Context) { func UserRefreshToken(c *gin.Context) { service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{}) resp, err := service.Refresh(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -145,9 +131,7 @@ func UserRegister(c *gin.Context) { // UserSendReset 发送密码重设邮件 func UserSendReset(c *gin.Context) { service := ParametersFromContext[*user.UserResetEmailService](c, user.UserResetEmailParameterCtx{}) - if err := service.Reset(c); err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if err := service.Reset(c); respondErr(c, err) { return } c.JSON(200, serializer.Response{}) @@ -157,9 +141,7 @@ func UserSendReset(c *gin.Context) { func UserReset(c *gin.Context) { service := ParametersFromContext[*user.UserResetService](c, user.UserResetParameterCtx{}) res, err := service.Reset(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } c.JSON(200, serializer.Response{Data: res}) @@ -170,13 +152,23 @@ func UserActivate(c *gin.Context) { c.JSON(200, user.ActivateUser(c)) } +// UserRequestEmailChange starts the self-service email change flow. +func UserRequestEmailChange(c *gin.Context) { + service := ParametersFromContext[*user.RequestEmailChangeService](c, user.RequestEmailChangeParamCtx{}) + respond(c, service.Request(c), serializer.Response{}) +} + +// UserActivateEmailChange applies the pending email change from the signed +// confirmation link. +func UserActivateEmailChange(c *gin.Context) { + respond(c, user.ActivateEmailChange(c), serializer.Response{}) +} + // UserSignOut 用户退出登录 func UserSignOut(c *gin.Context) { service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{}) res, err := service.Delete(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -196,9 +188,7 @@ func UserMe(c *gin.Context) { // UserGet 获取用户信息 func UserGet(c *gin.Context) { u, err := user.GetUser(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -215,9 +205,7 @@ func UserGet(c *gin.Context) { // UserStorage 获取用户的存储信息 func UserStorage(c *gin.Context) { res, err := user.GetUserCapacity(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -229,9 +217,7 @@ func UserStorage(c *gin.Context) { // UserSetting 获取用户设定 func UserSetting(c *gin.Context) { res, err := user.GetUserSettings(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -242,21 +228,14 @@ func UserSetting(c *gin.Context) { // UploadAvatar 从文件上传头像 func UploadAvatar(c *gin.Context) { - if err := user.UpdateUserAvatar(c); err != nil { - c.JSON(200, serializer.Err(c, err)) - return - } - - c.JSON(200, serializer.Response{}) + respond(c, user.UpdateUserAvatar(c), serializer.Response{}) } // GetUserAvatar 获取用户头像 func GetUserAvatar(c *gin.Context) { service := ParametersFromContext[*user.GetAvatarService](c, user.GetAvatarServiceParamsCtx{}) err := service.Get(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } } @@ -265,9 +244,7 @@ func GetUserAvatar(c *gin.Context) { func UpdateOption(c *gin.Context) { service := ParametersFromContext[*user.PatchUserSetting](c, user.PatchUserSettingParamsCtx{}) err := service.Patch(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -320,9 +297,7 @@ func UpdateOption(c *gin.Context) { // UserInit2FA 初始化二步验证 func UserInit2FA(c *gin.Context) { secret, err := user.Init2FA(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -346,9 +321,7 @@ func UserPerformCopySession(c *gin.Context) { func UserPrepareLogin(c *gin.Context) { service := ParametersFromContext[*user.PrepareLoginService](c, user.PrepareLoginParameterCtx{}) res, err := service.Prepare(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -372,9 +345,7 @@ func UserSSOCallback(c *gin.Context) { func UserSSOExchange(c *gin.Context) { service := ParametersFromContext[*user.SSOExchangeService](c, user.SSOExchangeParameterCtx{}) res, err := service.SSOExchange(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -385,9 +356,7 @@ func UserSSOExchange(c *gin.Context) { func UserSearch(c *gin.Context) { service := ParametersFromContext[*user.SearchUserService](c, user.SearchUserParamCtx{}) u, err := service.Search(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -403,9 +372,7 @@ func UserSearch(c *gin.Context) { func ListPublicShare(c *gin.Context) { service := ParametersFromContext[*share.ListShareService](c, share.ListShareParamCtx{}) resp, err := service.ListInUserProfile(c, hashid.FromContext(c)) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } diff --git a/routers/controllers/webdav.go b/routers/controllers/webdav.go index 3b3033ab..9d2569f7 100644 --- a/routers/controllers/webdav.go +++ b/routers/controllers/webdav.go @@ -10,9 +10,7 @@ import ( func ListDavAccounts(c *gin.Context) { service := ParametersFromContext[*setting.ListDavAccountsService](c, setting.ListDavAccountParamCtx{}) resp, err := service.List(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -27,9 +25,7 @@ func ListDavAccounts(c *gin.Context) { func CreateDAVAccounts(c *gin.Context) { service := ParametersFromContext[*setting.CreateDavAccountService](c, setting.CreateDavAccountParamCtx{}) resp, err := service.Create(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -42,9 +38,7 @@ func CreateDAVAccounts(c *gin.Context) { func UpdateDAVAccounts(c *gin.Context) { service := ParametersFromContext[*setting.CreateDavAccountService](c, setting.CreateDavAccountParamCtx{}) resp, err := service.Update(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -56,9 +50,7 @@ func UpdateDAVAccounts(c *gin.Context) { // DeleteDAVAccounts deletes WebDAV accounts. func DeleteDAVAccounts(c *gin.Context) { err := setting.DeleteDavAccount(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } diff --git a/routers/controllers/workflow.go b/routers/controllers/workflow.go index f2a8a3a2..12c6afe2 100644 --- a/routers/controllers/workflow.go +++ b/routers/controllers/workflow.go @@ -11,9 +11,7 @@ import ( func ListTasks(c *gin.Context) { service := ParametersFromContext[*explorer.ListTaskService](c, explorer.ListTaskParamCtx{}) resp, err := service.ListTasks(c) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -27,9 +25,7 @@ func ListTasks(c *gin.Context) { func GetTaskPhaseProgress(c *gin.Context) { taskId := hashid.FromContext(c) resp, err := explorer.TaskPhaseProgress(c, taskId) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -46,9 +42,7 @@ func SetDownloadTaskTarget(c *gin.Context) { taskId := hashid.FromContext(c) service := ParametersFromContext[*explorer.SetDownloadFilesService](c, explorer.SetDownloadFilesParamCtx{}) err := service.SetDownloadFiles(c, taskId) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { return } @@ -58,9 +52,40 @@ func SetDownloadTaskTarget(c *gin.Context) { func CancelDownloadTask(c *gin.Context) { taskId := hashid.FromContext(c) err := explorer.CancelDownloadTask(c, taskId) - if err != nil { - c.JSON(200, serializer.Err(c, err)) - c.Abort() + if respondErr(c, err) { + return + } + + c.JSON(200, serializer.Response{}) +} + +// CancelTask terminates a queued or suspending task. +func CancelTask(c *gin.Context) { + taskId := hashid.FromContext(c) + err := explorer.CancelTask(c, taskId) + if respondErr(c, err) { + return + } + + c.JSON(200, serializer.Response{}) +} + +// DeleteTask hides a finished task record from the owner's list. +func DeleteTask(c *gin.Context) { + taskId := hashid.FromContext(c) + err := explorer.DeleteTask(c, taskId) + if respondErr(c, err) { + return + } + + c.JSON(200, serializer.Response{}) +} + +// RetryTask re-queues a failed task with its original args. +func RetryTask(c *gin.Context) { + taskId := hashid.FromContext(c) + err := explorer.RetryTask(c, taskId) + if respondErr(c, err) { return } diff --git a/routers/router.go b/routers/router.go index 0b124e06..f573268b 100644 --- a/routers/router.go +++ b/routers/router.go @@ -431,6 +431,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { middleware.HashID(hashid.UserID), controllers.UserActivate, ) + // 邮箱更换确认 Done + user.GET("activate_email/:id", + middleware.SignRequired(dep.GeneralAuth()), + middleware.HashID(hashid.UserID), + controllers.UserActivateEmailChange, + ) // 获取用户头像 user.GET("avatar/:id", middleware.HashID(hashid.UserID), @@ -590,6 +596,24 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { middleware.HashID(hashid.TaskID), controllers.GetTaskPhaseProgress, ) + // Retry a failed task with its original args + wf.POST(":id/retry", + middleware.RequiredScopes(types.ScopeWorkflowWrite), + middleware.HashID(hashid.TaskID), + controllers.RetryTask, + ) + // Cancel a queued or suspending task + wf.POST(":id/cancel", + middleware.RequiredScopes(types.ScopeWorkflowWrite), + middleware.HashID(hashid.TaskID), + controllers.CancelTask, + ) + // Delete (hide) a finished task record + wf.DELETE(":id", + middleware.RequiredScopes(types.ScopeWorkflowWrite), + middleware.HashID(hashid.TaskID), + controllers.DeleteTask, + ) // Create task to create an archive file wf.POST("archive", middleware.RequiredScopes(types.ScopeWorkflowWrite), @@ -796,6 +820,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromJSON[explorer.RebuildFTSIndexWorkflowService](explorer.CreateRebuildFTSIndexParamCtx{}), controllers.RebuildFTSIndex, ) + // Create task to audit physical blobs against the entities table + wf.POST("blobAudit", + middleware.IsAdmin(), + middleware.RequiredScopes(types.ScopeWorkflowWrite, types.ScopeAdminWrite), + controllers.FromJSON[explorer.BlobAuditWorkflowService](explorer.BlobAuditParamCtx{}), + controllers.BlobAudit, + ) // 取得文件外链 source := file.Group("source") @@ -899,7 +930,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { auth.Use(middleware.LoginRequired()) { // 管理 - admin := auth.Group("admin", middleware.IsAdmin()) + admin := auth.Group("admin", middleware.IsAdminOrDelegated()) admin.Use(middleware.RequiredScopes(types.ScopeAdminRead)) { admin.GET("summary", @@ -907,7 +938,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.AdminSummary, ) - settings := admin.Group("settings") + settings := admin.Group("settings", middleware.AdminSection(types.GroupPermissionAdminSettings)) { // Get settings settings.POST("", @@ -923,7 +954,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { } // 用户组管理 - group := admin.Group("group") + group := admin.Group("group", middleware.AdminSection(types.GroupPermissionAdminGroups)) { // 列出用户组 group.POST("", @@ -955,7 +986,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - tool := admin.Group("tool") + tool := admin.Group("tool", middleware.AdminSection(types.GroupPermissionAdminSettings)) { tool.GET("wopi", middleware.RequiredScopes(types.ScopeAdminWrite), @@ -977,7 +1008,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - queue := admin.Group("queue") + queue := admin.Group("queue", middleware.AdminSection(types.GroupPermissionAdminQueue)) { queue.GET("metrics", controllers.AdminGetQueueMetrics) // List tasks @@ -1009,7 +1040,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { } // 存储策略管理 - policy := admin.Group("policy") + policy := admin.Group("policy", middleware.AdminSection(types.GroupPermissionAdminStorage)) { // 列出存储策略 policy.POST("", @@ -1075,7 +1106,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - node := admin.Group("node") + node := admin.Group("node", middleware.AdminSection(types.GroupPermissionAdminStorage)) { node.POST("", controllers.FromJSON[adminsvc.AdminListService](adminsvc.AdminListServiceParamsCtx{}), @@ -1112,7 +1143,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - oauthClient := admin.Group("oauthClient") + oauthClient := admin.Group("oauthClient", middleware.AdminSection(types.GroupPermissionAdminSettings)) { // List OAuth clients oauthClient.POST("", @@ -1150,7 +1181,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - user := admin.Group("user") + user := admin.Group("user", middleware.AdminSection(types.GroupPermissionAdminUsers)) { // 列出用户 user.POST("", @@ -1182,6 +1213,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromJSON[adminsvc.BatchUserService](adminsvc.BatchUserParamCtx{}), controllers.AdminDeleteUser, ) + // 批量更新用户 + batch.POST("update", + middleware.RequiredScopes(types.ScopeAdminWrite), + controllers.FromJSON[adminsvc.BatchUserUpdateService](adminsvc.BatchUserUpdateParamCtx{}), + controllers.AdminBatchUpdateUser, + ) } user.POST(":id/calibrate", middleware.RequiredScopes(types.ScopeAdminWrite), @@ -1190,7 +1227,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - file := admin.Group("file") + file := admin.Group("file", middleware.AdminSection(types.GroupPermissionAdminFiles)) { // 列出文件 file.POST("", @@ -1221,7 +1258,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - entity := admin.Group("entity") + entity := admin.Group("entity", middleware.AdminSection(types.GroupPermissionAdminFiles)) { // List blobs entity.POST("", @@ -1246,7 +1283,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) } - share := admin.Group("share") + share := admin.Group("share", middleware.AdminSection(types.GroupPermissionAdminShares)) { // List shares share.POST("", @@ -1315,6 +1352,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) // 获得二步验证初始化信息 setting.GET("2fa", controllers.UserInit2FA) + // 请求更换邮箱(向新地址发送确认链接) + setting.POST("email", + middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite), + middleware.RateLimitByIP("email_change", 5, time.Hour), + controllers.FromJSON[usersvc.RequestEmailChangeService](usersvc.RequestEmailChangeParamCtx{}), + controllers.UserRequestEmailChange, + ) } } diff --git a/routers/router_test.go b/routers/router_test.go new file mode 100644 index 00000000..a8397dc5 --- /dev/null +++ b/routers/router_test.go @@ -0,0 +1,59 @@ +package routers + +import ( + "path/filepath" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// stubSettingProvider satisfies setting.Provider for route registration; +// per-request setting reads are not exercised here. +type stubSettingProvider struct { + setting.Provider +} + +// TestMasterRouteWiring verifies the master router mounts the core route +// groups end-to-end — a renamed path or dropped middleware chain fails here. +func TestMasterRouteWiring(t *testing.T) { + gin.SetMode(gin.TestMode) + logger := logging.NewConsoleLogger(logging.LevelError) + cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger) + require.NoError(t, err) + + dep := dependency.NewDependency( + dependency.WithLogger(logger), + dependency.WithConfigProvider(cfg), + dependency.WithGeneralAuth(auth.HMACAuth{SecretKey: []byte("test")}), + dependency.WithSettingProvider(&stubSettingProvider{}), + ) + + engine := InitRouter(dep) + + have := make(map[string]bool) + for _, r := range engine.Routes() { + have[r.Method+" "+r.Path] = true + } + + expected := []string{ + "GET /api/v4/site/ping", + "GET /api/v4/site/config/:section", + "POST /api/v4/session/token", + "GET /api/v4/session/prepare", + "POST /api/v4/user", + "GET /api/v4/user/activate/:id", + "GET /api/v4/user/info/:id", + "PUT /api/v4/file/upload", + "POST /api/v4/file/upload/:sessionId/:index", + "GET /f/:id/:name", + } + for _, e := range expected { + require.True(t, have[e], "route missing: %s", e) + } +} diff --git a/service/admin/file.go b/service/admin/file.go index e7c690f0..4fd7ecea 100644 --- a/service/admin/file.go +++ b/service/admin/file.go @@ -46,13 +46,11 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response { // // 列取存储策略中的目录 // policy, err := model.GetPolicyByID(service.ID) // if err != nil { - // return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", err) // } // // // 创建文件系统 // fs, err := filesystem.NewAnonymousFileSystem() // if err != nil { - // return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err) // } // defer fs.Recycle() // @@ -60,7 +58,6 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response { // fs.Policy = &policy // res, err := fs.ListPhysical(c.Request.Context(), service.Path) // if err != nil { - // return serializer.ErrDeprecated(serializer.CodeListFilesError, "", err) // } // // return serializer.Response{ @@ -73,20 +70,17 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response { //// 查找用户 //user, err := model.GetUserByID(service.ID) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeUserNotFound, "", err) //} // //// 创建文件系统 //fs, err := filesystem.NewFileSystem(&user) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err) //} //defer fs.Recycle() // //// 列取目录 //res, err := fs.List(c.Request.Context(), service.Path, nil) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeListFilesError, "", err) //} //return serializer.Response{ @@ -100,7 +94,6 @@ func (service *ListFolderService) List(c *gin.Context) serializer.Response { func (service *FileBatchService) Delete(c *gin.Context) serializer.Response { //files, err := model.GetFilesByIDs(service.ID, 0) //if err != nil { - // return serializer.DBErrDeprecated("Failed to list files for deleting", err) //} // //// 根据用户分组 @@ -157,6 +150,7 @@ const ( fileMetadataCondition = "file_metadata" fileSharedCondition = "file_shared" fileDirectLinkCondition = "file_direct_link" + fileDeletedCondition = "file_deleted" ) func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error) { @@ -177,6 +171,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error metadata string shared bool directLink bool + deleted *bool ) if service.Conditions[fileUserCondition] != "" { @@ -205,6 +200,13 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error directLink = true } + switch service.Conditions[fileDeletedCondition] { + case "true": + deleted = lo.ToPtr(true) + case "false": + deleted = lo.ToPtr(false) + } + res, err := fileClient.FlattenListFiles(ctx, &inventory.FlattenListFileParameters{ PaginationArgs: &inventory.PaginationArgs{ Page: service.Page - 1, @@ -218,6 +220,7 @@ func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error HasMetadata: metadata, Shared: shared, HasDirectLink: directLink, + Deleted: deleted, }) if err != nil { @@ -398,6 +401,9 @@ const ( entityUserCondition = "entity_user" entityPolicyCondition = "entity_policy" entityTypeCondition = "entity_type" + // entityRefCountCondition filters by reference count: "stale" (<=0, i.e. + // awaiting recycle) or "gt:N" / "lt:N" / "eq:N" comparisons. + entityRefCountCondition = "entity_ref_count" ) func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) { @@ -412,6 +418,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) policyID int err error entityType *types.EntityType + refCount *inventory.ReferenceCountFilter ) if s.Conditions[entityUserCondition] != "" { @@ -438,6 +445,13 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) entityType = &t } + if s.Conditions[entityRefCountCondition] != "" { + refCount, err = inventory.ParseReferenceCountFilter(s.Conditions[entityRefCountCondition]) + if err != nil { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid reference count filter", err) + } + } + res, err := fileClient.ListEntities(ctx, &inventory.ListEntityParameters{ PaginationArgs: &inventory.PaginationArgs{ Page: s.Page - 1, @@ -448,6 +462,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) UserID: userID, StoragePolicyID: policyID, EntityType: entityType, + ReferenceCount: refCount, }) if err != nil { diff --git a/service/admin/group.go b/service/admin/group.go index e2cddf84..3b2eddc1 100644 --- a/service/admin/group.go +++ b/service/admin/group.go @@ -8,6 +8,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/gin-gonic/gin" ) @@ -26,7 +27,6 @@ type GroupService struct { func (service *GroupService) Get() serializer.Response { //group, err := model.GetGroupByID(service.ID) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeGroupNotFound, "", err) //} // //return serializer.Response{Data: group} @@ -39,12 +39,10 @@ func (service *GroupService) Delete() serializer.Response { //// 查找用户组 //group, err := model.GetGroupByID(service.ID) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeGroupNotFound, "", err) //} // //// 是否为系统用户组 //if group.ID <= 3 { - // return serializer.ErrDeprecated(serializer.CodeInvalidActionOnSystemGroup, "", err) //} // //// 检查是否有用户使用 @@ -53,7 +51,6 @@ func (service *GroupService) Delete() serializer.Response { // Select("count(id)").Row() //row.Scan(&total) //if total > 0 { - // return serializer.ErrDeprecated(serializer.CodeGroupUsedByUser, strconv.Itoa(total), nil) //} // //model.DB.Delete(&group) @@ -165,6 +162,12 @@ func (s *UpsertGroupService) Update(c *gin.Context) (*GetGroupResponse, error) { return nil, serializer.NewError(serializer.CodeParamErr, "Initial admin group have to be admin", nil) } + // Delegated admins cannot modify admin-capable permission bits — the + // target group's existing admin bits are preserved. + if err := s.maskAdminPermissions(c, groupClient); err != nil { + return nil, err + } + group, err := groupClient.Upsert(c, s.Group) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to update group", err) @@ -182,6 +185,11 @@ func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) { return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil) } + // Delegated admins cannot create groups carrying admin-capable bits. + if err := s.maskAdminPermissions(c, groupClient); err != nil { + return nil, err + } + group, err := groupClient.Upsert(c, s.Group) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to create group", err) @@ -190,3 +198,34 @@ func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) { service := &SingleGroupService{ID: group.ID} return service.Get(c) } + +// maskAdminPermissions strips admin-capable permission bits from the payload +// when the acting user is not a full administrator, preventing delegated +// admins from escalating privileges. For updates, the target group's existing +// admin bits are preserved. +func (s *UpsertGroupService) maskAdminPermissions(c *gin.Context, groupClient inventory.GroupClient) error { + actor := inventory.UserFromContext(c) + if actor.Edges.Group != nil && actor.Edges.Group.Permissions != nil && + actor.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + return nil + } + + if s.Group.Permissions == nil { + s.Group.Permissions = &boolset.BooleanSet{} + } + if s.Group.ID > 0 { + existing, err := groupClient.GetByID(c, s.Group.ID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to get group", err) + } + for _, p := range types.AdminPermissionBits() { + boolset.Set(int(p), existing.Permissions != nil && existing.Permissions.Enabled(int(p)), s.Group.Permissions) + } + } else { + for _, p := range types.AdminPermissionBits() { + boolset.Set(int(p), false, s.Group.Permissions) + } + } + + return nil +} diff --git a/service/admin/policy.go b/service/admin/policy.go index 0d323622..1ef6d989 100644 --- a/service/admin/policy.go +++ b/service/admin/policy.go @@ -136,7 +136,6 @@ func (service *SlavePingService) Test(c *gin.Context) error { func (service *SlaveTestService) Test() serializer.Response { //slave, err := url.Parse(service.Server) //if err != nil { - // return serializer.ParamErrDeprecated("Failed to parse slave node server URL: "+err.Error(), nil) //} // //controller, _ := url.Parse("/api/v3/slave/ping") @@ -147,7 +146,6 @@ func (service *SlaveTestService) Test() serializer.Response { //} //bodyByte, _ := json.Marshal(body) // - //r := request.NewClientDeprecated() //res, err := r.Request( // "POST", // slave.ResolveReference(controller).String(), @@ -159,11 +157,9 @@ func (service *SlaveTestService) Test() serializer.Response { // ), //).DecodeResponse() //if err != nil { - // return serializer.ParamErrDeprecated("Failed to connect to slave node: "+err.Error(), nil) //} // //if res.Code != 0 { - // return serializer.ParamErrDeprecated("Successfully connected to slave node, but slave returns: "+res.Msg, nil) //} return serializer.Response{} @@ -176,7 +172,6 @@ func (service *PathTestService) Test() serializer.Response { //path = filepath.Join(path, "test.txt") //file, err := util.CreatNestedFile(util.RelativePath(path)) //if err != nil { - // return serializer.ParamErrDeprecated(fmt.Sprintf("Failed to create \"%s\": %s", path, err.Error()), nil) //} // //file.Close() diff --git a/service/admin/site.go b/service/admin/site.go index c03347b2..2031f9a2 100644 --- a/service/admin/site.go +++ b/service/admin/site.go @@ -52,14 +52,12 @@ func (service *BatchSettingChangeService) Change() serializer.Response { // if err := tx.Model(&model.Setting{}).Where("name = ?", setting.Key).Update("value", setting.Value).Error; err != nil { // cache.Deletes(cacheClean, "setting_") // tx.Rollback() - // return serializer.ErrDeprecated(serializer.CodeUpdateSetting, "Setting "+setting.Key+" failed to update", err) // } // // cacheClean = append(cacheClean, setting.Key) //} // //if err := tx.Commit().Error; err != nil { - // return serializer.DBErrDeprecated("Failed to update setting", err) //} // //cache.Deletes(cacheClean, "setting_") diff --git a/service/admin/user.go b/service/admin/user.go index fea859fb..319eb2d6 100644 --- a/service/admin/user.go +++ b/service/admin/user.go @@ -3,12 +3,14 @@ package admin import ( "context" "strconv" + "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" "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/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" @@ -38,6 +40,8 @@ const ( userGroupCondition = "user_group" userNickCondition = "user_nick" userEmailCondition = "user_email" + // userIDsCondition filters by a comma-separated list of numeric user IDs. + userIDsCondition = "user_ids" ) func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error) { @@ -51,6 +55,7 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error var ( err error groupID int + ids []int ) if service.Conditions[userGroupCondition] != "" { groupID, err = strconv.Atoi(service.Conditions[userGroupCondition]) @@ -59,6 +64,20 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error } } + if service.Conditions[userIDsCondition] != "" { + for _, part := range strings.Split(service.Conditions[userIDsCondition], ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.Atoi(part) + if err != nil { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid user ID list", err) + } + ids = append(ids, id) + } + } + res, err := userClient.ListUsers(ctx, &inventory.ListUserParameters{ PaginationArgs: &inventory.PaginationArgs{ Page: service.Page - 1, @@ -70,6 +89,7 @@ func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error GroupID: groupID, Nick: service.Conditions[userNickCondition], Email: service.Conditions[userEmailCondition], + IDs: ids, }) if err != nil { @@ -149,6 +169,58 @@ type adminUserEmailValidation struct { Email string `binding:"required,email"` } +// groupIsAdminCapable reports whether a permission set grants full or +// delegated admin access. +func groupIsAdminCapable(permissions *boolset.BooleanSet) bool { + if permissions == nil { + return false + } + for _, p := range types.AdminPermissionBits() { + if permissions.Enabled(int(p)) { + return true + } + } + return false +} + +// actorIsFullAdmin reports whether the acting user belongs to a group with the +// full admin permission. +func actorIsFullAdmin(c *gin.Context) bool { + actor := inventory.UserFromContext(c) + return actor.Edges.Group != nil && actor.Edges.Group.Permissions != nil && + actor.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) +} + +// guardAdminGroupChange rejects delegated admins that try to move users into +// or out of admin-capable groups. newGroupID == 0 keeps the current group. +func guardAdminGroupChange(c *gin.Context, dep dependency.Dep, existing *ent.User, newGroupID int) error { + if actorIsFullAdmin(c) { + return nil + } + + if existing != nil && existing.Edges.Group != nil && groupIsAdminCapable(existing.Edges.Group.Permissions) { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot modify an administrator", nil) + } + + targetGroupID := newGroupID + if targetGroupID == 0 && existing != nil { + targetGroupID = existing.GroupUsers + } + if targetGroupID == 0 { + return nil + } + + target, err := dep.GroupClient().GetByID(c, targetGroupID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to get group", err) + } + if groupIsAdminCapable(target.Permissions) { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot assign an administrator group", nil) + } + + return nil +} + func (s *UpsertUserService) validateEmail() error { if s.User == nil { return serializer.NewError(serializer.CodeParamErr, "Email format error", nil) @@ -184,6 +256,10 @@ func (s *UpsertUserService) Update(c *gin.Context) (*GetUserResponse, error) { } + if err := guardAdminGroupChange(c, dep, existing, s.User.GroupUsers); err != nil { + return nil, err + } + newUser, err := userClient.Upsert(ctx, s.User, s.Password, s.TwoFA) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to update user", err) @@ -209,6 +285,10 @@ func (s *UpsertUserService) Create(c *gin.Context) (*GetUserResponse, error) { return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil) } + if err := guardAdminGroupChange(c, dep, nil, s.User.GroupUsers); err != nil { + return nil, err + } + user, err := userClient.Upsert(c, s.User, s.Password, s.TwoFA) if err != nil { return nil, serializer.NewError(serializer.CodeDBError, "Failed to create user", err) @@ -232,6 +312,8 @@ func (s *BatchUserService) Delete(c *gin.Context) error { fileClient := dep.FileClient() current := inventory.UserFromContext(c) + fullAdmin := actorIsFullAdmin(c) + groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true) ae := serializer.NewAggregateError() for _, id := range s.IDs { if current.ID == id || id == 1 { @@ -239,6 +321,19 @@ func (s *BatchUserService) Delete(c *gin.Context) error { continue } + // Delegated admins cannot delete members of admin-capable groups. + if !fullAdmin { + target, err := userClient.GetByID(groupCtx, id) + if err != nil { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to get user", err)) + continue + } + if target.Edges.Group != nil && groupIsAdminCapable(target.Edges.Group.Permissions) { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeNoPermissionErr, "Cannot delete an administrator", nil)) + continue + } + } + fc, tx, ctx, err := inventory.WithTx(c, fileClient) if err != nil { ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)) @@ -271,3 +366,71 @@ func (s *BatchUserService) Delete(c *gin.Context) error { return ae.Aggregate() } + +type ( + // BatchUserUpdateService applies a status and/or group change to a set of + // users. At least one of the two fields must be set. + BatchUserUpdateService struct { + IDs []int `json:"ids" binding:"min=1"` + Status string `json:"status" binding:"omitempty,oneof=active inactive manual_banned"` + GroupID int `json:"group_id" binding:"omitempty,min=1"` + } + BatchUserUpdateParamCtx struct{} +) + +func (s *BatchUserUpdateService) Update(c *gin.Context) error { + if s.Status == "" && s.GroupID == 0 { + return serializer.NewError(serializer.CodeParamErr, "Nothing to update", nil) + } + + dep := dependency.FromContext(c) + userClient := dep.UserClient() + current := inventory.UserFromContext(c) + fullAdmin := actorIsFullAdmin(c) + + // Delegated admins cannot move users into an admin-capable group. + if !fullAdmin && s.GroupID > 0 { + target, err := dep.GroupClient().GetByID(c, s.GroupID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to get group", err) + } + if groupIsAdminCapable(target.Permissions) { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot assign an administrator group", nil) + } + } + + // The caller and the reserved initial admin cannot be modified in bulk. + ae := serializer.NewAggregateError() + groupCtx := context.WithValue(c, inventory.LoadUserGroup{}, true) + ids := lo.Filter(s.IDs, func(id int, _ int) bool { + if id == current.ID || id == 1 { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot modify this user in bulk", nil)) + return false + } + // Delegated admins cannot modify members of admin-capable groups. + if !fullAdmin { + target, err := userClient.GetByID(groupCtx, id) + if err != nil { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to get user", err)) + return false + } + if target.Edges.Group != nil && groupIsAdminCapable(target.Edges.Group.Permissions) { + ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeNoPermissionErr, "Cannot modify an administrator", nil)) + return false + } + } + return true + }) + + var status *user.Status + if s.Status != "" { + st := user.Status(s.Status) + status = &st + } + + if _, err := userClient.BatchUpdate(c, ids, status, s.GroupID); err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to update users", err) + } + + return ae.Aggregate() +} diff --git a/service/basic/site.go b/service/basic/site.go index 87e9cd5e..196be9b6 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -27,6 +27,10 @@ type SiteConfig struct { CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"` CustomHTML *setting.CustomHTML `json:"custom_html,omitempty"` + // Share section + ShareDefaultPrivate bool `json:"share_default_private,omitempty"` + DefaultShareLinksInProfile string `json:"default_share_links_in_profile,omitempty"` + // Login Section LoginCaptcha bool `json:"login_captcha,omitempty"` RegCaptcha bool `json:"reg_captcha,omitempty"` @@ -43,6 +47,11 @@ type SiteConfig struct { PrivacyPolicyUrl string `json:"privacy_policy_url,omitempty"` SSOEnabled bool `json:"sso_enabled,omitempty"` SSODisplayName string `json:"sso_display_name,omitempty"` + SSOAutoRedirect bool `json:"sso_auto_redirect,omitempty"` + + // DownloadCDNRoutes exposes configured CDN mirror endpoints so clients + // can offer a download-route picker (#2987). + DownloadCDNRoutes []setting.CDNRoute `json:"download_cdn_routes,omitempty"` // Explorer section Icons string `json:"icons,omitempty"` @@ -58,6 +67,10 @@ type SiteConfig struct { CustomProps []types.CustomProps `json:"custom_props,omitempty"` ShowEncryptionStatus bool `json:"show_encryption_status,omitempty"` FullTextSearch bool `json:"full_text_search,omitempty"` + // RemoteDownloadProviders lists distinct downloader providers offered by + // active remote-download-capable nodes, so clients can let users pick + // between e.g. Aria2 and qBittorrent per download. + RemoteDownloadProviders []string `json:"remote_download_providers,omitempty"` // Thumbnail section ThumbExts []string `json:"thumb_exts,omitempty"` @@ -102,6 +115,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { TosUrl: legalDocs.TermsOfService, SSOEnabled: sso.Enabled && sso.Issuer != "" && sso.ClientID != "", SSODisplayName: sso.DisplayName, + SSOAutoRedirect: sso.AutoRedirect, }, nil case "explorer": explorerSettings := settings.ExplorerFrontendSettings(c) @@ -117,18 +131,19 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { } } return &SiteConfig{ - MaxBatchSize: maxBatchSize, - FileViewers: fileViewers, - DefaultViewerMapping: settings.DefaultViewerMapping(c), - Icons: explorerSettings.Icons, - MapProvider: mapSettings.Provider, - GoogleMapTileType: mapSettings.GoogleTileType, - MapboxAK: mapSettings.MapboxAK, - ThumbnailWidth: w, - ThumbnailHeight: h, - CustomProps: customProps, - ShowEncryptionStatus: showEncryptionStatus, - FullTextSearch: settings.FTSEnabled(c), + MaxBatchSize: maxBatchSize, + FileViewers: fileViewers, + DefaultViewerMapping: settings.DefaultViewerMapping(c), + Icons: explorerSettings.Icons, + MapProvider: mapSettings.Provider, + GoogleMapTileType: mapSettings.GoogleTileType, + MapboxAK: mapSettings.MapboxAK, + ThumbnailWidth: w, + ThumbnailHeight: h, + CustomProps: customProps, + ShowEncryptionStatus: showEncryptionStatus, + FullTextSearch: settings.FTSEnabled(c), + RemoteDownloadProviders: remoteDownloadProviders(c, dep), }, nil case "emojis": emojis := settings.EmojiPresets(c) @@ -196,23 +211,27 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { appSetting := settings.AppSetting(c) customNavItems := settings.CustomNavItems(c) customHTML := settings.CustomHTML(c) + shareDefaults := settings.ShareDefaults(c) return &SiteConfig{ - InstanceID: siteBasic.ID, - SiteName: siteBasic.Name, - Themes: themes.Themes, - DefaultTheme: themes.DefaultTheme, - User: &userRes, - Logo: logo.Normal, - LogoLight: logo.Light, - CaptchaType: settings.CaptchaType(c), - TurnstileSiteID: settings.TurnstileCaptcha(c).Key, - ReCaptchaKey: reCaptcha.Key, - CapInstanceURL: capCaptcha.InstanceURL, - CapSiteKey: capCaptcha.SiteKey, - CapAssetServer: capCaptcha.AssetServer, - AppPromotion: appSetting.Promotion, - CustomNavItems: customNavItems, - CustomHTML: customHTML, + InstanceID: siteBasic.ID, + SiteName: siteBasic.Name, + Themes: themes.Themes, + DefaultTheme: themes.DefaultTheme, + User: &userRes, + Logo: logo.Normal, + LogoLight: logo.Light, + CaptchaType: settings.CaptchaType(c), + TurnstileSiteID: settings.TurnstileCaptcha(c).Key, + ReCaptchaKey: reCaptcha.Key, + CapInstanceURL: capCaptcha.InstanceURL, + CapSiteKey: capCaptcha.SiteKey, + CapAssetServer: capCaptcha.AssetServer, + AppPromotion: appSetting.Promotion, + CustomNavItems: customNavItems, + CustomHTML: customHTML, + ShareDefaultPrivate: shareDefaults.PrivateByDefault, + DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile), + DownloadCDNRoutes: settings.DownloadCDNRoutes(c), }, nil } @@ -256,3 +275,24 @@ func GetCaptchaImage(c *gin.Context) *CaptchaResponse { Ticket: idKeyD, } } + +// remoteDownloadProviders returns the distinct downloader providers offered by +// active nodes with remote-download capability, in stable sorted order. +func remoteDownloadProviders(c *gin.Context, dep dependency.Dep) []string { + nodes, err := dep.NodeClient().ListActiveNodes(c, nil) + if err != nil { + return nil + } + seen := map[string]bool{} + var providers []string + for _, n := range nodes { + if n.Capabilities == nil || !n.Capabilities.Enabled(int(types.NodeCapabilityRemoteDownload)) || + n.Settings == nil || n.Settings.Provider == "" || seen[string(n.Settings.Provider)] { + continue + } + seen[string(n.Settings.Provider)] = true + providers = append(providers, string(n.Settings.Provider)) + } + sort.Strings(providers) + return providers +} diff --git a/service/callback/oauth.go b/service/callback/oauth.go index 3f9b6f3a..3a553a8f 100644 --- a/service/callback/oauth.go +++ b/service/callback/oauth.go @@ -17,42 +17,35 @@ type OauthService struct { //// GDriveAuth Google Drive 更新认证信息 //func (service *OauthService) GDriveAuth(c *gin.Context) serializer.Response { // if service.Error != "" { -// return serializer.ParamErrDeprecated(service.Error, nil) // } // // // validate required scope // if missing, found := lo.Find[string](googledrive.RequiredScope, func(item string) bool { // return !strings.Contains(service.Scope, item) // }); found { -// return serializer.ParamErrDeprecated(fmt.Sprintf("Missing required scope: %s", missing), nil) // } // // policyID, ok := util.GetSession(c, "googledrive_oauth_policy").(uint) // if !ok { -// return serializer.ErrDeprecated(serializer.CodeNotFound, "", nil) // } // // util.DeleteSession(c, "googledrive_oauth_policy") // // policy, err := model.GetPolicyByID(policyID) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", nil) // } // // client, err := googledrive.NewClient(&policy) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to initialize Google Drive client", err) // } // // credential, err := client.ObtainToken(c, service.Code, "") // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to fetch AccessToken", err) // } // // // 更新存储策略的 RefreshToken // client.Policy.AccessKey = credential.RefreshToken // if err := client.Policy.SaveAndClearCache(); err != nil { -// return serializer.DBErrDeprecated("Failed to update RefreshToken", err) // } // // cache.Deletes([]string{client.Policy.AccessKey}, googledrive.TokenCachePrefix) @@ -62,41 +55,34 @@ type OauthService struct { // OdAuth OneDrive 更新认证信息 func (service *OauthService) OdAuth(c *gin.Context) serializer.Response { //if service.Error != "" { - // return serializer.ParamErrDeprecated(service.ErrorMsg, nil) //} // //policyID, ok := util.GetSession(c, "onedrive_oauth_policy").(uint) //if !ok { - // return serializer.ErrDeprecated(serializer.CodeNotFound, "", nil) //} // //util.DeleteSession(c, "onedrive_oauth_policy") // //policy, err := model.GetPolicyByID(policyID) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", nil) //} // //client, err := onedrive.NewClient(&policy) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to initialize OneDrive client", err) //} // //credential, err := client.ObtainToken(c, onedrive.WithCode(service.Code)) //if err != nil { - // return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to fetch AccessToken", err) //} // //// 更新存储策略的 RefreshToken //client.Policy.AccessKey = credential.RefreshToken //if err := client.Policy.SaveAndClearCache(); err != nil { - // return serializer.DBErrDeprecated("Failed to update RefreshToken", err) //} // //cache.Deletes([]string{client.Policy.AccessKey}, "onedrive_") //if client.Policy.OptionsSerialized.OdDriver != "" && strings.Contains(client.Policy.OptionsSerialized.OdDriver, "http") { // if err := querySharePointSiteID(c, client.Policy); err != nil { - // return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to query SharePoint basic ID", err) // } //} diff --git a/service/callback/upload.go b/service/callback/upload.go index 057c75eb..01c108e5 100644 --- a/service/callback/upload.go +++ b/service/callback/upload.go @@ -64,7 +64,6 @@ func ProcessCallback(c *gin.Context) error { // // 创建文件系统 // fs, err := filesystem.NewFileSystemFromCallback(c) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err) // } // defer fs.Recycle() // @@ -74,7 +73,6 @@ func ProcessCallback(c *gin.Context) error { // // 获取文件信息 // info, err := fs.Handler.(onedrive.Driver).Client.Meta(context.Background(), "", uploadSession.SavePath) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeQueryMetaFailed, "", err) // } // // // 验证与回调会话中是否一致 @@ -89,7 +87,6 @@ func ProcessCallback(c *gin.Context) error { // // if isSizeCheckFailed || !strings.EqualFold(info.GetSourcePath(), actualPath) { // fs.Handler.(onedrive.Driver).Client.Delete(context.Background(), []string{info.GetSourcePath()}) -// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err) // } // service.Meta = info // return ProcessCallback(c) @@ -102,7 +99,6 @@ func ProcessCallback(c *gin.Context) error { // // 创建文件系统 // fs, err := filesystem.NewFileSystemFromCallback(c) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err) // } // defer fs.Recycle() // @@ -112,12 +108,10 @@ func ProcessCallback(c *gin.Context) error { // // 获取文件信息 // info, err := fs.Handler.(*s3.Driver).Meta(context.Background(), uploadSession.SavePath) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err) // } // // // 验证实际文件信息与回调会话中是否一致 // if uploadSession.Size != info.Size { -// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err) // } // // return ProcessCallback(service, c) @@ -128,7 +122,6 @@ func ProcessCallback(c *gin.Context) error { // // 创建文件系统 // fs, err := filesystem.NewFileSystemFromCallback(c) // if err != nil { -// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err) // } // defer fs.Recycle() // @@ -138,7 +131,6 @@ func ProcessCallback(c *gin.Context) error { // // 验证文件大小 // if uploadSession.Size != service.Size { // fs.Handler.Delete(context.Background(), []string{uploadSession.SavePath}) -// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err) // } // // return ProcessCallback(service, c) diff --git a/service/explorer/file.go b/service/explorer/file.go index 0552664f..6544bfb6 100644 --- a/service/explorer/file.go +++ b/service/explorer/file.go @@ -246,6 +246,10 @@ type ( RenameFileService struct { Uri string `json:"uri" binding:"required"` NewName string `json:"new_name" binding:"required,min=1,max=255"` + // ExpectID optionally carries the hashid of the file the caller + // believes sits at Uri; the rename fails with a conflict if the + // resolved file differs (retried request after path reuse, #3565). + ExpectID string `json:"expect_id"` } ) @@ -260,6 +264,14 @@ func (service *RenameFileService) Rename(c *gin.Context) (*FileResponse, error) return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err) } + if service.ExpectID != "" { + expectID, err := dep.HashIDEncoder().Decode(service.ExpectID, hashid.FileID) + if err != nil { + return nil, serializer.NewError(serializer.CodeParamErr, "unknown expect_id", err) + } + util.WithValue(c, dbfs.ExpectedSourceIDsCtxKey{}, []int{expectID}) + } + file, err := m.Rename(c, uri, service.NewName) if err != nil { return nil, err @@ -274,6 +286,16 @@ type ( Uris []string `json:"uris" binding:"required,min=1"` Dst string `json:"dst" binding:"required"` Copy bool `json:"copy"` + // ExpectIDs optionally carries the hashids of the source files the + // caller selected, positionally aligned with Uris; a mismatch fails + // that entry with a conflict (#3565). An empty string entry skips + // the check for that position. + ExpectIDs []string `json:"expect_ids"` + // OnConflict selects the behaviour when a destination child with + // the same name exists: "skip" drops the colliding source, + // "overwrite" deletes the destination object first. Empty keeps + // the default fail-fast behaviour (#3159). + OnConflict string `json:"on_conflict" binding:"omitempty,eq=skip|eq=overwrite"` } ) @@ -297,6 +319,28 @@ func (s *MoveFileService) Move(c *gin.Context) error { return serializer.NewError(serializer.CodeParamErr, "unknown destination uri", err) } + if len(s.ExpectIDs) > 0 { + if len(s.ExpectIDs) != len(s.Uris) { + return serializer.NewError(serializer.CodeParamErr, "expect_ids must align with uris", nil) + } + ids := make([]int, len(s.ExpectIDs)) + for i, e := range s.ExpectIDs { + if e == "" { + continue + } + id, err := dep.HashIDEncoder().Decode(e, hashid.FileID) + if err != nil { + return serializer.NewError(serializer.CodeParamErr, "unknown expect_ids entry", err) + } + ids[i] = id + } + util.WithValue(c, dbfs.ExpectedSourceIDsCtxKey{}, ids) + } + + if s.OnConflict != "" { + util.WithValue(c, dbfs.MoveConflictCtxKey{}, s.OnConflict) + } + return m.MoveOrCopy(c, uris, dst, s.Copy) } diff --git a/service/explorer/response.go b/service/explorer/response.go index 7b4688d8..fd94a473 100644 --- a/service/explorer/response.go +++ b/service/explorer/response.go @@ -342,6 +342,7 @@ type Share struct { AllowEdit bool `json:"allow_edit,omitempty"` PreviewOnly bool `json:"preview_only,omitempty"` UploadOnly bool `json:"upload_only,omitempty"` + Note string `json:"note,omitempty"` // Only viewable if explicitly unlocked by owner SourceUri string `json:"source_uri,omitempty"` @@ -393,6 +394,8 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid. res.AllowEdit = s.Props.AllowEdit res.PreviewOnly = s.Props.PreviewOnly res.UploadOnly = s.Props.UploadOnly + // Owner-private note; never sent to share visitors (#3570). + res.Note = s.Props.Note } } diff --git a/service/explorer/response_share_test.go b/service/explorer/response_share_test.go new file mode 100644 index 00000000..67fadda4 --- /dev/null +++ b/service/explorer/response_share_test.go @@ -0,0 +1,72 @@ +package explorer + +import ( + "context" + "net/url" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/require" +) + +// stubSettingProvider satisfies the SettingProvider dependency; only +// ExposeUserEmail and ShareDefaults are exercised by BuildUserRedacted. +type stubSettingProvider struct { + setting.Provider +} + +func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false } + +func (stubSettingProvider) ShareDefaults(context.Context) *setting.ShareDefaults { + return &setting.ShareDefaults{} +} + +// TestBuildShareNoteVisibility ensures the owner-defined share note is only +// exposed to the share owner, never to visitors (upstream #3570). +func TestBuildShareNoteVisibility(t *testing.T) { + dep := dependency.NewDependency(dependency.WithSettingProvider(stubSettingProvider{})) + ctx := context.WithValue(context.Background(), dependency.DepCtx{}, dep) + + hasher, err := hashid.New("test-salt") + require.NoError(t, err) + base := &url.URL{Scheme: "https", Host: "example.com"} + + owner := &ent.User{ID: 1, Email: "owner@example.com", Settings: &types.UserSetting{}} + visitor := &ent.User{ID: 2, Email: "visitor@example.com", Settings: &types.UserSetting{}} + anonymous := &ent.User{ID: 0, Settings: &types.UserSetting{}} + + newShare := func() *ent.Share { + return &ent.Share{ + ID: 1, + Props: &types.ShareProps{Note: "tax receipts Q3"}, + } + } + + t.Run("owner sees note", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, owner, owner, + "receipts.zip", types.FileTypeFile, true, false) + require.Equal(t, "tax receipts Q3", res.Note) + }) + + t.Run("unlocked visitor does not see note", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, visitor, owner, + "receipts.zip", types.FileTypeFile, true, false) + require.Empty(t, res.Note) + }) + + t.Run("anonymous visitor does not see note", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, anonymous, owner, + "receipts.zip", types.FileTypeFile, true, false) + require.Empty(t, res.Note) + }) + + t.Run("locked share does not leak note to visitor", func(t *testing.T) { + res := BuildShare(ctx, newShare(), base, hasher, visitor, owner, + "receipts.zip", types.FileTypeFile, false, false) + require.Empty(t, res.Note) + }) +} diff --git a/service/explorer/slave.go b/service/explorer/slave.go index c4335aff..40e26367 100644 --- a/service/explorer/slave.go +++ b/service/explorer/slave.go @@ -151,6 +151,9 @@ type ( SlaveThumbService struct { Src string `uri:"src" binding:"required"` Ext string `uri:"ext" binding:"required"` + // Eid optionally carries the entity ID of the requested blob so the + // slave can resolve encryption metadata from the shared DB (#3164). + Eid int `form:"eid"` } ) @@ -165,12 +168,32 @@ func (s *SlaveThumbService) Thumb(c *gin.Context) error { } settings := dep.SettingProvider() - var entity fs.Entity - entity, err = local.NewLocalFileEntity(types.EntityTypeThumbnail, string(src)+settings.ThumbSlaveSidecarSuffix(c)) + var ( + entity fs.Entity + srcEntity fs.Entity + ) + // Resolve the DB entity when the caller knows its ID: it carries the + // encryption metadata needed to serve/generate encrypted thumbnails. + if s.Eid > 0 { + if e, err := dep.FileClient().GetEntityByID(c, s.Eid); err == nil { + dbEntity := fs.NewEntity(e) + if dbEntity.Type() == types.EntityTypeThumbnail { + entity = dbEntity + } else { + srcEntity = dbEntity + } + } + } + + if entity == nil { + entity, err = local.NewLocalFileEntity(types.EntityTypeThumbnail, string(src)+settings.ThumbSlaveSidecarSuffix(c)) + } if err != nil { - srcEntity, err := local.NewLocalFileEntity(types.EntityTypeVersion, string(src)) - if err != nil { - return fs.ErrPathNotExist.WithError(err) + if srcEntity == nil { + srcEntity, err = local.NewLocalFileEntity(types.EntityTypeVersion, string(src)) + if err != nil { + return fs.ErrPathNotExist.WithError(err) + } } entity, err = m.SubmitAndAwaitThumbnailTask(c, nil, s.Ext, srcEntity) diff --git a/service/explorer/upload.go b/service/explorer/upload.go index 4332a110..7643732e 100644 --- a/service/explorer/upload.go +++ b/service/explorer/upload.go @@ -205,6 +205,15 @@ func processChunkUpload(c *gin.Context, m manager.FileManager, session *fs.Uploa if err != nil { return err } + if !allReceived { + // A missing session record (expired TTL, eviction, or a racing + // cancel) means completion can never trigger — fail loudly so the + // client retries instead of leaving a permanently stuck placeholder. + dep := dependency.FromContext(c) + if _, ok := dep.KV().Get(manager.UploadSessionCachePrefix + session.Props.UploadSessionID); !ok { + return serializer.NewError(serializer.CodeUploadSessionExpired, "", nil) + } + } if allReceived { _, err := m.CompleteUpload(ctx, session) if err != nil { diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index 8bef8c95..fb2496ed 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -1,8 +1,11 @@ package explorer import ( + "context" "encoding/gob" + "fmt" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "strings" "time" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -20,7 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gofrs/uuid" "github.com/samber/lo" - "golang.org/x/tools/container/intsets" + "math" ) // ItemMoveService 处理多文件/目录移动 @@ -71,9 +74,14 @@ func init() { type ( DownloadWorkflowService struct { - Src []string `json:"src"` - SrcFile string `json:"src_file"` - Dst string `json:"dst" binding:"required"` + Src []string `json:"src"` + SrcFile string `json:"src_file"` + Dst string `json:"dst" binding:"required"` + FileName string `json:"file_name" binding:"omitempty,max=255"` + Username string `json:"username" binding:"omitempty,max=255"` + Password string `json:"password" binding:"omitempty,max=255"` + Headers []string `json:"headers" binding:"omitempty,max=32,dive,max=2048"` + Provider string `json:"provider" binding:"omitempty,max=64"` } CreateDownloadParamCtx struct{} ) @@ -129,6 +137,37 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } } + // Validate custom request headers: "Name: value" lines, no CRLF injection. + for _, h := range service.Headers { + name, _, ok := strings.Cut(h, ":") + if !ok || strings.TrimSpace(name) == "" || strings.ContainsAny(h, "\r\n") { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid header", nil) + } + } + + // Validate a requested downloader provider against what the node pool + // actually offers. + if service.Provider != "" && providerNodeAvailable(c, dep, service.Provider) == 0 { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid downloader provider", nil) + } + + // Custom file name only applies to single-source tasks; HTTP credentials + // and headers only apply to plain HTTP(S) source URLs. + taskOpts := &workflows.RemoteDownloadTaskOption{ + Provider: service.Provider, + HTTPUsername: service.Username, + HTTPPassword: service.Password, + HTTPHeaders: service.Headers, + } + if len(service.Src) <= 1 { + taskOpts.FileName = service.FileName + } + if service.SrcFile != "" { + taskOpts.HTTPUsername = "" + taskOpts.HTTPPassword = "" + taskOpts.HTTPHeaders = nil + } + // batch creating tasks ae := serializer.NewAggregateError() tasks := make([]queue.Task, 0, len(service.Src)) @@ -137,7 +176,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T continue } - t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst) + t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst, taskOpts) if err != nil { ae.Add(src, err) continue @@ -151,7 +190,7 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } if service.SrcFile != "" { - t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst) + t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst, taskOpts) if err != nil { ae.Add(service.SrcFile, err) } @@ -333,14 +372,15 @@ func (service *ListTaskService) ListTasks(c *gin.Context) (*TaskListResponse, er PageToken: service.NextPageToken, PageSize: service.PageSize, }, - Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType}, - UserID: user.ID, + Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType}, + UserID: user.ID, + ExcludeHidden: true, } if service.Category != "general" { args.Types = []string{queue.RemoteDownloadTaskType} if service.Category == "downloading" { - args.PageSize = intsets.MaxInt + args.PageSize = math.MaxInt args.Status = []task.Status{task.StatusSuspending, task.StatusProcessing, task.StatusQueued} } else if service.Category == "downloaded" { args.Status = []task.Status{task.StatusCanceled, task.StatusError, task.StatusCompleted} @@ -413,6 +453,138 @@ func CancelDownloadTask(c *gin.Context, taskID int) error { return nil } +// queueForTaskType maps a persisted task type to the dependency queue that +// owns it. Slave-side task types are not retryable from the master UI. +func queueForTaskType(c *gin.Context, dep dependency.Dep, taskType string) (queue.Queue, error) { + switch taskType { + case queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType: + return dep.IoIntenseQueue(c), nil + case queue.RemoteDownloadTaskType: + return dep.RemoteDownloadQueue(c), nil + case queue.MediaMetaTaskType, queue.FullTextIndexTaskType, queue.FullTextDeleteTaskType, + queue.FullTextRebuildTaskType, queue.FullTextCopyTaskType, queue.FullTextChangeOwnerTaskType: + return dep.MediaMetaQueue(c), nil + case queue.EntityRecycleRoutineTaskType, queue.ExplicitEntityRecycleTaskType, queue.UploadSentinelCheckTaskType: + return dep.EntityRecycleQueue(c), nil + } + return nil, fmt.Errorf("task type %q is not retryable", taskType) +} + +// RetryTask re-queues a failed task with its original args (#2823). +func RetryTask(c *gin.Context, taskID int) error { + dep := dependency.FromContext(c) + u := inventory.UserFromContext(c) + taskClient := dep.TaskClient() + + ctx := context.WithValue(c, inventory.LoadTaskUser{}, true) + ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true) + model, err := taskClient.GetTaskByID(ctx, taskID) + if err != nil { + return serializer.NewError(serializer.CodeNotFound, "Task not found", err) + } + + if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + return serializer.NewError(serializer.CodeNotFound, "Task not found", nil) + } + + if model.Status != task.StatusError { + return serializer.NewError(serializer.CodeParamErr, "Only failed tasks can be retried", nil) + } + + resumed, err := queue.NewTaskFromModel(model) + if err != nil { + return serializer.NewError(serializer.CodeInternalSetting, "Failed to rebuild task", err) + } + + q, err := queueForTaskType(c, dep, model.Type) + if err != nil { + return serializer.NewError(serializer.CodeParamErr, "Task type is not retryable", err) + } + + if err := q.QueueTask(c, resumed); err != nil { + return serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err) + } + + return nil +} + +// CancelTask terminates a queued or suspending task; running remote +// downloads are canceled through their downloader handle (#2270). +func CancelTask(c *gin.Context, taskID int) error { + dep := dependency.FromContext(c) + u := inventory.UserFromContext(c) + taskClient := dep.TaskClient() + + ctx := context.WithValue(c, inventory.LoadTaskUser{}, true) + model, err := taskClient.GetTaskByID(ctx, taskID) + if err != nil { + return serializer.NewError(serializer.CodeNotFound, "Task not found", err) + } + + if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + return serializer.NewError(serializer.CodeNotFound, "Task not found", nil) + } + + switch model.Status { + case task.StatusQueued, task.StatusSuspending: + q, err := queueForTaskType(c, dep, model.Type) + if err != nil { + return serializer.NewError(serializer.CodeParamErr, "Task type cannot be canceled", err) + } + if q.CancelTask(ctx, taskID) { + return nil + } + // Not in the in-memory registry (e.g. after a restart before + // resume); persist the cancel so it won't be picked up later. + if err := taskClient.SetStatusByID(ctx, taskID, task.StatusCanceled); err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to cancel task", err) + } + return nil + case task.StatusProcessing: + // Only remote downloads expose a runtime cancel handle; other + // running tasks cannot be interrupted safely. + if t, found := dep.TaskRegistry().Get(taskID); found { + if dl, ok := t.(*workflows.RemoteDownloadTask); ok { + if err := dl.CancelDownload(c); err != nil { + return serializer.NewError(serializer.CodeInternalSetting, "Failed to cancel download task", err) + } + return nil + } + } + return serializer.NewError(serializer.CodeParamErr, "Running task cannot be canceled", nil) + default: + return serializer.NewError(serializer.CodeParamErr, "Only queued or running tasks can be canceled", nil) + } +} + +// DeleteTask hides a finished task record from its owner's list. The row +// is kept so admins retain the history (#2270 follow-up, user request). +func DeleteTask(c *gin.Context, taskID int) error { + dep := dependency.FromContext(c) + u := inventory.UserFromContext(c) + taskClient := dep.TaskClient() + + model, err := taskClient.GetTaskByID(c, taskID) + if err != nil { + return serializer.NewError(serializer.CodeNotFound, "Task not found", err) + } + + if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + return serializer.NewError(serializer.CodeNotFound, "Task not found", nil) + } + + switch model.Status { + case task.StatusCompleted, task.StatusError, task.StatusCanceled: + default: + return serializer.NewError(serializer.CodeParamErr, "Only finished tasks can be deleted", nil) + } + + if err := taskClient.HideByIDs(c, model.UserTasks, taskID); err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to delete task", err) + } + return nil +} + type ( SetDownloadFilesService struct { Files []*downloader.SetFileToDownloadArgs `json:"files" binding:"required"` @@ -481,3 +653,47 @@ func (service *RebuildFTSIndexWorkflowService) CreateRebuildFTSIndexTask(c *gin. return BuildTaskResponse(t, nil, hasher), nil } + +type ( + BlobAuditWorkflowService struct { + PolicyID int `json:"policy_id" binding:"required,min=1"` + Delete bool `json:"delete"` + } + BlobAuditParamCtx struct{} +) + +// CreateBlobAuditTask queues a blob-vs-database audit for one storage policy. +func (service *BlobAuditWorkflowService) CreateBlobAuditTask(c *gin.Context) (*TaskResponse, error) { + dep := dependency.FromContext(c) + user := inventory.UserFromContext(c) + hasher := dep.HashIDEncoder() + + if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) { + return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Only admin can run a blob audit", nil) + } + + if _, err := dep.StoragePolicyClient().GetPolicyByID(c, service.PolicyID); err != nil { + return nil, serializer.NewError(serializer.CodeNotFound, "Storage policy not found", err) + } + + t, err := workflows.NewBlobAuditTask(c, user, service.PolicyID, service.Delete) + if err != nil { + return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err) + } + + if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil { + return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err) + } + + return BuildTaskResponse(t, nil, hasher), nil +} + +// providerNodeAvailable returns the ID of an active remote-download node +// offering the given provider, or 0 when none does. +func providerNodeAvailable(c *gin.Context, dep dependency.Dep, provider string) int { + nodes, err := dep.NodeClient().ListActiveNodes(c, nil) + if err != nil { + return 0 + } + return workflows.PickProviderNode(nodes, provider) +} diff --git a/service/explorer/workflows_retry_test.go b/service/explorer/workflows_retry_test.go new file mode 100644 index 00000000..0649b855 --- /dev/null +++ b/service/explorer/workflows_retry_test.go @@ -0,0 +1,297 @@ +package explorer + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + enttask "github.com/cloudreve/Cloudreve/v4/ent/task" + 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/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/queue" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" +) + +// captureQueue records submitted tasks without running workers. +type captureQueue struct { + queue.Queue + submitted []queue.Task + cancelable bool +} + +func (q *captureQueue) QueueTask(ctx context.Context, t queue.Task) error { + q.submitted = append(q.submitted, t) + return nil +} + +func (q *captureQueue) CancelTask(ctx context.Context, taskID int) bool { + return q.cancelable +} + +// retryDepStub exposes only the dependencies RetryTask/CancelTask touch. +type retryDepStub struct { + dependency.Dep + taskClient inventory.TaskClient + ioQueue queue.Queue + downloadQ queue.Queue + recycleQ queue.Queue + mediaMetaQ queue.Queue + registry queue.TaskRegistry +} + +func (d *retryDepStub) TaskClient() inventory.TaskClient { return d.taskClient } +func (d *retryDepStub) IoIntenseQueue(context.Context) queue.Queue { return d.ioQueue } +func (d *retryDepStub) RemoteDownloadQueue(context.Context) queue.Queue { return d.downloadQ } +func (d *retryDepStub) EntityRecycleQueue(context.Context) queue.Queue { return d.recycleQ } +func (d *retryDepStub) MediaMetaQueue(context.Context) queue.Queue { return d.mediaMetaQ } +func (d *retryDepStub) TaskRegistry() queue.TaskRegistry { + if d.registry == nil { + d.registry = queue.NewTaskRegistry() + } + return d.registry +} + +// TestRetryTask verifies failed-task retry: owner/admin gating, status gate, +// and queue routing by task type (upstream #2823). +func TestRetryTask(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + newUser := func(email string) *ent.User { + u := client.User.Create().SetEmail(email).SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx) + return client.User.Query().WithGroup().Where(entuser.ID(u.ID)).OnlyX(ctx) + } + owner := newUser("owner@example.com") + other := newUser("other@example.com") + + ioQ := &captureQueue{} + downloadQ := &captureQueue{} + dep := &retryDepStub{ + taskClient: inventory.NewTaskClient(client, conf.SQLiteDB, nil), + ioQueue: ioQ, + downloadQ: downloadQ, + recycleQ: &captureQueue{}, + mediaMetaQ: &captureQueue{}, + } + + newCtx := func(u *ent.User) *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("POST", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dep) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + newTask := func(ownerID int, taskType string, status enttask.Status) *ent.Task { + return client.Task.Create(). + SetType(taskType). + SetStatus(status). + SetPublicState(&types.TaskPublicState{}). + SetCorrelationID(uuid.Must(uuid.NewV4())). + SetUserTasks(ownerID). + SaveX(ctx) + } + + t.Run("failed extract task re-queues to io-intense queue", func(t *testing.T) { + ioQ.submitted = nil + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusError) + require.NoError(t, RetryTask(newCtx(owner), m.ID)) + require.Len(t, ioQ.submitted, 1) + require.Equal(t, m.ID, ioQ.submitted[0].ID()) + }) + + t.Run("failed download task re-queues to download queue", func(t *testing.T) { + downloadQ.submitted = nil + m := newTask(owner.ID, queue.RemoteDownloadTaskType, enttask.StatusError) + require.NoError(t, RetryTask(newCtx(owner), m.ID)) + require.Len(t, downloadQ.submitted, 1) + }) + + t.Run("non-error task rejected", func(t *testing.T) { + ioQ.submitted = nil + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusCompleted) + require.Error(t, RetryTask(newCtx(owner), m.ID)) + require.Empty(t, ioQ.submitted) + }) + + t.Run("other user's task rejected", func(t *testing.T) { + ioQ.submitted = nil + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusError) + require.Error(t, RetryTask(newCtx(other), m.ID)) + require.Empty(t, ioQ.submitted) + }) + + t.Run("slave task type not retryable", func(t *testing.T) { + m := newTask(owner.ID, queue.SlaveUploadTaskType, enttask.StatusError) + require.Error(t, RetryTask(newCtx(owner), m.ID)) + }) +} + +// TestCancelTask verifies queued-task cancellation falls back to a persisted +// status update when the task is not in the queue's in-memory registry +// (upstream #2270). +func TestCancelTask(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + owner := client.User.Create().SetEmail("cancel-owner@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx) + owner = client.User.Query().WithGroup().Where(entuser.ID(owner.ID)).OnlyX(ctx) + other := client.User.Create().SetEmail("cancel-other@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx) + other = client.User.Query().WithGroup().Where(entuser.ID(other.ID)).OnlyX(ctx) + + ioQ := &captureQueue{} + dep := &retryDepStub{ + taskClient: inventory.NewTaskClient(client, conf.SQLiteDB, nil), + ioQueue: ioQ, + downloadQ: &captureQueue{}, + recycleQ: &captureQueue{}, + mediaMetaQ: &captureQueue{}, + } + + newCtx := func(u *ent.User) *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("POST", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dep) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + newTask := func(ownerID int, taskType string, status enttask.Status) *ent.Task { + return client.Task.Create(). + SetType(taskType). + SetStatus(status). + SetPublicState(&types.TaskPublicState{}). + SetCorrelationID(uuid.Must(uuid.NewV4())). + SetUserTasks(ownerID). + SaveX(ctx) + } + + t.Run("queued task canceled via registry", func(t *testing.T) { + ioQ.cancelable = true + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusQueued) + require.NoError(t, CancelTask(newCtx(owner), m.ID)) + }) + + t.Run("queued task falls back to persisted cancel", func(t *testing.T) { + ioQ.cancelable = false + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusQueued) + require.NoError(t, CancelTask(newCtx(owner), m.ID)) + require.Equal(t, enttask.StatusCanceled, client.Task.GetX(ctx, m.ID).Status) + }) + + t.Run("suspending task cancelable", func(t *testing.T) { + ioQ.cancelable = false + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusSuspending) + require.NoError(t, CancelTask(newCtx(owner), m.ID)) + require.Equal(t, enttask.StatusCanceled, client.Task.GetX(ctx, m.ID).Status) + }) + + t.Run("completed task rejected", func(t *testing.T) { + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusCompleted) + require.Error(t, CancelTask(newCtx(owner), m.ID)) + }) + + t.Run("processing non-download task rejected", func(t *testing.T) { + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusProcessing) + require.Error(t, CancelTask(newCtx(owner), m.ID)) + }) + + t.Run("other user's task rejected", func(t *testing.T) { + m := newTask(owner.ID, queue.ExtractArchiveTaskType, enttask.StatusQueued) + require.Error(t, CancelTask(newCtx(other), m.ID)) + }) + + t.Run("slave task type not cancelable", func(t *testing.T) { + m := newTask(owner.ID, queue.SlaveUploadTaskType, enttask.StatusQueued) + require.Error(t, CancelTask(newCtx(owner), m.ID)) + }) +} + +func TestDeleteTask(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + owner := client.User.Create().SetEmail("del-owner@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx) + owner = client.User.Query().WithGroup().Where(entuser.ID(owner.ID)).OnlyX(ctx) + other := client.User.Create().SetEmail("del-other@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx) + other = client.User.Query().WithGroup().Where(entuser.ID(other.ID)).OnlyX(ctx) + + tc := inventory.NewTaskClient(client, conf.SQLiteDB, nil) + dep := &retryDepStub{ + taskClient: tc, + ioQueue: &captureQueue{}, + downloadQ: &captureQueue{}, + recycleQ: &captureQueue{}, + mediaMetaQ: &captureQueue{}, + } + + newCtx := func(u *ent.User) *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("DELETE", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dep) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + newTask := func(ownerID int, status enttask.Status) *ent.Task { + return client.Task.Create(). + SetType(queue.ExtractArchiveTaskType). + SetStatus(status). + SetPublicState(&types.TaskPublicState{}). + SetCorrelationID(uuid.Must(uuid.NewV4())). + SetUserTasks(ownerID). + SaveX(ctx) + } + + t.Run("finished task hidden from owner list", func(t *testing.T) { + m := newTask(owner.ID, enttask.StatusCompleted) + require.NoError(t, DeleteTask(newCtx(owner), m.ID)) + require.True(t, client.Task.GetX(ctx, m.ID).Hidden) + + res, err := tc.List(ctx, &inventory.ListTaskArgs{ + PaginationArgs: &inventory.PaginationArgs{PageSize: 10}, + UserID: owner.ID, + ExcludeHidden: true, + }) + require.NoError(t, err) + for _, task := range res.Tasks { + require.NotEqual(t, m.ID, task.ID) + } + }) + + t.Run("running task rejected", func(t *testing.T) { + m := newTask(owner.ID, enttask.StatusProcessing) + require.Error(t, DeleteTask(newCtx(owner), m.ID)) + require.False(t, client.Task.GetX(ctx, m.ID).Hidden) + }) + + t.Run("other user's task rejected", func(t *testing.T) { + m := newTask(owner.ID, enttask.StatusCompleted) + require.Error(t, DeleteTask(newCtx(other), m.ID)) + require.False(t, client.Task.GetX(ctx, m.ID).Hidden) + }) +} diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index 064799ad..24023314 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -58,7 +58,7 @@ type ( } ) -func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { +func (s *GrantService) Issue(c *gin.Context) (*GrantResponse, error) { dep := dependency.FromContext(c) user := inventory.UserFromContext(c) kv := dep.KV() @@ -129,7 +129,7 @@ type ( ExchangeTokenParamCtx struct{} ExchangeTokenService struct { ClientID string `form:"client_id" binding:"required"` - ClientSecret string `form:"client_secret" binding:"required"` + ClientSecret string `form:"client_secret"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` Code string `form:"code" binding:"required"` RedirectURI string `form:"redirect_uri"` @@ -178,14 +178,15 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) } } - // 4. Validate client secret + // 4. Validate client secret. Public clients (empty stored secret) cannot + // hold a credential — they must have authorized with PKCE instead. app, err := oAuthClient.GetByGUID(c, s.ClientID) if err != nil { return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err) } - if app.Secret != s.ClientSecret { - return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid client secret", nil) + if err := validateClientAuth(app, s.ClientSecret, authCode.CodeChallenge); err != nil { + return nil, err } // 5. Validate scopes are still valid for this app @@ -250,6 +251,22 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) return resp, nil } +// validateClientAuth enforces client authentication at token exchange: +// confidential clients must present their secret; public clients (empty +// stored secret) must have authorized with PKCE per RFC 8252. +func validateClientAuth(app *ent.OAuthClient, clientSecret, codeChallenge string) error { + if app.Secret == "" { + if codeChallenge == "" { + return serializer.NewError(serializer.CodeCredentialInvalid, "Public clients must authorize with PKCE", nil) + } + return nil + } + if app.Secret != clientSecret { + return serializer.NewError(serializer.CodeCredentialInvalid, "Invalid client secret", nil) + } + return 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{ diff --git a/service/oauth/oauth_test.go b/service/oauth/oauth_test.go new file mode 100644 index 00000000..803fe772 --- /dev/null +++ b/service/oauth/oauth_test.go @@ -0,0 +1,24 @@ +package oauth + +import ( + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/stretchr/testify/require" +) + +func TestValidateClientAuth(t *testing.T) { + confidential := &ent.OAuthClient{Secret: "s3cret"} + public := &ent.OAuthClient{Secret: ""} + + // Confidential clients: secret required and must match. + require.Error(t, validateClientAuth(confidential, "", "challenge")) + require.Error(t, validateClientAuth(confidential, "wrong", "challenge")) + require.NoError(t, validateClientAuth(confidential, "s3cret", "")) + + // Public clients: secret ignored, PKCE challenge mandatory. + require.Error(t, validateClientAuth(public, "", "")) + require.Error(t, validateClientAuth(public, "anything", "")) + require.NoError(t, validateClientAuth(public, "", "challenge")) + require.NoError(t, validateClientAuth(public, "stale-known-secret", "challenge")) +} diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go index c37861ac..4d24179e 100644 --- a/service/oauth/oidc.go +++ b/service/oauth/oidc.go @@ -37,6 +37,7 @@ func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { }, TokenEndpointAuthMethods: []string{ "client_secret_post", + "none", }, CodeChallengeMethodsSupported: []string{ "S256", diff --git a/service/setting/webdav.go b/service/setting/webdav.go index 3ed88001..9d194996 100644 --- a/service/setting/webdav.go +++ b/service/setting/webdav.go @@ -42,11 +42,9 @@ type WebDAVMountCreateService struct { // folderID, _ := c.Get("object_id") // folder, err := model.GetFoldersByIDs([]uint{folderID.(uint)}, user.ID) // if err != nil || len(folder) == 0 { -// return serializer.ErrDeprecated(serializer.CodeParentNotExist, "", err) // } // // if err := folder[0].Mount(0); err != nil { -// return serializer.DBErrDeprecated("Failed to update folder record", err) // } // // return serializer.Response{} diff --git a/service/share/manage.go b/service/share/manage.go index 45ef01b1..bb266af4 100644 --- a/service/share/manage.go +++ b/service/share/manage.go @@ -22,7 +22,7 @@ type ( ShareCreateService struct { Uri string `json:"uri" binding:"required"` IsPrivate bool `json:"is_private"` - Password string `json:"password" binding:"omitempty,max=32,alphanum"` + Password string `json:"password" binding:"omitempty,max=32"` RemainDownloads int `json:"downloads"` Expire int `json:"expire"` ShareView bool `json:"share_view"` @@ -31,6 +31,8 @@ type ( AllowEdit bool `json:"allow_edit"` PreviewOnly bool `json:"preview_only"` UploadOnly bool `json:"upload_only"` + // Optional owner-defined note shown on My Shares (#3570). + Note string `json:"note" binding:"omitempty,max=255"` } ShareCreateParamCtx struct{} @@ -98,6 +100,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string, AllowEdit: service.AllowEdit, PreviewOnly: service.PreviewOnly, UploadOnly: service.UploadOnly, + Note: service.Note, }) if err != nil { return "", err diff --git a/service/share/visit.go b/service/share/visit.go index 51168a93..22f96fc8 100644 --- a/service/share/visit.go +++ b/service/share/visit.go @@ -162,11 +162,20 @@ func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShar return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err) } - if targetUser.Settings != nil && targetUser.Settings.ShareLinksInProfile == types.ProfileHideShare { + // An unset user preference ("") inherits the site-wide default; explicit + // values (public_share/all_share/hide_share) always win. + level := types.ProfilePublicShareOnly + if targetUser.Settings != nil { + level = targetUser.Settings.ShareLinksInProfile + } + if level == types.ProfilePublicShareOnly { + level = dep.SettingProvider().ShareDefaults(c).LinksInProfile + } + if level == types.ProfileHideShare { return nil, serializer.NewError(serializer.CodeParamErr, "User has disabled share links in profile", nil) } - publicOnly := targetUser.Settings == nil || targetUser.Settings.ShareLinksInProfile == types.ProfilePublicShareOnly + publicOnly := level != types.ProfileAllShare args := &inventory.ListShareArgs{ PaginationArgs: &inventory.PaginationArgs{ UseCursorPagination: true, diff --git a/service/share/visit_test.go b/service/share/visit_test.go new file mode 100644 index 00000000..edd25920 --- /dev/null +++ b/service/share/visit_test.go @@ -0,0 +1,124 @@ +package share + +import ( + "context" + "net/http/httptest" + "net/url" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// stubSettingProvider satisfies the SettingProvider dependency; only +// ShareDefaults, SiteURL and ExposeUserEmail are exercised by +// ListInUserProfile and the share response builder. +type stubSettingProvider struct { + setting.Provider + defaults *setting.ShareDefaults +} + +func (s stubSettingProvider) ShareDefaults(context.Context) *setting.ShareDefaults { + return s.defaults +} + +func (stubSettingProvider) SiteURL(context.Context) *url.URL { + return &url.URL{Scheme: "https", Host: "example.com"} +} + +func (stubSettingProvider) ExposeUserEmail(context.Context) bool { return false } + +// TestListInUserProfileVisibilityResolution ensures a user's empty +// share_links_in_profile inherits the site-wide default while explicit +// values always win (upstream #3390). +func TestListInUserProfileVisibilityResolution(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + hasher, err := hashid.New("test-salt") + require.NoError(t, err) + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + + newOwner := func(email string, level types.ShareLinksInProfileLevel) int { + owner := client.User.Create(). + SetEmail(email). + SetNick("owner"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{ShareLinksInProfile: level}). + SaveX(ctx) + file := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SaveX(ctx) + client.Share.Create().SetUser(owner).SetFile(file).SaveX(ctx) // public share + client.Share.Create().SetUser(owner).SetFile(file).SetPassword("pw").SaveX(ctx) // private share + return owner.ID + } + + newCtx := func(dep dependency.Dep, requesterID int) *gin.Context { + w := httptest.NewRecorder() + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(w, engine) + c.Request = httptest.NewRequest("GET", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dep) + util.WithValue(c, inventory.UserCtx{}, client.User.GetX(context.Background(), requesterID)) + return c + } + + newDep := func(defaults *setting.ShareDefaults) dependency.Dep { + return dependency.NewDependency( + dependency.WithKV(cache.NewMemoStore("", nil)), + dependency.WithLogger(logging.NewConsoleLogger(logging.LevelDebug)), + dependency.WithUserClient(inventory.NewUserClient(client)), + dependency.WithShareClient(inventory.NewShareClient(client, conf.SQLiteDB, hasher)), + dependency.WithHashIDEncoder(hasher), + dependency.WithSettingProvider(stubSettingProvider{defaults: defaults}), + ) + } + + svc := &ListShareService{PageSize: 20} + + tests := []struct { + name string + userLevel types.ShareLinksInProfileLevel + siteDefault types.ShareLinksInProfileLevel + wantCount int + wantErr bool + }{ + {name: "unset inherits site default all_share", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfileAllShare, wantCount: 2}, + {name: "unset inherits site default public only", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfilePublicShareOnly, wantCount: 1}, + {name: "unset inherits site default hide", userLevel: types.ProfilePublicShareOnly, siteDefault: types.ProfileHideShare, wantErr: true}, + {name: "explicit all_share overrides site public", userLevel: types.ProfileAllShare, siteDefault: types.ProfilePublicShareOnly, wantCount: 2}, + {name: "explicit public_share overrides site all", userLevel: types.ProfileSharePublic, siteDefault: types.ProfileAllShare, wantCount: 1}, + {name: "explicit hide overrides site all", userLevel: types.ProfileHideShare, siteDefault: types.ProfileAllShare, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + uid := newOwner(tt.name+"@example.com", tt.userLevel) + dep := newDep(&setting.ShareDefaults{LinksInProfile: tt.siteDefault}) + c := newCtx(dep, uid) + + res, err := svc.ListInUserProfile(c, uid) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, res.Shares, tt.wantCount) + }) + } +} diff --git a/service/user/email_change.go b/service/user/email_change.go new file mode 100644 index 00000000..e0c3817c --- /dev/null +++ b/service/user/email_change.go @@ -0,0 +1,123 @@ +package user + +import ( + "fmt" + "time" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" + "github.com/cloudreve/Cloudreve/v4/pkg/email" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/gin-gonic/gin" +) + +const ( + emailChangeSessionKey = "email_change_" + emailChangeTTL = 24 * 3600 +) + +type ( + // RequestEmailChangeService starts the email-change flow: validates the new + // address and the current password, stores the pending address in KV, and + // sends a signed confirmation link to the new mailbox. + RequestEmailChangeService struct { + NewEmail string `json:"new_email" binding:"required,email"` + Password string `json:"password" binding:"required"` + } + RequestEmailChangeParamCtx struct{} +) + +func (s *RequestEmailChangeService) Request(c *gin.Context) error { + dep := dependency.FromContext(c) + u := inventory.UserFromContext(c) + userClient := dep.UserClient() + + if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil { + return err + } + + if err := inventory.CheckPassword(u, s.Password); err != nil { + return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err) + } + + if err := CheckEmailAllowed(dep.SettingProvider().EmailFilter(c), s.NewEmail); err != nil { + return err + } + + if _, err := userClient.GetByEmail(c, s.NewEmail); err == nil { + return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil) + } + + // The pending address lives in KV — the signed link carries only the user + // id, so the target address can't be tampered with. + kv := dep.KV() + if err := kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), s.NewEmail, emailChangeTTL); err != nil { + return serializer.NewError(serializer.CodeInternalSetting, "Failed to store email change session", err) + } + + base := dep.SettingProvider().SiteURL(c) + userID := hashid.EncodeUserID(dep.HashIDEncoder(), u.ID) + ttl := time.Now().Add(emailChangeTTL * time.Second) + activateURL, err := auth.SignURI(c, dep.GeneralAuth(), routes.MasterUserActivateEmailAPIUrl(base, userID).String(), &ttl) + if err != nil { + return serializer.NewError(serializer.CodeEncryptError, "Failed to sign the activation link", err) + } + + credential := activateURL.Query().Get("sign") + finalURL := routes.MasterUserActivateEmailUrl(base) + queries := finalURL.Query() + queries.Add("id", userID) + queries.Add("sign", credential) + finalURL.RawQuery = queries.Encode() + + title, body, err := email.NewActivationEmail(c, dep.SettingProvider(), u, finalURL.String()) + if err != nil { + return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err) + } + + if err := dep.EmailClient(c).Send(c, s.NewEmail, title, body); err != nil { + return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err) + } + + return nil +} + +// ActivateEmailChange applies a pending email change once the signed link +// from the confirmation mail is opened. The target address comes from KV so a +// valid signature alone can't pick an arbitrary address. +func ActivateEmailChange(c *gin.Context) error { + uid := hashid.FromContext(c) + dep := dependency.FromContext(c) + userClient := dep.UserClient() + kv := dep.KV() + + pending, ok := kv.Get(fmt.Sprintf("%s%d", emailChangeSessionKey, uid)) + if !ok { + return serializer.NewError(serializer.CodeParamErr, "No pending email change", nil) + } + newEmail, ok := pending.(string) + if !ok { + return serializer.NewError(serializer.CodeInternalSetting, "Invalid pending email change", nil) + } + + target, err := userClient.GetByID(c, uid) + if err != nil { + return serializer.NewError(serializer.CodeUserNotFound, "User not found", err) + } + + if _, err := userClient.GetByEmail(c, newEmail); err == nil { + return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil) + } + + target.Email = newEmail + if _, err := userClient.Upsert(c, target, "", ""); err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to update email", err) + } + + kv.Delete("", fmt.Sprintf("%s%d", emailChangeSessionKey, uid)) + return nil +} diff --git a/service/user/email_change_test.go b/service/user/email_change_test.go new file mode 100644 index 00000000..e1829a6e --- /dev/null +++ b/service/user/email_change_test.go @@ -0,0 +1,77 @@ +package user + +import ( + "context" + "fmt" + "net/http/httptest" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/cache" + "github.com/cloudreve/Cloudreve/v4/pkg/hashid" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestActivateEmailChange verifies the signed-link confirmation path: pending +// address comes from KV, the change applies once, and replays/concurrent +// claims are rejected. +func TestActivateEmailChange(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + u := client.User.Create(). + SetEmail("old@example.com"). + SetNick("change"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{}). + SaveX(ctx) + client.User.Create(). + SetEmail("taken@example.com"). + SetNick("taken"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{}). + SaveX(ctx) + + kv := cache.NewMemoStore("", nil) + dep := dependency.NewDependency( + dependency.WithUserClient(inventory.NewUserClient(client)), + dependency.WithKV(kv), + ) + + newCtx := func(uid int) *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("GET", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dep) + util.WithValue(c, hashid.ObjectIDCtx{}, uid) + return c + } + + // No pending change → rejected. + require.Error(t, ActivateEmailChange(newCtx(u.ID))) + + // Pending change applies. + require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "new@example.com", 60)) + require.NoError(t, ActivateEmailChange(newCtx(u.ID))) + require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email) + + // Replay after success is rejected (KV entry consumed). + require.Error(t, ActivateEmailChange(newCtx(u.ID))) + + // Address claimed between request and confirm is rejected. + require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "taken@example.com", 60)) + require.Error(t, ActivateEmailChange(newCtx(u.ID))) + require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email) +} diff --git a/service/user/login.go b/service/user/login.go index d466ccdb..6e775285 100644 --- a/service/user/login.go +++ b/service/user/login.go @@ -3,6 +3,8 @@ package user import ( "context" "fmt" + "net/netip" + "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/ent" @@ -88,8 +90,12 @@ func (service *UserResetEmailService) Reset(c *gin.Context) error { return serializer.NewError(serializer.CodeUserNotFound, "User not found", err) } + if u, err = userClient.LiftExpiredBan(c, u); err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) + } + if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned { - return serializer.NewError(serializer.CodeUserBaned, "This user is banned", nil) + return banError(u, "This user is banned") } if u.Status == user.StatusInactive { @@ -127,6 +133,9 @@ func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error ctx := context.WithValue(c, inventory.LoadUserGroup{}, true) expectedUser, err := userClient.GetByEmail(ctx, service.UserName) + if err == nil { + expectedUser, err = userClient.LiftExpiredBan(ctx, expectedUser) + } // 一系列校验 if err != nil { @@ -134,9 +143,11 @@ func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error } else if checkErr := inventory.CheckPassword(expectedUser, service.Password); checkErr != nil { err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err) } else if expectedUser.Status == user.StatusManualBanned || expectedUser.Status == user.StatusSysBanned { - err = serializer.NewError(serializer.CodeUserBaned, "This account has been blocked", nil) + err = banError(expectedUser, "This account has been blocked") } else if expectedUser.Status == user.StatusInactive { err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil) + } else if ipErr := checkLoginIPWhitelist(c.ClientIP(), expectedUser.Edges.Group); ipErr != nil { + err = ipErr } if err != nil { @@ -159,6 +170,12 @@ type ( func IssueToken(c *gin.Context) (*BuiltinLoginResponse, error) { dep := dependency.FromContext(c) u := inventory.UserFromContext(c) + + // Best-effort last-login stamp; a failed update must not block sign-in. + if err := dep.UserClient().UpdateLastLogin(c, u.ID); err != nil { + dep.Logger().Warning("Failed to update last_login for user %d: %s", u.ID, err) + } + token, err := dep.TokenAuth().Issue(c, &auth.IssueTokenArgs{ User: u, RootTokenID: nil, @@ -263,3 +280,35 @@ func (service *PrepareLoginService) Prepare(c *gin.Context) (*PrepareLoginRespon PasswordEnabled: expectedUser.Password != "", }, nil } + +// checkLoginIPWhitelist enforces a group's login IP whitelist. Entries are +// exact IPs or CIDR ranges; an empty or nil list allows all addresses. +// Malformed entries are ignored so a bad admin value cannot lock everyone out. +func checkLoginIPWhitelist(clientIP string, group *ent.Group) error { + if group == nil || group.Settings == nil || len(group.Settings.LoginIPWhitelist) == 0 { + return nil + } + + addr, err := netip.ParseAddr(clientIP) + if err != nil { + return serializer.NewError(serializer.CodeNoPermissionErr, "Cannot determine client IP", err) + } + + for _, entry := range group.Settings.LoginIPWhitelist { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if prefix, perr := netip.ParsePrefix(entry); perr == nil { + if prefix.Contains(addr) { + return nil + } + continue + } + if entryAddr, aerr := netip.ParseAddr(entry); aerr == nil && entryAddr == addr { + return nil + } + } + + return serializer.NewError(serializer.CodeNoPermissionErr, "Login from this IP address is not allowed", nil) +} diff --git a/service/user/response.go b/service/user/response.go index ac063a0d..d2c053a9 100644 --- a/service/user/response.go +++ b/service/user/response.go @@ -24,15 +24,18 @@ type PreparePasskeyLoginResponse struct { } type UserSettings struct { - VersionRetentionEnabled bool `json:"version_retention_enabled"` - VersionRetentionExt []string `json:"version_retention_ext,omitempty"` - VersionRetentionMax int `json:"version_retention_max,omitempty"` - Paswordless bool `json:"passwordless"` - TwoFAEnabled bool `json:"two_fa_enabled"` - Passkeys []Passkey `json:"passkeys,omitempty"` - DisableViewSync bool `json:"disable_view_sync"` - ShareLinksInProfile string `json:"share_links_in_profile"` - OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` + VersionRetentionEnabled bool `json:"version_retention_enabled"` + VersionRetentionExt []string `json:"version_retention_ext,omitempty"` + VersionRetentionMax int `json:"version_retention_max,omitempty"` + Paswordless bool `json:"passwordless"` + TwoFAEnabled bool `json:"two_fa_enabled"` + Passkeys []Passkey `json:"passkeys,omitempty"` + DisableViewSync bool `json:"disable_view_sync"` + ShareLinksInProfile string `json:"share_links_in_profile"` + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` + PreferredViewers map[string]string `json:"preferred_viewers,omitempty"` + TrashRetention int `json:"trash_retention,omitempty"` + OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` } func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings { @@ -47,6 +50,9 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa }), DisableViewSync: u.Settings.DisableViewSync, ShareLinksInProfile: string(u.Settings.ShareLinksInProfile), + ShareDefaultPrivate: u.Settings.ShareDefaultPrivate, + PreferredViewers: u.Settings.PreferredViewers, + TrashRetention: u.Settings.TrashRetention, OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant { return BuildOauthGrant(item) }), @@ -118,6 +124,12 @@ type User struct { Language string `json:"language,omitempty"` DisableViewSync bool `json:"disable_view_sync,omitempty"` ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"` + // ShareDefaultPrivate is the user's own private-share override; nil means + // the site default applies. Never resolved here — the dialog computes the + // effective default from user ?? site. + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` + // PreferredViewers maps file extensions to viewer IDs ("always open with"). + PreferredViewers map[string]string `json:"preferred_viewers,omitempty"` } type Group struct { @@ -175,6 +187,8 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User { Language: user.Settings.Language, DisableViewSync: user.Settings.DisableViewSync, ShareLinksInProfile: user.Settings.ShareLinksInProfile, + ShareDefaultPrivate: user.Settings.ShareDefaultPrivate, + PreferredViewers: user.Settings.PreferredViewers, } } @@ -230,12 +244,17 @@ func BuildUserRedacted(ctx context.Context, u *ent.User, level int, idEncoder ha userRaw := BuildUser(u, idEncoder) user := User{ - ID: userRaw.ID, - Nickname: userRaw.Nickname, - Avatar: userRaw.Avatar, - CreatedAt: userRaw.CreatedAt, + ID: userRaw.ID, + Nickname: userRaw.Nickname, + Avatar: userRaw.Avatar, + CreatedAt: userRaw.CreatedAt, + // Profile visitors see the effective visibility: an unset user + // preference resolves to the site-wide default (#3390). ShareLinksInProfile: userRaw.ShareLinksInProfile, } + if user.ShareLinksInProfile == types.ProfilePublicShareOnly { + user.ShareLinksInProfile = dependency.FromContext(ctx).SettingProvider().ShareDefaults(ctx).LinksInProfile + } if userRaw.Group != nil { user.Group = RedactedGroup(userRaw.Group) diff --git a/service/user/setting.go b/service/user/setting.go index 58d821c9..293282e9 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -230,10 +231,39 @@ type ( TwoFACode *string `json:"two_fa_code" binding:"omitempty"` DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"` ShareLinksInProfile *string `json:"share_links_in_profile" binding:"omitempty"` + // ShareDefaultPrivate accepts "true", "false" or "" (clear the + // override and inherit the site default). + ShareDefaultPrivate *string `json:"share_default_private" binding:"omitempty"` + // PreferredViewers replaces the whole extension → viewer-id map. + PreferredViewers *map[string]string `json:"preferred_viewers" binding:"omitempty"` + // TrashRetention overrides group trash retention, in seconds. + // 0 clears the override. Capped at ~10 years. + TrashRetention *int `json:"trash_retention" binding:"omitempty,min=0,max=315360000"` } PatchUserSettingParamsCtx struct{} ) +var ( + preferredViewerExtPattern = regexp.MustCompile(`^[a-z0-9]{1,20}$`) + preferredViewerIDPattern = regexp.MustCompile(`^[\w.\-:]{1,64}$`) +) + +const preferredViewersMaxEntries = 200 + +// validatePreferredViewers bounds the extension → viewer-id map to sane +// shapes so it can't be abused as arbitrary JSON storage. +func validatePreferredViewers(m map[string]string) error { + if len(m) > preferredViewersMaxEntries { + return serializer.NewError(serializer.CodeParamErr, "Too many preferred viewers", nil) + } + for ext, viewerID := range m { + if !preferredViewerExtPattern.MatchString(ext) || !preferredViewerIDPattern.MatchString(viewerID) { + return serializer.NewError(serializer.CodeParamErr, "Invalid preferred viewer entry", nil) + } + } + return nil +} + func (s *PatchUserSetting) Patch(c *gin.Context) error { dep := dependency.FromContext(c) u := inventory.UserFromContext(c) @@ -277,7 +307,38 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error { } if s.ShareLinksInProfile != nil { - u.Settings.ShareLinksInProfile = types.ShareLinksInProfileLevel(*s.ShareLinksInProfile) + level := types.ShareLinksInProfileLevel(*s.ShareLinksInProfile) + switch level { + case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare, types.ProfileSharePublic: + default: + return serializer.NewError(serializer.CodeParamErr, "Invalid share links visibility", nil) + } + u.Settings.ShareLinksInProfile = level + saveSetting = true + } + + if s.ShareDefaultPrivate != nil { + if *s.ShareDefaultPrivate != "" && *s.ShareDefaultPrivate != "true" && *s.ShareDefaultPrivate != "false" { + return serializer.NewError(serializer.CodeParamErr, "Invalid share privacy default", nil) + } + u.Settings.ShareDefaultPrivate = nil + if *s.ShareDefaultPrivate != "" { + v := *s.ShareDefaultPrivate == "true" + u.Settings.ShareDefaultPrivate = &v + } + saveSetting = true + } + + if s.PreferredViewers != nil { + if err := validatePreferredViewers(*s.PreferredViewers); err != nil { + return err + } + u.Settings.PreferredViewers = *s.PreferredViewers + saveSetting = true + } + + if s.TrashRetention != nil { + u.Settings.TrashRetention = *s.TrashRetention saveSetting = true } diff --git a/service/user/setting_test.go b/service/user/setting_test.go new file mode 100644 index 00000000..c9e724e4 --- /dev/null +++ b/service/user/setting_test.go @@ -0,0 +1,146 @@ +package user + +import ( + "context" + "net/http/httptest" + "strconv" + "testing" + + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/util" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestPatchUserSettingShareDefaults verifies the tri-state private-share +// override and profile-visibility validation (upstream #3390). +func TestPatchUserSettingShareDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + u := client.User.Create(). + SetEmail("patch@example.com"). + SetNick("patch"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{}). + SaveX(ctx) + + newCtx := func() *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("PATCH", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dependency.NewDependency( + dependency.WithUserClient(inventory.NewUserClient(client)), + )) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + strPtr := func(s string) *string { return &s } + + patch := func(s *PatchUserSetting) error { + return s.Patch(newCtx()) + } + + // Set override on. + require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("true")})) + require.NotNil(t, u.Settings.ShareDefaultPrivate) + require.True(t, *u.Settings.ShareDefaultPrivate) + + // Set override off. + require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("false")})) + require.NotNil(t, u.Settings.ShareDefaultPrivate) + require.False(t, *u.Settings.ShareDefaultPrivate) + + // Persisted to DB. + persisted := client.User.GetX(ctx, u.ID) + require.NotNil(t, persisted.Settings.ShareDefaultPrivate) + require.False(t, *persisted.Settings.ShareDefaultPrivate) + + // Empty clears the override back to inheritance. + require.NoError(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("")})) + require.Nil(t, u.Settings.ShareDefaultPrivate) + + // Malformed value rejected. + require.Error(t, patch(&PatchUserSetting{ShareDefaultPrivate: strPtr("yes")})) + + // Valid profile visibility values accepted. + for _, v := range []string{"", "public_share", "all_share", "hide_share"} { + require.NoError(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr(v)})) + require.Equal(t, types.ShareLinksInProfileLevel(v), u.Settings.ShareLinksInProfile) + } + + // Invalid profile visibility rejected. + require.Error(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr("friends_only")})) +} + +// TestPatchUserSettingPreferredViewers verifies the extension → viewer-id map +// is persisted and validated (upstream: persist "always open with" in DB). +func TestPatchUserSettingPreferredViewers(t *testing.T) { + gin.SetMode(gin.TestMode) + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + u := client.User.Create(). + SetEmail("viewers@example.com"). + SetNick("viewers"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{}). + SaveX(ctx) + + newCtx := func() *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("PATCH", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dependency.NewDependency( + dependency.WithUserClient(inventory.NewUserClient(client)), + )) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + patch := func(s *PatchUserSetting) error { + return s.Patch(newCtx()) + } + + // Valid map persists. + valid := map[string]string{"md": "codemirror", "epub": "epub"} + require.NoError(t, patch(&PatchUserSetting{PreferredViewers: &valid})) + require.Equal(t, "codemirror", u.Settings.PreferredViewers["md"]) + + persisted := client.User.GetX(ctx, u.ID) + require.Equal(t, "epub", persisted.Settings.PreferredViewers["epub"]) + + // Empty map clears all preferences. + empty := map[string]string{} + require.NoError(t, patch(&PatchUserSetting{PreferredViewers: &empty})) + require.Empty(t, u.Settings.PreferredViewers) + + // Invalid extension key rejected. + badExt := map[string]string{"../evil": "x"} + require.Error(t, patch(&PatchUserSetting{PreferredViewers: &badExt})) + + // Invalid viewer id rejected. + badID := map[string]string{"md": "../../etc"} + require.Error(t, patch(&PatchUserSetting{PreferredViewers: &badID})) + + // Oversized map rejected. + big := map[string]string{} + for i := 0; i < preferredViewersMaxEntries+1; i++ { + big[string(rune('a'+i%26))+strconv.Itoa(i)] = "v" + } + require.Error(t, patch(&PatchUserSetting{PreferredViewers: &big})) +} diff --git a/service/user/sso.go b/service/user/sso.go index 6784bd2b..4eebf411 100644 --- a/service/user/sso.go +++ b/service/user/sso.go @@ -1,6 +1,7 @@ package user import ( + "context" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -170,9 +171,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) { return } - email := strings.ToLower(strings.TrimSpace(claims.Email)) + // Profile claims resolve across standard and AD FS-style alternates; + // AD FS userinfo returns only sub, so the ID token is the primary + // source for name/email/upn (#3572). + email := firstEmailClaim(claims.Email, claims.UPN, claims.UniqueName) name := claims.Name + if name == "" { + name = strings.TrimSpace(claims.GivenName + " " + claims.FamilyName) + } preferred := claims.PreferredUsername + if preferred == "" { + preferred = claims.UniqueName + } // Some providers omit email from the ID token; fall back to userinfo. if email == "" && tokens.AccessToken != "" && discovery.UserinfoEndpoint != "" { @@ -180,12 +190,18 @@ func (service *SSOCallbackService) SSOCallback(c *gin.Context) { if err != nil { dep.Logger().Warning("SSO userinfo request failed: %s", err) } else { - email = strings.ToLower(strings.TrimSpace(info.Email)) + email = firstEmailClaim(info.Email, info.UPN, info.UniqueName) if name == "" { name = info.Name + if name == "" { + name = strings.TrimSpace(info.GivenName + " " + info.FamilyName) + } } if preferred == "" { preferred = info.PreferredUsername + if preferred == "" { + preferred = info.UniqueName + } } } } @@ -239,11 +255,15 @@ func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid SSO ticket", nil) } - u, err := dep.UserClient().GetByID(c, uid) + ctx := context.WithValue(c, inventory.LoadUserGroup{}, true) + u, err := dep.UserClient().GetByID(ctx, uid) if err != nil { return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err) } - if err := checkUserStatus(u); err != nil { + if u, err = dep.UserClient().LiftExpiredBan(c, u); err != nil { + return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) + } + if err := checkUserStatus(c, u); err != nil { return nil, err } @@ -255,9 +275,12 @@ func (service *SSOExchangeService) SSOExchange(c *gin.Context) (any, error) { func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email, name, preferred string) (*ent.User, error) { userClient := dep.UserClient() - u, err := userClient.GetByEmail(c, email) + u, err := userClient.GetByEmail(context.WithValue(c, inventory.LoadUserGroup{}, true), email) if err == nil { - if err := checkUserStatus(u); err != nil { + if u, err = userClient.LiftExpiredBan(c, u); err != nil { + return nil, serializer.NewError(serializer.CodeDBError, "Failed to lift expired ban", err) + } + if err := checkUserStatus(c, u); err != nil { return nil, err } return u, nil @@ -291,14 +314,25 @@ func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email, return newUser, nil } -func checkUserStatus(u *ent.User) error { +// banError renders the ban error. When a ban reason is configured it is +// sent as the message so the frontend can render it next to the +// localized "blocked" text (#2478). +func banError(u *ent.User, fallback string) error { + msg := fallback + if u.BanReason != "" { + msg = u.BanReason + } + return serializer.NewError(serializer.CodeUserBaned, msg, nil) +} + +func checkUserStatus(c *gin.Context, u *ent.User) error { switch u.Status { case user.StatusSysBanned, user.StatusManualBanned: - return serializer.NewError(serializer.CodeUserBaned, "User is banned", nil) + return banError(u, "User is banned") case user.StatusInactive: return serializer.NewError(serializer.CodeUserNotActivated, "User is not activated", nil) } - return nil + return checkLoginIPWhitelist(c.ClientIP(), u.Edges.Group) } // CheckEmailAllowed enforces the sign-up email filter. Shared by the classic @@ -309,8 +343,14 @@ func CheckEmailAllowed(filter *setting.EmailFilter, email string) error { return serializer.NewError(serializer.CodeParamErr, "Invalid email", nil) } - if filter.DisableSubAddress && strings.Contains(local, "+") { - return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil) + if filter.DisableSubAddress { + chars := filter.SubAddressChars + if chars == "" { + chars = "+" + } + if strings.ContainsAny(local, chars) { + return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil) + } } domain = strings.ToLower(domain) @@ -582,3 +622,16 @@ func fetchOIDCUserInfo(c *gin.Context, client request.Client, endpoint, accessTo return &info, nil } + +// firstEmailClaim returns the first candidate usable as an email address. +// AD FS upn/unique_name are accepted only when email-shaped, since +// DOMAIN\user and non-routable identifiers cannot serve as addresses. +func firstEmailClaim(candidates ...string) string { + for _, c := range candidates { + c = strings.ToLower(strings.TrimSpace(c)) + if strings.Contains(c, "@") { + return c + } + } + return "" +} diff --git a/service/user/sso_test.go b/service/user/sso_test.go index 2b0fbcdf..7450294b 100644 --- a/service/user/sso_test.go +++ b/service/user/sso_test.go @@ -3,6 +3,8 @@ package user import ( "testing" + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/setting" "github.com/stretchr/testify/require" @@ -103,6 +105,35 @@ func TestCheckEmailAllowed(t *testing.T) { }, email: "a+tag@example.com", }, + { + name: "custom sub-address chars rejected", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterDisabled, + DisableSubAddress: true, + SubAddressChars: "+-.", + }, + email: "a-tag@example.com", + wantErr: true, + code: serializer.CodeParamErr, + }, + { + name: "custom sub-address chars allow plain local part", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterDisabled, + DisableSubAddress: true, + SubAddressChars: "+-.", + }, + email: "user@example.com", + }, + { + name: "dot in domain not treated as sub-address", + filter: &setting.EmailFilter{ + Mode: setting.EmailFilterDisabled, + DisableSubAddress: true, + SubAddressChars: "+-.", + }, + email: "user@mail.example.com", + }, { name: "invalid email rejected", filter: &setting.EmailFilter{Mode: setting.EmailFilterDisabled}, @@ -135,3 +166,33 @@ func TestSanitizeSSORedirect(t *testing.T) { require.Equal(t, "/home", sanitizeSSORedirect("/home")) require.Equal(t, "/share/s/abc", sanitizeSSORedirect("/share/s/abc")) } + +func TestFirstEmailClaim(t *testing.T) { + require.Equal(t, "user@corp.com", firstEmailClaim("User@Corp.com")) + require.Equal(t, "user@corp.com", firstEmailClaim(" User@Corp.COM ")) + require.Equal(t, "user@corp.com", firstEmailClaim("", `CORP\user`, "user@corp.com")) + require.Equal(t, "", firstEmailClaim("", `CORP\user`, "not-an-email")) + require.Equal(t, "", firstEmailClaim()) +} + +func TestCheckLoginIPWhitelist(t *testing.T) { + group := func(list ...string) *ent.Group { + return &ent.Group{Settings: &types.GroupSetting{LoginIPWhitelist: list}} + } + + // Empty whitelist allows everything. + require.NoError(t, checkLoginIPWhitelist("1.2.3.4", nil)) + require.NoError(t, checkLoginIPWhitelist("1.2.3.4", group())) + + // Exact IP match. + require.NoError(t, checkLoginIPWhitelist("10.0.0.5", group("10.0.0.5", "192.168.0.0/16"))) + require.Error(t, checkLoginIPWhitelist("10.0.0.6", group("10.0.0.5"))) + + // CIDR ranges, v4 and v6. + require.NoError(t, checkLoginIPWhitelist("192.168.1.9", group("192.168.0.0/16"))) + require.NoError(t, checkLoginIPWhitelist("fd00::42", group("fd00::/8"))) + require.Error(t, checkLoginIPWhitelist("8.8.8.8", group("192.168.0.0/16", "fd00::/8"))) + + // Malformed entries are skipped rather than locking everyone out. + require.NoError(t, checkLoginIPWhitelist("1.2.3.4", group("not-an-ip", "1.2.3.4"))) +}