diff --git a/.github/workflows/beta_release.yml b/.github/workflows/beta_release.yml index 90148487..c2d66711 100644 --- a/.github/workflows/beta_release.yml +++ b/.github/workflows/beta_release.yml @@ -61,20 +61,45 @@ jobs: strategy: matrix: include: - - target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch + - target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch (exclude mips64le) hash: "md5" - - target: "linux-!(arm*)-musl*" #musl-not-arm + flags: "" + goflags: "" + - target: "linux-(mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-compat-family + hash: "md5-linux-musl-mips" + flags: "-ldflags=-linkmode external -extldflags '-static -fpic'" + goflags: "" + musl_static: "true" + - target: "linux-!(arm*|mips|mips64|mipsle|mips64le|loong64)-musl*" # musl-not-arm (exclude compat-family) hash: "md5-linux-musl" + flags: "-ldflags=-linkmode external -extldflags '-static -fpic'" + goflags: "" + musl_static: "true" - target: "linux-arm*-musl*" #musl-arm hash: "md5-linux-musl-arm" + flags: "-ldflags=-linkmode external -extldflags '-static -fpic'" + goflags: "" + musl_static: "true" - target: "windows-arm64" #win-arm64 hash: "md5-windows-arm64" + flags: "" + goflags: "" + musl_static: "false" - target: "windows7-*" #win7 hash: "md5-windows7" + flags: "" + goflags: "-tags=sqlite_cgo_compat" + musl_static: "false" - target: "android-*" #android hash: "md5-android" + flags: "" + goflags: "" + musl_static: "false" - target: "freebsd-*" #freebsd hash: "md5-freebsd" + flags: "" + goflags: "" + musl_static: "false" name: Beta Release runs-on: ubuntu-latest @@ -99,6 +124,8 @@ jobs: uses: OpenListTeam/cgo-actions@v1.2.2 with: targets: ${{ matrix.target }} + flags: ${{ matrix.flags || '-ldflags=' }} + static-link-for-musl: true musl-target-format: $os-$musl-$arch github-token: ${{ secrets.GITHUB_TOKEN }} out-dir: build @@ -110,6 +137,26 @@ jobs: github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling + env: + GOFLAGS: ${{ matrix.goflags }} + + - name: Verify musl binaries are static + if: matrix.musl_static == 'true' + run: | + set -e + shopt -s nullglob + files=(build/openlist-*-musl-*) + if [ ${#files[@]} -eq 0 ]; then + echo "No musl binaries found" + exit 1 + fi + for f in "${files[@]}"; do + if readelf -l "$f" | grep -q "Requesting program interpreter"; then + echo "Dynamic binary detected: $f" + readelf -l "$f" | grep "Requesting program interpreter" || true + exit 1 + fi + done - name: Compress run: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 312150eb..f8e46b8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,8 @@ jobs: uses: OpenListTeam/cgo-actions@v1.2.2 with: targets: ${{ matrix.target }} + flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }} + static-link-for-musl: true musl-target-format: $os-$musl-$arch github-token: ${{ secrets.GITHUB_TOKEN }} out-dir: build @@ -51,6 +53,16 @@ jobs: github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling output: openlist$ext + - name: Verify musl binary is static + if: contains(matrix.target, '-musl') + run: | + set -e + if readelf -l build/openlist | grep -q "Requesting program interpreter"; then + echo "Dynamic binary detected: build/openlist" + readelf -l build/openlist | grep "Requesting program interpreter" || true + exit 1 + fi + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/issue_pr_comment.yml b/.github/workflows/issue_pr_comment.yml index 44f6e0af..c618485f 100644 --- a/.github/workflows/issue_pr_comment.yml +++ b/.github/workflows/issue_pr_comment.yml @@ -20,7 +20,7 @@ jobs: with: script: | const issueBody = context.payload.issue.body || ""; - const unchecked = /- \[ \] /.test(issueBody); + const unchecked = /- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody); let comment = "感谢您联系OpenList。我们会尽快回复您。\n"; comment += "Thanks for contacting OpenList. We will reply to you as soon as possible.\n\n"; if (unchecked) { diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ff558d64 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,89 @@ +# Security Policy + +## Supported Versions + +Only the latest stable release receives security patches. We strongly recommend always keeping OpenList up to date. + +| Version | Supported | +| -------------------- | ------------------ | +| Latest stable (v4.x) | :white_check_mark: | +| Older versions | :x: | + +## Reporting a Vulnerability + +**Please do NOT report security vulnerabilities through public GitHub Issues.** + +If you discover a security vulnerability in OpenList, please report it responsibly by using one of the following channels: + +- **GitHub Private Security Advisory** (preferred): [Submit here](https://github.com/OpenListTeam/OpenList/security/advisories/new) +- **Telegram**: Contact a maintainer privately via [@OpenListTeam](https://t.me/OpenListTeam) + +When reporting, please include as much of the following as possible: + +- A description of the vulnerability and its potential impact +- The affected version(s) +- Step-by-step instructions to reproduce the issue +- Any proof-of-concept code or screenshots (if applicable) +- Suggested mitigation or fix (optional but appreciated) + +## Security Best Practices for Users + +To keep your OpenList instance secure: + +- Always update to the latest release. +- Use a strong, unique admin password and change it after first login. +- Enable HTTPS (TLS) for your deployment — do **not** expose OpenList over plain HTTP on the public internet. +- Limit exposed ports using a reverse proxy (e.g., Nginx, Caddy). +- Set up access controls and avoid enabling guest access unless necessary. +- Regularly review mounted storage permissions and revoke unused API tokens. +- When using Docker, avoid running the container as root if possible. + +## Acknowledgments + +We sincerely thank all security researchers and community members who responsibly disclose vulnerabilities and help make OpenList safer for everyone. + +--- + +# 安全政策 + +## 支持的版本 + +我们仅对最新稳定版本提供安全补丁。强烈建议始终保持 OpenList 为最新版本。 + +| 版本 | 是否支持 | +| ------------------ | ------------------ | +| 最新稳定版(v4.x) | :white_check_mark: | +| 旧版本 | :x: | + +## 报告漏洞 + +**请勿通过公开的 GitHub Issues 报告安全漏洞。** + +如果您在 OpenList 中发现安全漏洞,请通过以下渠道之一负责任地进行报告: + +- **GitHub 私密安全公告**(推荐):[点击提交](https://github.com/OpenListTeam/OpenList/security/advisories/new) +- **Telegram**:通过 [@OpenListTeam](https://t.me/OpenListTeam) 私信联系维护者 + +报告时,请尽量提供以下信息: + +- 漏洞描述及其潜在影响 +- 受影响的版本 +- 复现问题的详细步骤 +- 概念验证代码或截图(如有) +- 建议的缓解措施或修复方案(可选,但非常欢迎) + +## 用户安全最佳实践 + +为保障您的 OpenList 实例安全: + +- 始终更新至最新版本。 +- 使用强且唯一的管理员密码,并在首次登录后立即修改。 +- 为您的部署启用 HTTPS(TLS)—— **请勿**在公网上以明文 HTTP 方式暴露 OpenList。 +- 使用反向代理(如 Nginx、Caddy)限制对外暴露的端口。 +- 配置访问控制,非必要情况下不要开启访客访问。 +- 定期检查已挂载存储的权限,并撤销未使用的 API 令牌。 +- 使用 Docker 部署时,尽可能避免以 root 用户运行容器。 + +## 致谢 + +我们衷心感谢所有负责任地披露漏洞、帮助 OpenList 变得更加安全的安全研究人员和社区成员。 diff --git a/build.sh b/build.sh index 53559a1d..32ef2d06 100644 --- a/build.sh +++ b/build.sh @@ -31,7 +31,7 @@ else # webVersion=$(eval "curl -fsSL --max-time 2 $githubAuthArgs \"https://api.github.com/repos/$frontendRepo/releases/latest\"" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g') fi -webVersion=4.1.9 +webVersion=4.2.1 echo "backend version: $version" echo "frontend version: $webVersion" @@ -50,6 +50,53 @@ ldflags="\ -X 'github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=$webVersion' \ " +# Keep sqlite driver tag selection centralized to avoid target drift. +GetBuildTagsForTarget() { + local target="$1" + case "$target" in + linux-loong64|linux-mips|linux-mips64|linux-mips64le|linux-mipsle|linux-musl-loong64|linux-musl-mips|linux-musl-mips64|linux-musl-mips64le|linux-musl-mipsle|windows-386|windows7-386|windows7-amd64) + echo "jsoniter,sqlite_cgo_compat" + ;; + *) + echo "jsoniter" + ;; + esac +} + +# Keep musl static link flags centralized for all musl build paths. +GetMuslStaticLdflags() { + echo "-linkmode external -extldflags '-static -fpic' $ldflags" +} + +# Fail fast if a musl build artifact is not fully static. +AssertStaticBinary() { + local binary="$1" + if [ ! -f "$binary" ]; then + echo "Error: binary not found: $binary" + return 1 + fi + + if command -v readelf >/dev/null 2>&1; then + if readelf -l "$binary" 2>/dev/null | grep -q "Requesting program interpreter"; then + echo "Error: binary is not fully static: $binary" + readelf -l "$binary" | grep "Requesting program interpreter" || true + return 1 + fi + return 0 + fi + + if command -v file >/dev/null 2>&1; then + if file "$binary" | grep -qi "dynamically linked"; then + echo "Error: binary is dynamically linked: $binary" + file "$binary" + return 1 + fi + return 0 + fi + + echo "Warning: readelf/file not found, skip static verification for $binary" + return 0 +} FetchWebRolling() { pre_release_json=$(eval "curl -fsSL --max-time 2 $githubAuthArgs -H \"Accept: application/vnd.github.v3+json\" \"https://api.github.com/repos/$frontendRepo/releases/tags/rolling\"") pre_release_assets=$(echo "$pre_release_json" | jq -r '.assets[].browser_download_url') @@ -112,6 +159,7 @@ BuildWin7() { # Build for both 386 and amd64 architectures for arch in "386" "amd64"; do echo "building for windows7-${arch}" + build_tags=$(GetBuildTagsForTarget "windows7-${arch}") export GOOS=windows export GOARCH=${arch} export CGO_ENABLED=1 @@ -126,14 +174,14 @@ BuildWin7() { fi # Use the patched Go compiler for Win7 compatibility - $(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags=jsoniter . + $(pwd)/go-win7/bin/go build -o "${1}-${arch}.exe" -ldflags="$ldflags" -tags="$build_tags" . done } BuildDev() { rm -rf .git/ mkdir -p "dist" - muslflags="--extldflags '-static -fpic' $ldflags" + muslflags="$(GetMuslStaticLdflags)" BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/" FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross) for i in "${FILES[@]}"; do @@ -151,7 +199,8 @@ BuildDev() { export GOARCH=${os_arch##*-} export CC=${cgo_cc} export CGO_ENABLED=1 - go build -o ./dist/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter . + CGO_LDFLAGS="-static" go build -o ./dist/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter . + AssertStaticBinary "./dist/$appName-$os_arch" done xgo -targets=windows/amd64,darwin/amd64,darwin/arm64 -out "$appName" -ldflags="$ldflags" -tags=jsoniter . mv "$appName"-* dist @@ -185,7 +234,7 @@ BuildDockerMultiplatform() { # run PrepareBuildDockerMusl before build export PATH=$PATH:$PWD/build/musl-libs/bin - docker_lflags="--extldflags '-static -fpic' $ldflags" + docker_lflags="$(GetMuslStaticLdflags)" export CGO_ENABLED=1 OS_ARCHES=(linux-amd64 linux-arm64) @@ -195,11 +244,28 @@ BuildDockerMultiplatform() { cgo_cc=${CGO_ARGS[$i]} os=${os_arch%%-*} arch=${os_arch##*-} + build_tags=$(GetBuildTagsForTarget "$os_arch") export GOOS=$os export GOARCH=$arch export CC=${cgo_cc} echo "building for $os_arch" - go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags=jsoniter . + CGO_LDFLAGS="-static" go build -o build/$os/$arch/"$appName" -ldflags="$docker_lflags" -tags="$build_tags" . + AssertStaticBinary "build/$os/$arch/$appName" + done + + DOCKER_ARM_ARCHES=(linux-arm/v6 linux-arm/v7) + CGO_ARGS=(armv6-linux-musleabihf-gcc armv7l-linux-musleabihf-gcc) + GO_ARM=(6 7) + export GOOS=linux + export GOARCH=arm + for i in "${!DOCKER_ARM_ARCHES[@]}"; do + docker_arch=${DOCKER_ARM_ARCHES[$i]} + cgo_cc=${CGO_ARGS[$i]} + export GOARM=${GO_ARM[$i]} + export CC=${cgo_cc} + echo "building for $docker_arch" + CGO_LDFLAGS="-static" go build -o build/${docker_arch%%-*}/${docker_arch##*-}/"$appName" -ldflags="$docker_lflags" -tags=jsoniter . + AssertStaticBinary "build/${docker_arch%%-*}/${docker_arch##*-}/$appName" done } @@ -224,6 +290,8 @@ BuildLoongGLIBC() { local target_abi="$2" local output_file="$1" local oldWorldGoVersion="1.25.0" + local loong_tags + loong_tags=$(GetBuildTagsForTarget "linux-loong64") if [ "$target_abi" = "abi1.0" ]; then echo building for linux-loong64-abi1.0 @@ -298,7 +366,7 @@ BuildLoongGLIBC() { CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \ CGO_ENABLED=1 \ GOCACHE="$abi1_cache_dir" \ - $(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then + $(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then echo "Error: Build failed with patched Go compiler" echo "Attempting retry with cache cleanup..." env GOCACHE="$abi1_cache_dir" $(pwd)/go-loong64-abi1.0/bin/go clean -cache @@ -307,7 +375,7 @@ BuildLoongGLIBC() { CXX="$(pwd)/gcc8-loong64-abi1.0/bin/loongarch64-linux-gnu-g++" \ CGO_ENABLED=1 \ GOCACHE="$abi1_cache_dir" \ - $(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then + $(pwd)/go-loong64-abi1.0/bin/go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then echo "Error: Build failed again after cache cleanup" echo "Build environment details:" echo "GOOS=linux" @@ -353,11 +421,11 @@ BuildLoongGLIBC() { # Use standard Go compiler for new-world build echo "Building with standard Go compiler for new-world ABI2.0..." - if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then + if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then echo "Error: Build failed with standard Go compiler" echo "Attempting retry with cache cleanup..." go clean -cache - if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags=jsoniter .; then + if ! go build -a -o "$output_file" -ldflags="$ldflags" -tags="$loong_tags" .; then echo "Error: Build failed again after cache cleanup" echo "Build environment details:" echo "GOOS=$GOOS" @@ -375,8 +443,9 @@ BuildLoongGLIBC() { BuildReleaseLinuxMusl() { mkdir -p "build" - muslflags="--extldflags '-static -fpic' $ldflags" + muslflags="$(GetMuslStaticLdflags)" BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/" + # Keep mips-family targets enabled; sqlite driver selection is handled by Go build tags. FILES=(x86_64-linux-musl-cross aarch64-linux-musl-cross mips-linux-musl-cross mips64-linux-musl-cross mips64el-linux-musl-cross mipsel-linux-musl-cross powerpc64le-linux-musl-cross s390x-linux-musl-cross loongarch64-linux-musl-cross) for i in "${FILES[@]}"; do url="${BASE}${i}.tgz" @@ -389,18 +458,20 @@ BuildReleaseLinuxMusl() { for i in "${!OS_ARCHES[@]}"; do os_arch=${OS_ARCHES[$i]} cgo_cc=${CGO_ARGS[$i]} + build_tags=$(GetBuildTagsForTarget "$os_arch") echo building for ${os_arch} export GOOS=${os_arch%%-*} export GOARCH=${os_arch##*-} export CC=${cgo_cc} export CGO_ENABLED=1 - go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter . + CGO_LDFLAGS="-static" go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags="$build_tags" . + AssertStaticBinary "./build/$appName-$os_arch" done } BuildReleaseLinuxMuslArm() { mkdir -p "build" - muslflags="--extldflags '-static -fpic' $ldflags" + muslflags="$(GetMuslStaticLdflags)" BASE="https://github.com/OpenListTeam/musl-compilers/releases/latest/download/" FILES=(arm-linux-musleabi-cross arm-linux-musleabihf-cross armel-linux-musleabi-cross armel-linux-musleabihf-cross armv5l-linux-musleabi-cross armv5l-linux-musleabihf-cross armv6-linux-musleabi-cross armv6-linux-musleabihf-cross armv7l-linux-musleabihf-cross armv7m-linux-musleabi-cross armv7r-linux-musleabihf-cross) for i in "${FILES[@]}"; do @@ -422,7 +493,8 @@ BuildReleaseLinuxMuslArm() { export CC=${cgo_cc} export CGO_ENABLED=1 export GOARM=${arm} - go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter . + CGO_LDFLAGS="-static" go build -o ./build/$appName-$os_arch -ldflags="$muslflags" -tags=jsoniter . + AssertStaticBinary "./build/$appName-$os_arch" done } diff --git a/drivers/115/util.go b/drivers/115/util.go index cd008507..f5b27c76 100644 --- a/drivers/115/util.go +++ b/drivers/115/util.go @@ -20,6 +20,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" + netutil "github.com/OpenListTeam/OpenList/v4/internal/net" "github.com/OpenListTeam/OpenList/v4/pkg/http_range" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/aliyun/aliyun-oss-go-sdk/oss" @@ -279,7 +280,7 @@ func (c *Pan115) UploadByOSS(ctx context.Context, params *driver115.UploadOSSPar if err != nil { return nil, err } - ossClient, err := oss.New(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret) + ossClient, err := netutil.NewOSSClient(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret) if err != nil { return nil, err } @@ -339,7 +340,7 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload return nil, err } - if ossClient, err = oss.New(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret, oss.EnableMD5(true), oss.EnableCRC(true)); err != nil { + if ossClient, err = netutil.NewOSSClient(driver115.OSSEndpoint, ossToken.AccessKeyID, ossToken.AccessKeySecret, oss.EnableMD5(true), oss.EnableCRC(true)); err != nil { return nil, err } diff --git a/drivers/115_open/upload.go b/drivers/115_open/upload.go index 3575678c..d02640e2 100644 --- a/drivers/115_open/upload.go +++ b/drivers/115_open/upload.go @@ -9,6 +9,7 @@ import ( sdk "github.com/OpenListTeam/115-sdk-go" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" + netutil "github.com/OpenListTeam/OpenList/v4/internal/net" streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/aliyun/aliyun-oss-go-sdk/oss" @@ -36,7 +37,7 @@ func calPartSize(fileSize int64) int64 { } func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error { - ossClient, err := oss.New(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken)) + ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken)) if err != nil { return err } @@ -70,7 +71,7 @@ func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp // } func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer, up driver.UpdateProgress, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error { - ossClient, err := oss.New(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken)) + ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken)) if err != nil { return err } diff --git a/drivers/123/util.go b/drivers/123/util.go index 3476f031..09833132 100644 --- a/drivers/123/util.go +++ b/drivers/123/util.go @@ -82,7 +82,6 @@ type Params struct { XChannel string XAppVersion string } - func signPath(path string, os string, version string) (k string, v string) { table := []byte{'a', 'd', 'e', 'f', 'g', 'h', 'l', 'm', 'y', 'i', 'j', 'n', 'o', 'p', 'k', 'q', 'r', 's', 't', 'u', 'b', 'c', 'v', 'w', 's', 'z'} random := fmt.Sprintf("%.f", math.Round(1e7*rand.Float64())) diff --git a/drivers/123_open/driver.go b/drivers/123_open/driver.go index 9608cedf..78ff272b 100644 --- a/drivers/123_open/driver.go +++ b/drivers/123_open/driver.go @@ -34,8 +34,8 @@ func (d *Open123) Init(ctx context.Context) error { d.UploadThread = 3 } - if d.RefreshToken != "" { - // refresh token 直接主动刷新 + if (d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0) || (d.ClientID != "" && d.ClientSecret != "") { + // proactive refresh by renewapi or client credentials d.AccessToken = "" d.tm = &tokenManager{} } else { @@ -181,6 +181,22 @@ func (d *Open123) Put(ctx context.Context, dstDir model.Obj, file model.FileStre if err != nil { return nil, fmt.Errorf("parse parentFileID error: %v", err) } + + // 尝试 SHA1 秒传 + sha1Hash := file.GetHash().GetHash(utils.SHA1) + if len(sha1Hash) == utils.SHA1.Width { + resp, err := d.sha1Reuse(parentFileId, file.GetName(), sha1Hash, file.GetSize(), 2) + if err == nil && resp.Data.Reuse { + return File{ + FileName: file.GetName(), + Size: file.GetSize(), + FileId: resp.Data.FileID, + Type: 2, + SHA1: sha1Hash, + }, nil + } + } + // etag 文件md5 etag := file.GetHash().GetHash(utils.MD5) if len(etag) < utils.MD5.Width { diff --git a/drivers/123_open/meta.go b/drivers/123_open/meta.go index 5481ef35..d23f8eec 100644 --- a/drivers/123_open/meta.go +++ b/drivers/123_open/meta.go @@ -6,9 +6,6 @@ import ( ) type Addition struct { - // refresh_token方式的AccessToken 【对个人开发者暂未开放】 - RefreshToken string `json:"RefreshToken" required:"false"` - // 通过 https://www.123pan.com/developer 申请 ClientID string `json:"ClientID" required:"false"` ClientSecret string `json:"ClientSecret" required:"false"` @@ -16,6 +13,13 @@ type Addition struct { // 直接写入AccessToken, AccessToken有过期时间,不建议直接填写 AccessToken string `json:"AccessToken" required:"false"` + // refresh_token方式的AccessToken 【对个人开发者暂未开放】 + RefreshToken string `json:"RefreshToken" required:"false"` + + // 使用在线API + UseOnlineAPI bool `json:"use_online_api" default:"true"` + APIAddress string `json:"api_url_address" default:"https://api.oplist.org/123cloud/renewapi"` + // 用户名+密码方式登录的AccessToken可以兼容 //Username string `json:"username" required:"false"` //Password string `json:"password" required:"false"` diff --git a/drivers/123_open/token.go b/drivers/123_open/token.go index 3c5c416c..a628d22f 100644 --- a/drivers/123_open/token.go +++ b/drivers/123_open/token.go @@ -1,7 +1,6 @@ package _123_open import ( - "encoding/json" "errors" "fmt" "net/http" @@ -13,10 +12,16 @@ import ( ) var ( - AccessToken = "https://open-api.123pan.com/api/v1/access_token" - RefreshToken = "https://open-api.123pan.com/api/v1/oauth2/access_token" + AccessToken = "https://open-api.123pan.com/api/v1/access_token" ) +func expiresInToExpiredAt(expiresIn int64) (time.Time, error) { + if expiresIn <= 0 { + return time.Time{}, errors.New("invalid expires_in from official API") + } + return time.Now().UTC().Add(time.Duration(expiresIn) * time.Second), nil +} + type tokenManager struct { // accessToken string expiredAt time.Time @@ -43,73 +48,82 @@ func (d *Open123) getAccessToken(forceRefresh bool) (string, error) { } func (d *Open123) flushAccessToken() error { - // directly send request to avoid deadlock - req := base.RestyClient.R() - req.SetHeaders(map[string]string{ - "authorization": "Bearer " + d.AccessToken, - "platform": "open_platform", - "Content-Type": "application/json", - }) + // Official app renewapi response contains access_token, refresh_token and expires_in. + if d.UseOnlineAPI && d.RefreshToken != "" && len(d.APIAddress) > 0 { + var resp RefreshTokenResp + _, err := base.RestyClient.R(). + SetResult(&resp). + SetQueryParams(map[string]string{ + "refresh_ui": d.RefreshToken, + "server_use": "true", + "driver_txt": "123cloud_oa", + }). + Get(d.APIAddress) + if err != nil { + return err + } - if d.ClientID != "" { - if d.RefreshToken != "" { - var resp RefreshTokenResp - req.SetQueryParam("client_id", d.ClientID) - if d.ClientSecret != "" { - req.SetQueryParam("client_secret", d.ClientSecret) + if resp.AccessToken == "" || resp.RefreshToken == "" { + errMessage := resp.ErrorDescription + if errMessage == "" { + errMessage = resp.Text } - req.SetQueryParam("grant_type", "refresh_token") - req.SetQueryParam("refresh_token", d.RefreshToken) - req.SetResult(&resp) - res, err := req.Execute(http.MethodPost, RefreshToken) - if err != nil { - return err + if errMessage == "" { + errMessage = resp.Message } - body := res.Body() - var baseResp BaseResp - if err = json.Unmarshal(body, &baseResp); err != nil { - return err + if errMessage == "" { + errMessage = resp.Error } - if baseResp.Code != 0 { - return fmt.Errorf("get access token failed: %s", baseResp.Message) + if errMessage != "" { + return fmt.Errorf("failed to refresh token: %s", errMessage) } + return fmt.Errorf("empty access_token or refresh_token returned from official API") + } + expiredAt, err := expiresInToExpiredAt(resp.ExpiresIn) + if err != nil { + return err + } - d.AccessToken = resp.AccessToken - // add token expire time - d.tm.expiredAt = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second) - d.RefreshToken = resp.RefreshToken - op.MustSaveDriverStorage(d) - d.tm.blockRefresh = false - return nil - } else if d.ClientSecret != "" { - var resp AccessTokenResp - req.SetBody(base.Json{ - "clientID": d.ClientID, - "clientSecret": d.ClientSecret, - }) - req.SetResult(&resp) - res, err := req.Execute(http.MethodPost, AccessToken) - if err != nil { - return err - } - body := res.Body() - var baseResp BaseResp - if err = json.Unmarshal(body, &baseResp); err != nil { - return err - } - if baseResp.Code != 0 { - return fmt.Errorf("get access token failed: %s", baseResp.Message) - } - d.AccessToken = resp.Data.AccessToken - // parse token expire time - d.tm.expiredAt, err = time.Parse(time.RFC3339, resp.Data.ExpiredAt) - if err != nil { - return fmt.Errorf("parse expire time failed: %w", err) - } - op.MustSaveDriverStorage(d) - d.tm.blockRefresh = false - return nil + d.AccessToken = resp.AccessToken + d.RefreshToken = resp.RefreshToken + d.tm.expiredAt = expiredAt + op.MustSaveDriverStorage(d) + d.tm.blockRefresh = false + return nil + } + + // Developer API response contains code/message/data(accessToken, expiredAt). + if d.ClientID != "" && d.ClientSecret != "" { + req := base.RestyClient.R() + req.SetHeaders(map[string]string{ + "platform": "open_platform", + "Content-Type": "application/json", + }) + var resp AccessTokenResp + req.SetBody(base.Json{ + "clientID": d.ClientID, + "clientSecret": d.ClientSecret, + }) + req.SetResult(&resp) + _, err := req.Execute(http.MethodPost, AccessToken) + if err != nil { + return err + } + if resp.Code != 0 { + return fmt.Errorf("get access token failed: %s", resp.Message) + } + if resp.Data.AccessToken == "" || resp.Data.ExpiredAt == "" { + return errors.New("invalid token payload from developer API") + } + expiredAt, err := time.Parse(time.RFC3339, resp.Data.ExpiredAt) + if err != nil { + return fmt.Errorf("parse expire time failed: %w", err) } + d.AccessToken = resp.Data.AccessToken + d.tm.expiredAt = expiredAt.UTC() + op.MustSaveDriverStorage(d) + d.tm.blockRefresh = false + return nil } return errors.New("no valid authentication method available") } diff --git a/drivers/123_open/types.go b/drivers/123_open/types.go index 8745ff79..b6e507ac 100644 --- a/drivers/123_open/types.go +++ b/drivers/123_open/types.go @@ -58,9 +58,13 @@ type File struct { Category int `json:"category"` Status int `json:"status"` Trashed int `json:"trashed"` + SHA1 string } func (f File) GetHash() utils.HashInfo { + if len(f.SHA1) == utils.SHA1.Width && len(f.Etag) != utils.MD5.Width { + return utils.NewHashInfo(utils.SHA1, f.SHA1) + } return utils.NewHashInfo(utils.MD5, f.Etag) } @@ -121,11 +125,14 @@ type AccessTokenResp struct { } type RefreshTokenResp struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - RefreshToken string `json:"refresh_token"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + Code int `json:"code"` + Message string `json:"message"` + ErrorDescription string `json:"error_description"` + Error string `json:"error"` + Text string `json:"text"` } type UserInfoResp struct { @@ -190,6 +197,14 @@ type UploadCompleteResp struct { } `json:"data"` } +type SHA1ReuseResp struct { + BaseResp + Data struct { + FileID int64 `json:"fileID"` + Reuse bool `json:"reuse"` + } `json:"data"` +} + type OfflineDownloadResp struct { BaseResp Data struct { diff --git a/drivers/123_open/upload.go b/drivers/123_open/upload.go index 90cff90d..0e03684e 100644 --- a/drivers/123_open/upload.go +++ b/drivers/123_open/upload.go @@ -183,3 +183,21 @@ func (d *Open123) complete(preuploadID string) (*UploadCompleteResp, error) { } return &resp, nil } + +// SHA1 秒传 +func (d *Open123) sha1Reuse(parentFileID int64, filename string, sha1Hash string, size int64, duplicate int) (*SHA1ReuseResp, error) { + var resp SHA1ReuseResp + _, err := d.Request(UploadSHA1Reuse, http.MethodPost, func(req *resty.Request) { + req.SetBody(base.Json{ + "parentFileID": parentFileID, + "filename": filename, + "sha1": strings.ToLower(sha1Hash), + "size": size, + "duplicate": duplicate, + }) + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} diff --git a/drivers/123_open/util.go b/drivers/123_open/util.go index 5d961d5c..1b6eea2d 100644 --- a/drivers/123_open/util.go +++ b/drivers/123_open/util.go @@ -21,16 +21,17 @@ import ( var ( // 不同情况下获取的AccessTokenQPS限制不同 如下模块化易于拓展 Api = "https://open-api.123pan.com" - UserInfo = InitApiInfo(Api+"/api/v1/user/info", 1) - FileList = InitApiInfo(Api+"/api/v2/file/list", 3) - DownloadInfo = InitApiInfo(Api+"/api/v1/file/download_info", 5) - DirectLink = InitApiInfo(Api+"/api/v1/direct-link/url", 5) - Mkdir = InitApiInfo(Api+"/upload/v1/file/mkdir", 2) - Move = InitApiInfo(Api+"/api/v1/file/move", 1) - Rename = InitApiInfo(Api+"/api/v1/file/name", 1) - Trash = InitApiInfo(Api+"/api/v1/file/trash", 2) - UploadCreate = InitApiInfo(Api+"/upload/v2/file/create", 2) - UploadComplete = InitApiInfo(Api+"/upload/v2/file/upload_complete", 0) + UserInfo = InitApiInfo(Api+"/api/v1/user/info", 1) + FileList = InitApiInfo(Api+"/api/v2/file/list", 3) + DownloadInfo = InitApiInfo(Api+"/api/v1/file/download_info", 5) + DirectLink = InitApiInfo(Api+"/api/v1/direct-link/url", 5) + Mkdir = InitApiInfo(Api+"/upload/v1/file/mkdir", 2) + Move = InitApiInfo(Api+"/api/v1/file/move", 1) + Rename = InitApiInfo(Api+"/api/v1/file/name", 1) + Trash = InitApiInfo(Api+"/api/v1/file/trash", 2) + UploadCreate = InitApiInfo(Api+"/upload/v2/file/create", 2) + UploadComplete = InitApiInfo(Api+"/upload/v2/file/upload_complete", 0) + UploadSHA1Reuse = InitApiInfo(Api+"/upload/v2/file/sha1_reuse", 2) OfflineDownload = InitApiInfo(Api+"/api/v1/offline/download", 1) OfflineDownloadProcess = InitApiInfo(Api+"/api/v1/offline/download/process", 5) diff --git a/drivers/189pc/extension.go b/drivers/189pc/extension.go index 170aae47..6359ca58 100644 --- a/drivers/189pc/extension.go +++ b/drivers/189pc/extension.go @@ -10,11 +10,11 @@ import ( "errors" "fmt" "github.com/OpenListTeam/OpenList/v4/drivers/base" - "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/casfile" + "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/model" - "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/pkg/cron" "github.com/OpenListTeam/OpenList/v4/pkg/utils" log "github.com/sirupsen/logrus" @@ -51,16 +51,16 @@ var restoreTransferredCASFromInfo = func(ctx context.Context, y *Cloud189PC, dst return y.restoreSourceFromCASInfo(ctx, dstDir, casFileName, info) } -var restoreTransferredCASAndLink = func(ctx context.Context, y *Cloud189PC, obj model.Obj) (*model.Link, error) { +var restoreTransferredCASAndLink = func(ctx context.Context, y *Cloud189PC, obj model.Obj) (*model.Link, model.Obj, error) { casStream, err := openTransferredCASStream(ctx, y, obj) if err != nil { - return nil, err + return nil, nil, err } defer casStream.Close() info, err := readTransferredCASInfo(casStream) if err != nil { - return nil, err + return nil, nil, err } // Force payload-name semantics for this restore path, regardless of the driver's config. @@ -75,15 +75,19 @@ var restoreTransferredCASAndLink = func(ctx context.Context, y *Cloud189PC, obj } restoredObj, err := restoreTransferredCASFromInfo(ctx, forcedDriver, dstDir, obj.GetName(), info) if err != nil { - return nil, err + return nil, nil, err } - return linkTransferObj(ctx, y, restoredObj) + link, err := linkTransferObj(ctx, y, restoredObj) + if err != nil { + return nil, nil, err + } + return link, restoredObj, nil } func cloneDriverForCASRestore(y *Cloud189PC) *Cloud189PC { // Explicit field copy so we can keep sync.Map at its zero value (sync.Map must not be copied). return &Cloud189PC{ - Storage: y.Storage, + Storage: y.Storage, Addition: y.Addition, identity: y.identity, @@ -107,17 +111,15 @@ func cloneDriverForCASRestore(y *Cloud189PC) *Cloud189PC { } } -func (y *Cloud189PC) linkTransferredShareFile(ctx context.Context, transferFile model.Obj) (*model.Link, error) { +func (y *Cloud189PC) resolveTransferredShareFile(ctx context.Context, transferFile model.Obj) (*model.Link, model.Obj, error) { if strings.HasSuffix(strings.ToLower(transferFile.GetName()), ".cas") { return restoreTransferredCASAndLink(ctx, y, transferFile) } - return linkTransferObj(ctx, y, transferFile) -} - -func shouldScheduleTempCleanupForTransferredFile(transferFile model.Obj) bool { - // For share transfers, keep transferred .cas files because they may be needed for later inspection/debugging - // and because the restore flow must not delete the .cas file. - return !strings.HasSuffix(strings.ToLower(transferFile.GetName()), ".cas") + link, err := linkTransferObj(ctx, y, transferFile) + if err != nil { + return nil, nil, err + } + return link, transferFile, nil } func (y *Cloud189PC) createTempDir(ctx context.Context) error { @@ -279,28 +281,30 @@ func (y *Cloud189PC) Transfer(ctx context.Context, shareId int, fileId string, f } log.Debug("get new file link") - link, err := y.linkTransferredShareFile(ctx, transferFile) + link, cleanupTarget, err := y.resolveTransferredShareFile(ctx, transferFile) - if shouldScheduleTempCleanupForTransferredFile(transferFile) { + if cleanupTarget != nil { go func() { delayTime := setting.GetInt(conf.DeleteDelayTime, 900) if delayTime == 0 { return } - log.Infof("[%v] Delete 189 temp file %v after %v seconds.", y.ID, fileId, delayTime) + cleanupName := cleanupTarget.GetName() + cleanupID := cleanupTarget.GetID() + log.Infof("[%v] Delete 189 temp file %v after %v seconds.", y.ID, cleanupID, delayTime) time.Sleep(time.Duration(delayTime) * time.Second) - log.Infof("[%v] Delete 189 temp file: %v %v", y.ID, fileId, fileName) - removeErr := y.Remove(ctx, transferFile) + log.Infof("[%v] Delete 189 temp file: %v %v", y.ID, cleanupID, cleanupName) + removeErr := y.Remove(ctx, cleanupTarget) if removeErr != nil { - log.Infof("[%v] 天翼云盘删除文件:%s失败: %v", y.ID, fileName, removeErr) + log.Infof("[%v] 天翼云盘删除文件:%s失败: %v", y.ID, cleanupName, removeErr) return } - log.Debugf("[%v] 已删除天翼云盘下的文件: %v", y.ID, fileName) + log.Debugf("[%v] 已删除天翼云盘下的文件: %v", y.ID, cleanupName) _, removeErr = y.CreateBatchTask("CLEAR_RECYCLE", "", "", nil, BatchTaskInfo{ - FileId: transferFile.GetID(), - FileName: transferFile.GetName(), + FileId: cleanupID, + FileName: cleanupName, IsFolder: 0, }) if removeErr != nil { diff --git a/drivers/189pc/extension_test.go b/drivers/189pc/extension_test.go index 91f502da..5d0083ad 100644 --- a/drivers/189pc/extension_test.go +++ b/drivers/189pc/extension_test.go @@ -22,19 +22,19 @@ type stubFileStreamer struct { } func (s *stubFileStreamer) Read(_ []byte) (int, error) { return 0, errors.New("unexpected read") } -func (s *stubFileStreamer) GetSize() int64 { return 0 } -func (s *stubFileStreamer) GetName() string { return s.name } -func (s *stubFileStreamer) ModTime() time.Time { return time.Time{} } -func (s *stubFileStreamer) CreateTime() time.Time { return time.Time{} } -func (s *stubFileStreamer) IsDir() bool { return false } -func (s *stubFileStreamer) GetHash() utils.HashInfo { return utils.HashInfo{} } -func (s *stubFileStreamer) GetID() string { return "" } -func (s *stubFileStreamer) GetPath() string { return "" } -func (s *stubFileStreamer) GetMimetype() string { return "" } -func (s *stubFileStreamer) NeedStore() bool { return false } -func (s *stubFileStreamer) IsForceStreamUpload() bool { return false } -func (s *stubFileStreamer) GetExist() model.Obj { return nil } -func (s *stubFileStreamer) SetExist(model.Obj) {} +func (s *stubFileStreamer) GetSize() int64 { return 0 } +func (s *stubFileStreamer) GetName() string { return s.name } +func (s *stubFileStreamer) ModTime() time.Time { return time.Time{} } +func (s *stubFileStreamer) CreateTime() time.Time { return time.Time{} } +func (s *stubFileStreamer) IsDir() bool { return false } +func (s *stubFileStreamer) GetHash() utils.HashInfo { return utils.HashInfo{} } +func (s *stubFileStreamer) GetID() string { return "" } +func (s *stubFileStreamer) GetPath() string { return "" } +func (s *stubFileStreamer) GetMimetype() string { return "" } +func (s *stubFileStreamer) NeedStore() bool { return false } +func (s *stubFileStreamer) IsForceStreamUpload() bool { return false } +func (s *stubFileStreamer) GetExist() model.Obj { return nil } +func (s *stubFileStreamer) SetExist(model.Obj) {} func (s *stubFileStreamer) RangeRead(http_range.Range) (io.Reader, error) { return nil, errors.New("unexpected rangeread") } @@ -43,7 +43,7 @@ func (s *stubFileStreamer) CacheFullAndWriter(*model.UpdateProgress, io.Writer) } func (s *stubFileStreamer) GetFile() model.File { return nil } -func TestLinkTransferredShareFile_NonCASUsesDirectLinkSeam(t *testing.T) { +func TestResolveTransferredShareFile_NonCASUsesDirectLinkSeam(t *testing.T) { driver := &Cloud189PC{} nonCAS := &Cloud189File{Name: "movie.mkv"} @@ -60,24 +60,28 @@ func TestLinkTransferredShareFile_NonCASUsesDirectLinkSeam(t *testing.T) { linkSeamMu.Unlock() }) - link, err := driver.linkTransferredShareFile(context.Background(), nonCAS) + link, cleanupObj, err := driver.resolveTransferredShareFile(context.Background(), nonCAS) if err != nil { t.Fatalf("link non-cas transfer: %v", err) } if link.URL != "https://example.com/direct" { t.Fatalf("expected direct link, got %q", link.URL) } + if cleanupObj != nonCAS { + t.Fatalf("expected transferred object as cleanup target, got %#v", cleanupObj) + } if directCalls != 1 { t.Fatalf("expected direct link seam once, got %d", directCalls) } } -func TestLinkTransferredShareFile_CASRestoresPayloadNameEvenWhenDriverUsesCurrentName(t *testing.T) { +func TestResolveTransferredShareFile_CASRestoresPayloadNameEvenWhenDriverUsesCurrentName(t *testing.T) { driver := &Cloud189PC{ Addition: Addition{RestoreSourceUseCurrentName: true}, TempDirId: "temp-dir-id", } casObj := &Cloud189File{Name: "renamed.mkv.cas"} + restoredObj := &Cloud189File{ID: "restored-id", Name: "payload.mkv"} openCalls := 0 readCalls := 0 @@ -111,7 +115,7 @@ func TestLinkTransferredShareFile_CASRestoresPayloadNameEvenWhenDriverUsesCurren if info == nil || info.Name != "payload.mkv" { t.Fatalf("expected payload info, got %#v", info) } - return &Cloud189File{ID: "restored-id", Name: "payload.mkv"}, nil + return restoredObj, nil } linkTransferObj = func(ctx context.Context, y *Cloud189PC, obj model.Obj) (*model.Link, error) { linkCalls++ @@ -125,19 +129,22 @@ func TestLinkTransferredShareFile_CASRestoresPayloadNameEvenWhenDriverUsesCurren linkSeamMu.Unlock() }) - link, err := driver.linkTransferredShareFile(context.Background(), casObj) + link, cleanupObj, err := driver.resolveTransferredShareFile(context.Background(), casObj) if err != nil { t.Fatalf("link cas transfer: %v", err) } if link.URL != "https://example.com/payload.mkv" { t.Fatalf("expected restored payload link, got %q", link.URL) } + if cleanupObj != restoredObj { + t.Fatalf("expected restored object as cleanup target, got %#v", cleanupObj) + } if openCalls != 1 || readCalls != 1 || restoreCalls != 1 || linkCalls != 1 { t.Fatalf("expected open/read/restore/link once, got open=%d read=%d restore=%d link=%d", openCalls, readCalls, restoreCalls, linkCalls) } } -func TestLinkTransferredShareFile_CASRestoreFailureReturnsErrorAndDoesNotFallback(t *testing.T) { +func TestResolveTransferredShareFile_CASRestoreFailureReturnsErrorAndDoesNotFallback(t *testing.T) { driver := &Cloud189PC{TempDirId: "temp-dir-id"} casObj := &Cloud189File{Name: "movie.mkv.cas"} @@ -169,26 +176,17 @@ func TestLinkTransferredShareFile_CASRestoreFailureReturnsErrorAndDoesNotFallbac linkSeamMu.Unlock() }) - link, err := driver.linkTransferredShareFile(context.Background(), casObj) + link, cleanupObj, err := driver.resolveTransferredShareFile(context.Background(), casObj) if err == nil || err.Error() != "restore failed" { t.Fatalf("expected restore failed error, got %v", err) } if link != nil { t.Fatalf("expected nil link on restore failure, got %#v", link) } + if cleanupObj != nil { + t.Fatalf("expected nil cleanup target on restore failure, got %#v", cleanupObj) + } if linkCalls != 0 { t.Fatalf("expected no fallback link call, got %d", linkCalls) } } - -func TestTransferCleanupScheduling_SkipsCASFiles(t *testing.T) { - casObj := &Cloud189File{Name: "movie.mkv.cas"} - nonCAS := &Cloud189File{Name: "movie.mkv"} - - if shouldScheduleTempCleanupForTransferredFile(casObj) { - t.Fatal("expected .cas transferred file to skip temp cleanup scheduling") - } - if !shouldScheduleTempCleanupForTransferredFile(nonCAS) { - t.Fatal("expected non-.cas transferred file to schedule temp cleanup") - } -} diff --git a/drivers/alias/driver.go b/drivers/alias/driver.go index 64376957..e1ba41eb 100644 --- a/drivers/alias/driver.go +++ b/drivers/alias/driver.go @@ -229,6 +229,15 @@ func (d *Alias) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([ for _, obj := range objMap { objs = append(objs, obj) } + if d.OrderBy == "" { + sort := getAllSort(dirs) + if sort.OrderBy != "" { + model.SortFiles(objs, sort.OrderBy, sort.OrderDirection) + } + if d.ExtractFolder == "" && sort.ExtractFolder != "" { + model.ExtractFolder(objs, sort.ExtractFolder) + } + } return objs, nil } @@ -276,21 +285,38 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) ( }, nil } - reqPath := d.getBalancedPath(ctx, file) - link, fi, err := d.link(ctx, reqPath, args) + var link *model.Link + var fi model.Obj + var err error + files := file.(BalancedObjs) + if d.ReadConflictPolicy == RandomBalancedRP || d.ReadConflictPolicy == AllRWP { + rand.Shuffle(len(files), func(i, j int) { + files[i], files[j] = files[j], files[i] + }) + } + for _, f := range files { + if f == nil { + continue + } + link, fi, err = d.link(ctx, f.GetPath(), args) + if err == nil { + if link == nil { + // 重定向且需要通过代理 + return &model.Link{ + URL: fmt.Sprintf("%s/p%s?sign=%s", + common.GetApiUrl(ctx), + utils.EncodePath(f.GetPath(), true), + sign.Sign(f.GetPath())), + }, nil + } + break + } + } if err != nil { return nil, err } - if link == nil { - // 重定向且需要通过代理 - return &model.Link{ - URL: fmt.Sprintf("%s/p%s?sign=%s", - common.GetApiUrl(ctx), - utils.EncodePath(reqPath, true), - sign.Sign(reqPath)), - }, nil - } resultLink := *link // 复制一份,避免修改到原始link + resultLink.Expiration = nil resultLink.SyncClosers = utils.NewSyncClosers(link) if args.Redirect { return &resultLink, nil diff --git a/drivers/alias/util.go b/drivers/alias/util.go index 7336b9ba..8e5eb8a8 100644 --- a/drivers/alias/util.go +++ b/drivers/alias/util.go @@ -490,3 +490,43 @@ func (d *Alias) extract(ctx context.Context, reqPath string, args model.ArchiveI link, _, err := op.DriverExtract(ctx, storage, reqActualPath, args) return link, err } + +func getAllSort(dirs []model.Obj) model.Sort { + ret := model.Sort{} + noSort := false + noExtractFolder := false + for _, dir := range dirs { + if dir == nil { + continue + } + storage, err := fs.GetStorage(dir.GetPath(), &fs.GetStoragesArgs{}) + if err != nil { + continue + } + if !noSort && storage.GetStorage().OrderBy != "" { + if ret.OrderBy == "" { + ret.OrderBy = storage.GetStorage().OrderBy + ret.OrderDirection = storage.GetStorage().OrderDirection + if ret.OrderDirection == "" { + ret.OrderDirection = "asc" + } + } else if ret.OrderBy != storage.GetStorage().OrderBy || ret.OrderDirection != storage.GetStorage().OrderDirection { + ret.OrderBy = "" + ret.OrderDirection = "" + noSort = true + } + } + if !noExtractFolder && storage.GetStorage().ExtractFolder != "" { + if ret.ExtractFolder == "" { + ret.ExtractFolder = storage.GetStorage().ExtractFolder + } else if ret.ExtractFolder != storage.GetStorage().ExtractFolder { + ret.ExtractFolder = "" + noExtractFolder = true + } + } + if noSort && noExtractFolder { + break + } + } + return ret +} diff --git a/drivers/all.go b/drivers/all.go index 3ba0a059..e84f710f 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -29,6 +29,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/crypt" _ "github.com/OpenListTeam/OpenList/v4/drivers/degoo" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao" + _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_new" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_share" _ "github.com/OpenListTeam/OpenList/v4/drivers/dropbox" _ "github.com/OpenListTeam/OpenList/v4/drivers/febbox" diff --git a/drivers/azure_blob/driver.go b/drivers/azure_blob/driver.go index ddfe3ff6..1c0bb9a9 100644 --- a/drivers/azure_blob/driver.go +++ b/drivers/azure_blob/driver.go @@ -85,6 +85,9 @@ func (d *AzureBlob) Drop(ctx context.Context) error { // List retrieves blobs and directories under the specified path. func (d *AzureBlob) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { prefix := ensureTrailingSlash(dir.GetPath()) + if prefix == "/" { + prefix = "" + } pager := d.containerClient.NewListBlobsHierarchyPager("/", &container.ListBlobsHierarchyOptions{ Prefix: &prefix, @@ -100,10 +103,11 @@ func (d *AzureBlob) List(ctx context.Context, dir model.Obj, args model.ListArgs // Process directories for _, blobPrefix := range page.Segment.BlobPrefixes { objs = append(objs, &model.Object{ - Name: path.Base(strings.TrimSuffix(*blobPrefix.Name, "/")), - Path: *blobPrefix.Name, - Modified: *blobPrefix.Properties.LastModified, - Ctime: *blobPrefix.Properties.CreationTime, + Name: path.Base(strings.TrimSuffix(*blobPrefix.Name, "/")), + Path: *blobPrefix.Name, + // Azure does not support properties now. + //Modified: *blobPrefix.Properties.LastModified, + //Ctime: *blobPrefix.Properties.CreationTime, IsFolder: true, }) } diff --git a/drivers/baidu_netdisk/util.go b/drivers/baidu_netdisk/util.go index 0ab4ba0e..18346bfb 100644 --- a/drivers/baidu_netdisk/util.go +++ b/drivers/baidu_netdisk/util.go @@ -161,7 +161,7 @@ func (d *BaiduNetdisk) postForm(pathname string, params map[string]string, form func (d *BaiduNetdisk) getFiles(dir string) ([]File, error) { start := 0 - limit := 200 + limit := 1000 params := map[string]string{ "method": "list", "dir": dir, @@ -177,7 +177,6 @@ func (d *BaiduNetdisk) getFiles(dir string) ([]File, error) { for { params["start"] = strconv.Itoa(start) params["limit"] = strconv.Itoa(limit) - start += limit var resp ListResp _, err := d.get("/xpan/file", params, &resp) if err != nil { @@ -196,6 +195,11 @@ func (d *BaiduNetdisk) getFiles(dir string) ([]File, error) { } else { res = append(res, resp.List...) } + + if len(resp.List) < limit { + break + } + start += limit } return res, nil } diff --git a/drivers/cloudreve_v4/driver.go b/drivers/cloudreve_v4/driver.go index a1d30163..2963bf46 100644 --- a/drivers/cloudreve_v4/driver.go +++ b/drivers/cloudreve_v4/driver.go @@ -46,6 +46,9 @@ func (d *CloudreveV4) Init(ctx context.Context) error { if d.ref != nil { return nil } + if d.isShare() { + return nil + } if d.canLogin() { return d.login() } @@ -129,15 +132,7 @@ func (d *CloudreveV4) List(ctx context.Context, dir model.Obj, args model.ListAr } } return &model.ObjThumb{ - Object: model.Object{ - ID: src.ID, - Path: src.Path, - Name: src.Name, - Size: src.Size, - Modified: src.UpdatedAt, - Ctime: src.CreatedAt, - IsFolder: src.Type == 1, - }, + Object: *fileToObject(&src), Thumbnail: thumb, }, nil }) @@ -151,14 +146,7 @@ func (d *CloudreveV4) Get(ctx context.Context, path string) (model.Obj, error) { if err != nil { return nil, err } - return &model.Object{ - ID: info.ID, - Path: info.Path, - Name: info.Name, - Size: info.Size, - Modified: info.UpdatedAt, - Ctime: info.CreatedAt, - }, nil + return fileToObject(&info), nil } func (d *CloudreveV4) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { diff --git a/drivers/cloudreve_v4/types.go b/drivers/cloudreve_v4/types.go index 23335042..b67cfc86 100644 --- a/drivers/cloudreve_v4/types.go +++ b/drivers/cloudreve_v4/types.go @@ -122,6 +122,18 @@ type File struct { PrimaryEntity string `json:"primary_entity"` } +func fileToObject(f *File) *model.Object { + return &model.Object{ + ID: f.ID, + Path: f.Path, + Name: f.Name, + Size: f.Size, + Modified: f.UpdatedAt, + Ctime: f.CreatedAt, + IsFolder: f.Type == 1, + } +} + type StoragePolicy struct { ID string `json:"id"` Name string `json:"name"` diff --git a/drivers/cloudreve_v4/util.go b/drivers/cloudreve_v4/util.go index 853df9ad..5d0157ff 100644 --- a/drivers/cloudreve_v4/util.go +++ b/drivers/cloudreve_v4/util.go @@ -16,6 +16,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/setting" @@ -30,7 +31,9 @@ import ( const ( CodeLoginRequired = http.StatusUnauthorized + CodePathNotExist = 40016 // Path not exist CodeCredentialInvalid = 40020 // Failed to issue token + // IncorrectSharePassword = 40069 // Incorrect share password ) var ( @@ -101,6 +104,9 @@ func (d *CloudreveV4) _request(method string, path string, callback base.ReqCall if r.Code == CodeCredentialInvalid { return ErrorIssueToken } + if r.Code == CodePathNotExist { + return errs.ObjectNotFound + } return fmt.Errorf("%d: %s", r.Code, r.Msg) } @@ -272,9 +278,16 @@ func (d *CloudreveV4) parseJWT(token string, jwt any) error { return nil } +func (d *CloudreveV4) isShare() bool { + return strings.HasSuffix(d.GetRootPath(), "@share") +} + // check if token is expired // https://github.com/cloudreve/frontend/blob/ddfacc1c31c49be03beb71de4cc114c8811038d6/src/session/index.ts#L177-L200 func (d *CloudreveV4) isTokenExpired() bool { + if d.isShare() { + return false + } if d.RefreshToken == "" { // login again if username and password is set if d.canLogin() { diff --git a/drivers/doubao_new/auth.go b/drivers/doubao_new/auth.go new file mode 100644 index 00000000..537b6a6e --- /dev/null +++ b/drivers/doubao_new/auth.go @@ -0,0 +1,597 @@ +package doubao_new + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/url" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" + "github.com/go-resty/resty/v2" + "github.com/google/uuid" + "golang.org/x/crypto/pbkdf2" +) + +const ( + defaultAuthRefreshAheadSeconds = int64(120) + defaultDpopRefreshAheadSeconds = int64(5) +) + +type Clock interface { + Now() (int64, error) +} + +type SystemClock struct{} + +func (SystemClock) Now() (int64, error) { return time.Now().Unix(), nil } + +type DPoPTokenInput struct { + KeyPair *ecdsa.PrivateKey + ExpiresIn int64 // 默认 15 + + JTI string + HTM string + HTU string + IAT int64 + Nonce string + Clock Clock +} + +type DPoPTokenOutput struct { + DPoPToken string `json:"dpopToken"` + ExpiredTime int64 `json:"expiredTime"` + ExpiresIn int64 `json:"expiresIn"` +} + +type JWTPayload struct { + Exp int64 `json:"exp,omitempty"` + Iat int64 `json:"iat,omitempty"` + Nbf int64 `json:"nbf,omitempty"` + Jti string `json:"jti,omitempty"` + Htm string `json:"htm,omitempty"` + Htu string `json:"htu,omitempty"` + Nonce string `json:"nonce,omitempty"` + Sub string `json:"sub,omitempty"` +} + +type jwkECPrivateKey struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + D string `json:"d"` +} + +type dpopKeyPairEnvelope struct { + PrivateKey *jwkECPrivateKey `json:"privateKey"` + KeyPair *jwkECPrivateKey `json:"keyPair"` + JWK *jwkECPrivateKey `json:"jwk"` +} + +type encryptedDpopKeyPair struct { + Data string `json:"data"` + Ciphertext string `json:"ciphertext"` + Encrypted string `json:"encrypted"` + Secret string `json:"secret"` + Password string `json:"password"` + Passphrase string `json:"passphrase"` +} + +func GenerateDPoPToken(in DPoPTokenInput) (*DPoPTokenOutput, error) { + if in.KeyPair == nil { + return nil, errors.New("keyPair required") + } + if in.KeyPair.Curve != elliptic.P256() { + return nil, errors.New("ES256 requires P-256 key") + } + if in.Clock == nil { + in.Clock = SystemClock{} + } + if in.ExpiresIn <= 0 { + in.ExpiresIn = 15 + } + + now, err := in.Clock.Now() + if err != nil { + return nil, err + } + + payload := map[string]any{ + "jti": pickStr(in.JTI, uuid.NewString()), + "htm": pickStr(in.HTM, ""), + "htu": pickStr(in.HTU, ""), + "iat": pickI64(in.IAT, now), + "nonce": pickStr(in.Nonce, uuid.NewString()), + } + if in.ExpiresIn > 0 { + payload["exp"] = payload["iat"].(int64) + in.ExpiresIn + } + + pub := in.KeyPair.PublicKey + header := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": map[string]string{ + "kty": "EC", + "crv": "P-256", + "x": b64url(pad32(pub.X.Bytes())), + "y": b64url(pad32(pub.Y.Bytes())), + }, + } + + hb, err := json.Marshal(header) + if err != nil { + return nil, err + } + pb, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + hEnc := b64url(hb) + pEnc := b64url(pb) + signingInput := hEnc + "." + pEnc + + sum := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, in.KeyPair, sum[:]) + if err != nil { + return nil, err + } + + sig := append(pad32(r.Bytes()), pad32(s.Bytes())...) + token := signingInput + "." + b64url(sig) + + iat := payload["iat"].(int64) + return &DPoPTokenOutput{ + DPoPToken: token, + ExpiredTime: iat + in.ExpiresIn, + ExpiresIn: in.ExpiresIn, + }, nil +} + +func GenerateDPoPKeyPair() (*ecdsa.PrivateKey, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + return validateP256Key(key) +} + +func ParseJWTPayload(token string, out any) error { + token = strings.TrimSpace(trimTokenScheme(token)) + parts := strings.Split(token, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid JWT format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("failed to decode JWT payload: %w", err) + } + if err := json.Unmarshal(payload, out); err != nil { + return fmt.Errorf("failed to parse JWT payload: %w", err) + } + return nil +} + +func parseECPrivateKeyJWK(raw string) (*ecdsa.PrivateKey, error) { + var jwk jwkECPrivateKey + if err := json.Unmarshal([]byte(raw), &jwk); err != nil { + return nil, err + } + if jwk.D == "" || jwk.X == "" || jwk.Y == "" { + var env dpopKeyPairEnvelope + if err := json.Unmarshal([]byte(raw), &env); err != nil { + return nil, err + } + switch { + case env.PrivateKey != nil: + jwk = *env.PrivateKey + case env.KeyPair != nil: + jwk = *env.KeyPair + case env.JWK != nil: + jwk = *env.JWK + default: + return nil, errors.New("missing private key JWK") + } + } + + if jwk.Kty != "" && jwk.Kty != "EC" { + return nil, errors.New("unsupported JWK kty") + } + if jwk.Crv != "" && jwk.Crv != "P-256" { + return nil, errors.New("unsupported JWK curve") + } + if jwk.D == "" || jwk.X == "" || jwk.Y == "" { + return nil, errors.New("incomplete JWK") + } + + xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) + if err != nil { + return nil, fmt.Errorf("invalid jwk x: %w", err) + } + yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) + if err != nil { + return nil, fmt.Errorf("invalid jwk y: %w", err) + } + dBytes, err := base64.RawURLEncoding.DecodeString(jwk.D) + if err != nil { + return nil, fmt.Errorf("invalid jwk d: %w", err) + } + + key := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + }, + D: new(big.Int).SetBytes(dBytes), + } + return validateP256Key(key) +} + +func parseEncryptedDPoPKeyPair(raw, secret string) (*ecdsa.PrivateKey, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("empty encrypted key pair") + } + + var payload encryptedDpopKeyPair + ciphertext := raw + if strings.HasPrefix(raw, "{") { + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, err + } + switch { + case strings.TrimSpace(payload.Data) != "": + ciphertext = strings.TrimSpace(payload.Data) + case strings.TrimSpace(payload.Ciphertext) != "": + ciphertext = strings.TrimSpace(payload.Ciphertext) + case strings.TrimSpace(payload.Encrypted) != "": + ciphertext = strings.TrimSpace(payload.Encrypted) + default: + return nil, errors.New("missing encrypted dpop payload") + } + } + + decoded, err := decodeBase64Loose(ciphertext) + if err != nil { + return nil, err + } + if len(decoded) <= 12 { + return nil, errors.New("encrypted dpop payload too short") + } + + plain, err := decryptDoubaoKeyPair(decoded, secret) + if err != nil { + return nil, fmt.Errorf("failed to decrypt with secret: %w", err) + } + return parseECPrivateKeyJWK(string(plain)) +} + +func decryptDoubaoKeyPair(ciphertext []byte, secret string) ([]byte, error) { + key := pbkdf2.Key([]byte(secret), []byte("fixed-salt"), 100000, 32, sha256.New) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonceSize := aead.NonceSize() + if len(ciphertext) <= nonceSize { + return nil, errors.New("ciphertext too short") + } + nonce := ciphertext[:nonceSize] + enc := ciphertext[nonceSize:] + return aead.Open(nil, nonce, enc, nil) +} + +func decodeBase64Loose(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + raw = strings.ReplaceAll(raw, "\n", "") + raw = strings.ReplaceAll(raw, "\r", "") + raw = strings.ReplaceAll(raw, "\t", "") + raw = strings.ReplaceAll(raw, " ", "") + + encodings := []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } + var lastErr error + for _, enc := range encodings { + decoded, err := enc.DecodeString(raw) + if err == nil { + return decoded, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("invalid base64") + } + return nil, lastErr +} + +func validateP256Key(key *ecdsa.PrivateKey) (*ecdsa.PrivateKey, error) { + if key == nil { + return nil, errors.New("nil private key") + } + if key.Curve != elliptic.P256() { + return nil, errors.New("ES256 requires P-256 key") + } + if key.PublicKey.X == nil || key.PublicKey.Y == nil || key.D == nil { + return nil, errors.New("invalid private key") + } + if !key.Curve.IsOnCurve(key.PublicKey.X, key.PublicKey.Y) { + return nil, errors.New("public key is not on P-256 curve") + } + return key, nil +} + +func trimTokenScheme(token string) string { + token = strings.TrimSpace(token) + if i := strings.IndexByte(token, ' '); i > 0 { + scheme := strings.ToLower(strings.TrimSpace(token[:i])) + if scheme == "bearer" || scheme == "dpop" { + return strings.TrimSpace(token[i+1:]) + } + } + return token +} + +func b64url(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func pad32(b []byte) []byte { + if len(b) >= 32 { + return b[len(b)-32:] + } + out := make([]byte, 32) + copy(out[32-len(b):], b) + return out +} + +func pickStr(v, def string) string { + if v != "" { + return v + } + return def +} + +func pickI64(v, def int64) int64 { + if v != 0 { + return v + } + return def +} + +func (d *DoubaoNew) resolveAuthorization() string { + auth := trimTokenScheme(d.Authorization) + if auth == "" { + return "" + } + return "DPoP " + auth +} + +func shouldRefreshJWT(token string) bool { + if token == "" { + return true + } + var payload JWTPayload + if err := ParseJWTPayload(token, &payload); err != nil { + return true + } + if payload.Exp <= 0 { + return false + } + return payload.Exp <= time.Now().Unix()+defaultAuthRefreshAheadSeconds +} + +func (d *DoubaoNew) fetchBizAuth(dpop string, public bool) (string, error) { + var reqUrl string + client := base.RestyClient.Clone() + req := client.R() + req.SetHeader("accept", "application/json, text/javascript") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + req.SetHeader("content-type", "application/x-www-form-urlencoded") + if public { + reqUrl = DoubaoURL + "/passport/anonymity_user/biz_auth/" + } else { + reqUrl = DoubaoURL + "/passport/user/biz_auth/" + if d.Cookie != "" { + req.SetHeader("cookie", d.Cookie) + if csrf := strings.TrimSpace(cookie.GetStr(d.Cookie, "passport_csrf_token")); csrf != "" { + req.SetHeader("x-tt-passport-csrf-token", csrf) + } + } + if oldAuth := d.resolveAuthorization(); oldAuth != "" { + req.SetHeader("authorization", oldAuth) + } + } + if dpop != "" { + req.SetHeader("dpop", dpop) + } + values := url.Values{} + values.Set("client_id", d.AuthClientID) + values.Set("client_type", d.AuthClientType) + values.Set("scope", d.AuthScope) + values.Set("d_pop", dpop) + req.SetBody(values.Encode()) + req.SetQueryParam("aid", d.AppID) + req.SetQueryParam("account_sdk_source", d.AuthSDKSource) + req.SetQueryParam("sdk_version", d.AuthSDKVersion) + + res, err := req.Post(reqUrl) + if err != nil { + return "", err + } + var resp bizAuthResp + if err = json.Unmarshal(res.Body(), &resp); err != nil { + return "", err + } + if resp.Message != "success" || resp.Data.AccessToken == "" { + return "", fmt.Errorf("[doubao_new] %s: %s", resp.Message, resp.Data.Description) + } + return resp.Data.AccessToken, nil +} + +func (d *DoubaoNew) refreshAuthorizationWithDPoP(dpop string) (string, error) { + token, err := d.fetchBizAuth(dpop, false) + if err == nil && token != "" { + return token, nil + } + if err == nil { + err = errors.New("biz auth refresh failed") + } + return "", err +} + +func (d *DoubaoNew) resolveDpopForRequest(method, rawURL string) (string, error) { + if d.DPoPKeyPair != nil { + proof, err := GenerateDPoPToken(DPoPTokenInput{ + KeyPair: d.DPoPKeyPair, + HTM: strings.ToUpper(strings.TrimSpace(method)), + HTU: normalizeDPoPURL(rawURL), + }) + if err != nil { + return "", err + } + return proof.DPoPToken, nil + } + + static := d.DPoP + if static == "" { + return "", nil + } + if !d.IgnoreJWTCheck { + if payload, err := parseDPoPPayload(static); err == nil && payload.Exp > 0 { + now := time.Now().Unix() + if payload.Exp <= now+defaultDpopRefreshAheadSeconds { + return "", errors.New("static dpop token expired or near expiry; configure dpop_key_pair for automatic refresh") + } + } + } + return static, nil +} + +func (d *DoubaoNew) ensureAuthAdditons() bool { + return d.DPoPKeySecret != "" && d.AuthClientID != "" && d.AuthClientType != "" && + d.AuthScope != "" && d.AuthSDKSource != "" && d.AuthSDKVersion != "" +} + +func (d *DoubaoNew) resolveAuthorizationForRequest(method, rawURL string) (string, error) { + if !shouldRefreshJWT(d.Authorization) { + return d.resolveAuthorization(), nil + } + + if d.DPoPKeyPair == nil || strings.TrimSpace(d.Cookie) == "" || !d.ensureAuthAdditons() { + return d.resolveAuthorization(), nil + } + + d.authRefreshMu.Lock() + defer d.authRefreshMu.Unlock() + + if !shouldRefreshJWT(d.Authorization) { + return d.resolveAuthorization(), nil + } + + refreshDpop, err := d.resolveDpopForRequest(method, rawURL) + if err != nil || refreshDpop == "" { + return "", err + } + + newToken, err := d.refreshAuthorizationWithDPoP(refreshDpop) + if err != nil { + if auth := d.resolveAuthorization(); auth != "" { + return auth, nil + } + return "", err + } + d.Authorization = trimTokenScheme(newToken) + return d.resolveAuthorization(), nil +} + +func (d *DoubaoNew) resolveAuthorizationForPublic() (dpop string, auth string, err error) { + if d.DPoPPublic != "" && !shouldRefreshJWT(d.AuthorizationPublic) { + return d.DPoPPublic, "DPoP " + d.AuthorizationPublic, nil + } + + if !d.ensureAuthAdditons() { + return "", "", fmt.Errorf("[doubao_new] missing auth additions, please fill them all") + } + + d.authRefreshPublicMu.Lock() + defer d.authRefreshPublicMu.Unlock() + + if d.DPoPPublic != "" && !shouldRefreshJWT(d.AuthorizationPublic) { + return d.DPoPPublic, "DPoP " + d.AuthorizationPublic, nil + } + + // generate new public dpop + keypair, err := GenerateDPoPKeyPair() + if err != nil { + return "", "", err + } + proof, err := GenerateDPoPToken(DPoPTokenInput{ + KeyPair: keypair, + }) + d.DPoPPublic = proof.DPoPToken + + // get authorization token + d.AuthorizationPublic, err = d.fetchBizAuth(proof.DPoPToken, true) + if err != nil { + return "", "", err + } + + return d.DPoPPublic, "DPoP " + d.AuthorizationPublic, nil +} + +func (d *DoubaoNew) applyAuthHeaders(req *resty.Request, method, rawURL string) error { + auth, err := d.resolveAuthorizationForRequest(method, rawURL) + if err != nil { + return err + } + if auth != "" { + req.SetHeader("authorization", auth) + } + dpop, err := d.resolveDpopForRequest(method, rawURL) + if err != nil { + return err + } + if dpop != "" { + req.SetHeader("dpop", dpop) + } + return nil +} + +func normalizeDPoPURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + u.Fragment = "" + return u.String() +} + +func parseDPoPPayload(token string) (*JWTPayload, error) { + var payload JWTPayload + if err := ParseJWTPayload(token, &payload); err != nil { + return nil, err + } + return &payload, nil +} diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go new file mode 100644 index 00000000..2a90cf16 --- /dev/null +++ b/drivers/doubao_new/driver.go @@ -0,0 +1,529 @@ +package doubao_new + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) + +type DoubaoNew struct { + model.Storage + Addition + TtLogid string + + // DPoP access token (Authorization header value, without DPoP prefix) + Authorization string + AuthorizationPublic string + // DPoP header value + DPoP string + DPoPPublic string + // DPoP key pair for generating DPoP + DPoPKeyPairStr string + DPoPKeyPair *ecdsa.PrivateKey + + authRefreshMu sync.Mutex + authRefreshPublicMu sync.Mutex +} + +func (d *DoubaoNew) Config() driver.Config { + return config +} + +func (d *DoubaoNew) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *DoubaoNew) Init(ctx context.Context) error { + if cookieStr := strings.TrimSpace(d.Cookie); cookieStr != "" { + d.Cookie = cookieStr + auth := trimTokenScheme(cookie.GetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN")) + if auth != "" { + d.Authorization = auth + } + dpop := strings.TrimSpace(cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP")) + if dpop != "" { + d.DPoP = dpop + } + keypair := strings.TrimSpace(cookie.GetStr(d.Cookie, "feishu_dpop_keypair")) + if keypair != "" && d.DPoPKeySecret != "" { + d.DPoPKeyPairStr = keypair + d.DPoPKeyPair, _ = parseEncryptedDPoPKeyPair(keypair, d.DPoPKeySecret) + } + } + return nil +} + +func (d *DoubaoNew) Drop(ctx context.Context) error { + if d.Authorization != "" { + d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN", d.Authorization) + } + if d.DPoP != "" { + d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_DPOP", d.DPoP) + } + if d.DPoPKeyPairStr != "" { + d.Cookie = cookie.SetStr(d.Cookie, "feishu_dpop_keypair", d.DPoPKeyPairStr) + } + op.MustSaveDriverStorage(d) + return nil +} + +func (d *DoubaoNew) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + nodes, err := d.listAllChildren(ctx, dir.GetID()) + if err != nil { + return nil, err + } + + objs := make([]model.Obj, 0, len(nodes)) + for _, node := range nodes { + if node.NodeToken == "" || node.ObjToken == "" { + continue + } + + size := parseSize(node.Extra.Size) + isFolder := node.Type == 0 + if isFolder && node.NodeToken == dir.GetID() { + continue + } + + obj := &Object{ + Object: model.Object{ + ID: node.NodeToken, + Path: dir.GetID(), + Name: node.Name, + Size: size, + Modified: time.Unix(node.EditTime, 0), + Ctime: time.Unix(node.CreateTime, 0), + IsFolder: isFolder, + }, + ObjToken: node.ObjToken, + NodeType: node.NodeType, + ObjType: node.Type, + URL: node.URL, + } + objs = append(objs, obj) + } + + return objs, nil +} + +func (d *DoubaoNew) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + obj, ok := file.(*Object) + if !ok { + return nil, errors.New("unsupported object type") + } + if obj.IsFolder { + return nil, fmt.Errorf("link is directory") + } + var ( + err error + auth, dpop string + ) + if d.ShareLink { + err := d.createShare(ctx, obj) + if err != nil { + return nil, err + } + dpop, auth, err = d.resolveAuthorizationForPublic() + } else { + // TODO: append previewLink() with auth args to support ShareLink + if args.Type == "preview" || args.Type == "thumb" { + if link, err := d.previewLink(ctx, obj, args); err == nil { + return link, nil + } + } + auth = d.resolveAuthorization() + dpop, err = d.resolveDpopForRequest(http.MethodGet, DownloadBaseURL+"/space/api/box/stream/download/all/"+obj.ObjToken+"/") + } + if err != nil { + return nil, err + } + if auth == "" || dpop == "" { + return nil, errors.New("missing authorization or dpop") + } + if obj.ObjToken == "" { + return nil, errors.New("missing obj_token") + } + + query := url.Values{} + query.Set("authorization", auth) + query.Set("dpop", dpop) + + downloadURL := DownloadBaseURL + "/space/api/box/stream/download/all/" + obj.ObjToken + "/?" + query.Encode() + + headers := http.Header{ + "Referer": []string{DoubaoURL + "/"}, + "User-Agent": []string{base.UserAgent}, + } + + return &model.Link{ + URL: downloadURL, + Header: headers, + }, nil +} + +func (d *DoubaoNew) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + node, err := d.createFolder(ctx, parentDir.GetID(), dirName) + if err != nil { + return nil, err + } + return &Object{ + Object: model.Object{ + ID: node.NodeToken, + Path: parentDir.GetID(), + Name: node.Name, + Size: parseSize(node.Extra.Size), + Modified: time.Unix(node.EditTime, 0), + Ctime: time.Unix(node.CreateTime, 0), + IsFolder: true, + }, + ObjToken: node.ObjToken, + NodeType: node.NodeType, + ObjType: node.Type, + URL: node.URL, + }, nil +} + +func (d *DoubaoNew) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + if srcObj == nil { + return nil, errors.New("nil source object") + } + if dstDir == nil { + return nil, errors.New("nil destination dir") + } + srcToken := srcObj.GetID() + if srcToken == "" { + if obj, ok := srcObj.(*Object); ok { + srcToken = obj.ObjToken + } + } + if srcToken == "" { + return nil, errors.New("missing source token") + } + if err := d.moveObj(ctx, srcToken, dstDir.GetID()); err != nil { + return nil, err + } + if obj, ok := srcObj.(*Object); ok { + clone := *obj + clone.Path = dstDir.GetID() + return &clone, nil + } + return srcObj, nil +} + +func (d *DoubaoNew) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + if srcObj == nil { + return nil, errors.New("nil source object") + } + if srcObj.IsDir() { + if err := d.renameFolder(ctx, srcObj.GetID(), newName); err != nil { + return nil, err + } + } else { + fileToken := "" + if obj, ok := srcObj.(*Object); ok { + fileToken = obj.ObjToken + } + if fileToken == "" { + fileToken = srcObj.GetID() + } + if err := d.renameFile(ctx, fileToken, newName); err != nil { + return nil, err + } + } + + if obj, ok := srcObj.(*Object); ok { + clone := *obj + clone.Name = newName + return &clone, nil + } + return srcObj, nil +} + +func (d *DoubaoNew) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + // TODO copy obj, optional + return nil, errs.NotImplement +} + +func (d *DoubaoNew) Remove(ctx context.Context, obj model.Obj) error { + if obj == nil { + return errors.New("nil object") + } + token := obj.GetID() + if token == "" { + if o, ok := obj.(*Object); ok { + token = o.ObjToken + } + } + if token == "" { + return errors.New("missing object token") + } + return d.removeObj(ctx, []string{token}) +} + +func (d *DoubaoNew) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + if file == nil { + return nil, errors.New("nil file") + } + if file.GetSize() <= 0 { + return nil, errors.New("invalid file size") + } + + uploadPrep, err := d.prepareUpload(ctx, file.GetName(), file.GetSize(), dstDir.GetID()) + if err != nil { + return nil, err + } + if uploadPrep.BlockSize <= 0 { + return nil, errors.New("invalid block size from prepare") + } + + tmpFile, err := utils.CreateTempFile(file, file.GetSize()) + if err != nil { + return nil, err + } + defer tmpFile.Close() + + blockSize := uploadPrep.BlockSize + totalSize := file.GetSize() + numBlocks := int((totalSize + blockSize - 1) / blockSize) + blocks := make([]UploadBlock, 0, numBlocks) + blockMeta := make(map[int]UploadBlock, numBlocks) + + for seq := 0; seq < numBlocks; seq++ { + offset := int64(seq) * blockSize + length := blockSize + if remain := totalSize - offset; remain < length { + length = remain + } + buf := make([]byte, int(length)) + n, err := tmpFile.ReadAt(buf, offset) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + buf = buf[:n] + sum := sha256.Sum256(buf) + hash := base64.StdEncoding.EncodeToString(sum[:]) + checksum := adler32String(buf) + + block := UploadBlock{ + Hash: hash, + Seq: seq, + Size: int64(n), + Checksum: checksum, + IsUploaded: true, + } + blocks = append(blocks, block) + blockMeta[seq] = block + } + + needed, err := d.uploadBlocks(ctx, uploadPrep.UploadID, blocks, "explorer") + if err != nil { + return nil, err + } + + if len(needed.NeededUploadBlocks) > 0 { + sort.Slice(needed.NeededUploadBlocks, func(i, j int) bool { + return needed.NeededUploadBlocks[i].Seq < needed.NeededUploadBlocks[j].Seq + }) + const maxMergeBlockCount = 20 + var ( + groupSeqs []int + groupChecksums []string + groupSizes []int64 + groupRealSize int64 + groupExpectSum int64 + groupBuf bytes.Buffer + uploadedBytes int64 + ) + + flushGroup := func() error { + if len(groupSeqs) == 0 { + return nil + } + data := groupBuf.Bytes() + expectLen := groupExpectSum + if int64(len(data)) != expectLen { + return fmt.Errorf("[doubao_new] merge blocks invalid body len: got=%d expect=%d seqs=%v", len(data), expectLen, groupSeqs) + } + mergeResp, err := d.mergeUploadBlocks(ctx, uploadPrep.UploadID, groupSeqs, groupChecksums, groupSizes, blockSize, data) + if err != nil { + return err + } + if len(mergeResp.SuccessSeqList) != len(groupSeqs) { + return fmt.Errorf("[doubao_new] merge blocks incomplete: %v", mergeResp.SuccessSeqList) + } + success := make(map[int]bool, len(mergeResp.SuccessSeqList)) + for _, seq := range mergeResp.SuccessSeqList { + success[seq] = true + } + for _, seq := range groupSeqs { + if !success[seq] { + return fmt.Errorf("[doubao_new] merge blocks missing seq %d", seq) + } + } + + uploadedBytes += groupRealSize + groupSeqs = groupSeqs[:0] + groupChecksums = groupChecksums[:0] + groupSizes = groupSizes[:0] + groupRealSize = 0 + groupExpectSum = 0 + groupBuf.Reset() + if up != nil { + percent := float64(uploadedBytes) / float64(totalSize) * 100 + up(percent) + } + return nil + } + + for _, item := range needed.NeededUploadBlocks { + if _, ok := blockMeta[item.Seq]; !ok { + return nil, fmt.Errorf("[doubao_new] missing block meta for seq %d", item.Seq) + } + if item.Size <= 0 { + return nil, fmt.Errorf("[doubao_new] invalid block size from needed list: seq=%d size=%d", item.Seq, item.Size) + } + offset := int64(item.Seq) * blockSize + buf := make([]byte, int(item.Size)) + n, err := tmpFile.ReadAt(buf, offset) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + if n != len(buf) { + return nil, fmt.Errorf("[doubao_new] short read: seq=%d want=%d got=%d", item.Seq, len(buf), n) + } + buf = buf[:n] + realAdler := adler32String(buf) + if realAdler != item.Checksum { + return nil, fmt.Errorf("[doubao_new] block checksum mismatch: seq=%d offset=%d adler32=%s step2=%s", item.Seq, offset, realAdler, item.Checksum) + } + payloadStart := groupBuf.Len() + groupBuf.Write(buf) + payloadEnd := groupBuf.Len() + payloadAdler := adler32String(groupBuf.Bytes()[payloadStart:payloadEnd]) + if payloadAdler != item.Checksum { + return nil, fmt.Errorf("[doubao_new] payload checksum mismatch: seq=%d start=%d end=%d adler32=%s step2=%s", item.Seq, payloadStart, payloadEnd, payloadAdler, item.Checksum) + } + groupSeqs = append(groupSeqs, item.Seq) + groupChecksums = append(groupChecksums, item.Checksum) + groupSizes = append(groupSizes, item.Size) + groupRealSize += int64(n) + groupExpectSum += item.Size + if len(groupSeqs) >= maxMergeBlockCount { + if err := flushGroup(); err != nil { + return nil, err + } + } + } + + if err := flushGroup(); err != nil { + return nil, err + } + if up != nil { + up(100) + } + } else if up != nil { + up(100) + } + + numBlocksFinish := uploadPrep.NumBlocks + if numBlocksFinish <= 0 { + numBlocksFinish = numBlocks + } + finish, err := d.finishUpload(ctx, uploadPrep.UploadID, numBlocksFinish, "explorer") + if err != nil { + return nil, err + } + + nodeToken := finish.Extra.NodeToken + if nodeToken == "" { + nodeToken = finish.FileToken + } + now := time.Now() + return &Object{ + Object: model.Object{ + ID: nodeToken, + Path: dstDir.GetID(), + Name: file.GetName(), + Size: file.GetSize(), + Modified: now, + Ctime: now, + IsFolder: false, + }, + ObjToken: finish.FileToken, + }, nil +} + +func (d *DoubaoNew) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + data, err := d.getUserStorage(ctx) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: data.TotalSizeLimitBytes, + UsedSpace: data.UsedSizeBytes, + }, + }, nil +} + +func (d *DoubaoNew) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { + switch args.Method { + case "doubao_preview", "preview": + obj, ok := args.Obj.(*Object) + if !ok { + return nil, errors.New("unsupported object type") + } + info, err := d.getFileInfo(ctx, obj.ObjToken) + if err != nil { + return nil, err + } + entry, ok := info.PreviewMeta.Data["22"] + if !ok || entry.Status != 0 { + return nil, errs.NotSupport + } + + imgExt := ".webp" + pageNums := 1 + if entry.Extra != "" { + var extra PreviewImageExtra + if err := json.Unmarshal([]byte(entry.Extra), &extra); err == nil { + if extra.ImgExt != "" { + imgExt = extra.ImgExt + } + if extra.PageNums > 0 { + pageNums = extra.PageNums + } + } + } + + return base.Json{ + "version": info.Version, + "img_ext": imgExt, + "page_nums": pageNums, + }, nil + default: + return nil, errs.NotSupport + } +} + +var _ driver.Driver = (*DoubaoNew)(nil) diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go new file mode 100644 index 00000000..2345357b --- /dev/null +++ b/drivers/doubao_new/meta.go @@ -0,0 +1,39 @@ +package doubao_new + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + // Usually one of two + driver.RootID + // define other + Cookie string `json:"cookie" required:"true" help:"Web Cookie"` + AppID string `json:"app_id" required:"true" default:"497858" help:"Doubao App ID"` + DPoPKeySecret string `json:"dpop_key_secret" help:"DPoP Key Secret for generating DPoP token"` + AuthClientID string `json:"auth_client_id" help:"Doubao Biz Auth Client ID"` + AuthClientType string `json:"auth_client_type" help:"Doubao Biz Auth Client Type"` + AuthScope string `json:"auth_scope" help:"Doubao Biz Auth Scope"` + AuthSDKSource string `json:"auth_sdk_source" help:"Doubao Biz Auth SDK Source"` + AuthSDKVersion string `json:"auth_sdk_version" help:"Doubao Biz Auth SDK Version"` + ShareLink bool `json:"share_link" help:"Whether to use share link for download"` + IgnoreJWTCheck bool `json:"ignore_jwt_check" help:"Whether to ignore JWT check to prevent time issue"` +} + +var config = driver.Config{ + Name: "DoubaoNew", + LocalSort: true, + DefaultRoot: "", + Alert: `danger|Do not use 302 if the storage is public accessible. +Otherwise, the download link may leak sensitive information such as access token or signature. +Others may use the leaked link to access all your files.`, + NoOverwriteUpload: false, + PreferProxy: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &DoubaoNew{} + }) +} diff --git a/drivers/doubao_new/types.go b/drivers/doubao_new/types.go new file mode 100644 index 00000000..3b64a2a2 --- /dev/null +++ b/drivers/doubao_new/types.go @@ -0,0 +1,192 @@ +package doubao_new + +import "github.com/OpenListTeam/OpenList/v4/internal/model" + +type BaseResp struct { + Code int `json:"code"` + Msg string `json:"msg,omitempty"` + Message string `json:"message,omitempty"` +} + +type ListResp struct { + BaseResp + Data ListData `json:"data"` +} + +type ListData struct { + HasMore bool `json:"has_more"` + LastLabel string `json:"last_label"` + NodeList []string `json:"node_list"` + Entities struct { + Nodes map[string]Node `json:"nodes"` + Users map[string]User `json:"users"` + } `json:"entities"` +} + +type Node struct { + Token string `json:"token"` + NodeToken string `json:"node_token"` + ObjToken string `json:"obj_token"` + Name string `json:"name"` + Type int `json:"type"` + NodeType int `json:"node_type"` + OwnerID string `json:"owner_id"` + EditUID string `json:"edit_uid"` + CreateTime int64 `json:"create_time"` + EditTime int64 `json:"edit_time"` + URL string `json:"url"` + Extra struct { + Size string `json:"size"` + } `json:"extra"` +} + +type User struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Object struct { + model.Object + ObjToken string + NodeType int + ObjType int + URL string +} + +type CreateFolderResp struct { + BaseResp + Data struct { + Entities struct { + Nodes map[string]Node `json:"nodes"` + } `json:"entities"` + NodeList []string `json:"node_list"` + } `json:"data"` +} + +type FileInfoResp struct { + Code int `json:"code"` + Message string `json:"message"` + Data FileInfo `json:"data"` +} + +type FileInfo struct { + Name string `json:"name"` + NumBlocks int `json:"num_blocks"` + Version string `json:"version"` + MimeType string `json:"mime_type"` + MountPoint string `json:"mount_point"` + PreviewMeta PreviewMeta `json:"preview_meta"` +} + +type PreviewMeta struct { + Data map[string]PreviewMetaEntry `json:"data"` +} + +type PreviewMetaEntry struct { + Status int `json:"status"` + Extra string `json:"extra"` + PreviewFileSize int64 `json:"preview_file_size"` +} + +type PreviewImageExtra struct { + ImgExt string `json:"img_ext"` + PageNums int `json:"page_nums"` +} + +type UserStorageResp struct { + BaseResp + Data UserStorageData `json:"data"` +} + +type UserStorageData struct { + ShowSizeLimit bool `json:"show_size_limit"` + TotalSizeLimitBytes int64 `json:"total_size_limit_bytes"` + UsedSizeBytes int64 `json:"used_size_bytes"` +} + +type UploadPrepareResp struct { + BaseResp + Data UploadPrepareData `json:"data"` +} + +type UploadPrepareData struct { + BlockSize int64 `json:"block_size"` + NumBlocks int `json:"num_blocks"` + OptionBlockSize int64 `json:"option_block_size"` + DedupeSupport bool `json:"dedupe_support"` + UploadID string `json:"upload_id"` +} + +type UploadBlock struct { + Hash string `json:"hash"` + Seq int `json:"seq"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + IsUploaded bool `json:"isUploaded"` +} + +type UploadBlocksResp struct { + BaseResp + Data UploadBlocksData `json:"data"` +} + +type UploadBlocksData struct { + NeededUploadBlocks []UploadBlockNeed `json:"needed_upload_blocks"` +} + +type UploadBlockNeed struct { + Seq int `json:"seq"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + Hash string `json:"hash"` +} + +type UploadMergeResp struct { + BaseResp + Data UploadMergeData `json:"data"` +} + +type UploadMergeData struct { + SuccessSeqList []int `json:"success_seq_list"` +} + +type UploadFinishResp struct { + BaseResp + Data UploadFinishData `json:"data"` +} + +type UploadFinishData struct { + Version string `json:"version"` + DataVersion string `json:"data_version"` + Extra struct { + NodeToken string `json:"node_token"` + } `json:"extra"` + FileToken string `json:"file_token"` +} + +type RemoveResp struct { + BaseResp + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` +} + +type TaskStatusResp struct { + BaseResp + Data TaskStatusData `json:"data"` +} + +type TaskStatusData struct { + IsFinish bool `json:"is_finish"` + IsFail bool `json:"is_fail"` +} + +type bizAuthResp struct { + Data struct { + AccessToken string `json:"access_token"` + AuthScheme string `json:"auth_scheme"` + ExpiresIn int64 `json:"expires_in"` + Description string `json:"description,omitempty"` + } `json:"data"` + Message string `json:"message"` +} diff --git a/drivers/doubao_new/upload.go b/drivers/doubao_new/upload.go new file mode 100644 index 00000000..6f3a8b06 --- /dev/null +++ b/drivers/doubao_new/upload.go @@ -0,0 +1,283 @@ +package doubao_new + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/go-resty/resty/v2" +) + +func (d *DoubaoNew) prepareUpload(ctx context.Context, name string, size int64, mountNodeToken string) (UploadPrepareData, error) { + var resp UploadPrepareResp + _, err := d.request(ctx, "/space/api/box/upload/prepare/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", d.AppID) + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.prepare") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + body := base.Json{ + "mount_point": "explorer", + "mount_node_token": "", + "name": name, + "size": size, + "size_checker": true, + } + if mountNodeToken != "" { + body["mount_node_token"] = mountNodeToken + } + req.SetBody(body) + }, &resp) + if err != nil { + return UploadPrepareData{}, err + } + return resp.Data, nil +} + +func (d *DoubaoNew) uploadBlocks(ctx context.Context, uploadID string, blocks []UploadBlock, mountPoint string) (UploadBlocksData, error) { + if uploadID == "" { + return UploadBlocksData{}, fmt.Errorf("[doubao_new] upload blocks missing upload_id") + } + if mountPoint == "" { + mountPoint = "explorer" + } + var resp UploadBlocksResp + _, err := d.request(ctx, "/space/api/box/upload/blocks/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", d.AppID) + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.blocks") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + req.SetBody(base.Json{ + "blocks": blocks, + "upload_id": uploadID, + "mount_point": mountPoint, + }) + }, &resp) + if err != nil { + return UploadBlocksData{}, err + } + return resp.Data, nil +} + +func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqList []int, checksumList []string, sizeList []int64, blockOriginSize int64, data []byte) (UploadMergeData, error) { + if uploadID == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing upload_id") + } + if len(seqList) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty seq list") + } + if len(checksumList) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty checksum list") + } + if len(sizeList) != len(seqList) { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks size list mismatch") + } + if blockOriginSize <= 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks invalid block origin size") + } + if len(data) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty data") + } + + seqHeader := joinIntComma(seqList) + checksumHeader := buildCommaHeader(checksumList) + + client := base.NewRestyClient() + client.SetCookieJar(nil) + req := client.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("content-type", "application/octet-stream") + req.SetHeader("x-block-list-checksum", checksumHeader) + req.SetHeader("x-seq-list", seqHeader) + req.SetHeader("x-block-origin-size", strconv.FormatInt(blockOriginSize, 10)) + req.SetHeader("x-command", "space.api.box.stream.upload.merge_block") + req.SetHeader("x-csrftoken", "") + reqID := "" + if buf := make([]byte, 16); true { + if _, err := rand.Read(buf); err == nil { + reqID = hex.EncodeToString(buf) + } + } + if reqID != "" { + req.SetHeader("x-request-id", reqID) + } + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("upload_id", uploadID) + values.Set("mount_point", "explorer") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", d.AppID) + urlStr := DownloadBaseURL + "/space/api/box/stream/upload/merge_block/?" + values.Encode() + if err := d.applyAuthHeaders(req, http.MethodPost, urlStr); err != nil { + return UploadMergeData{}, err + } + req.Header.Del("cookie") + if req.Header.Get("x-command") == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") + } + req.SetBody(data) + + res, err := req.Execute(http.MethodPost, urlStr) + if err != nil { + return UploadMergeData{}, err + } + if v := res.Header().Get("X-Tt-Logid"); v != "" { + d.TtLogid = v + } else if v := res.Header().Get("x-tt-logid"); v != "" { + d.TtLogid = v + } + body := res.Body() + var resp UploadMergeResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return UploadMergeData{}, fmt.Errorf("%s", msg) + } + if resp.Code != 0 { + if res != nil && res.StatusCode() == http.StatusBadRequest && resp.Code == 2 { + success := make([]int, 0, len(seqList)) + offset := 0 + for i, seq := range seqList { + size := sizeList[i] + if size <= 0 { + return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback invalid size: seq=%d size=%d", seq, size) + } + if offset+int(size) > len(data) { + return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback payload out of range: seq=%d offset=%d size=%d total=%d", seq, offset, size, len(data)) + } + payload := data[offset : offset+int(size)] + block := UploadBlockNeed{ + Seq: seq, + Size: size, + Checksum: checksumList[i], + } + if err := d.uploadBlockV3(ctx, uploadID, block, payload); err != nil { + return UploadMergeData{SuccessSeqList: success}, err + } + success = append(success, seq) + offset += int(size) + } + return UploadMergeData{SuccessSeqList: success}, nil + } + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return UploadMergeData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + + return resp.Data, nil +} + +func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block UploadBlockNeed, data []byte) error { + if uploadID == "" { + return fmt.Errorf("[doubao_new] upload v3 block missing upload_id") + } + if block.Seq < 0 { + return fmt.Errorf("[doubao_new] upload v3 block invalid seq") + } + if len(data) == 0 { + return fmt.Errorf("[doubao_new] upload v3 block empty data") + } + + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) + req.SetHeader("x-block-checksum", block.Checksum) + req.SetMultipartFormData(map[string]string{ + "upload_id": uploadID, + "size": strconv.FormatInt(int64(len(data)), 10), + }) + req.SetMultipartField("file", "blob", "application/octet-stream", bytes.NewReader(data)) + + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("upload_id", uploadID) + values.Set("seq", strconv.Itoa(block.Seq)) + values.Set("size", strconv.FormatInt(int64(len(data)), 10)) + values.Set("checksum", block.Checksum) + values.Set("mount_point", "explorer") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", d.AppID) + urlStr := DownloadBaseURL + "/space/api/box/stream/upload/v3/block/?" + values.Encode() + if err := d.applyAuthHeaders(req, http.MethodPost, urlStr); err != nil { + return err + } + + res, err := req.Execute(http.MethodPost, urlStr) + if err != nil { + return err + } + body := res.Body() + if err := decodeBaseResp(body, res); err != nil { + return err + } + return nil +} + +func (d *DoubaoNew) finishUpload(ctx context.Context, uploadID string, numBlocks int, mountPoint string) (UploadFinishData, error) { + if uploadID == "" { + return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload missing upload_id") + } + if numBlocks <= 0 { + return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload invalid num_blocks") + } + if mountPoint == "" { + mountPoint = "explorer" + } + var resp UploadFinishResp + _, err := d.request(ctx, "/space/api/box/upload/finish/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", d.AppID) + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.finish") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + req.SetHeader("biz-scene", "file_upload") + req.SetHeader("biz-ua-type", "Web") + req.SetBody(base.Json{ + "upload_id": uploadID, + "num_blocks": numBlocks, + "mount_point": mountPoint, + "push_open_history_record": 1, + }) + }, &resp) + if err != nil { + return UploadFinishData{}, err + } + return resp.Data, nil +} diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go new file mode 100644 index 00000000..8ee38625 --- /dev/null +++ b/drivers/doubao_new/util.go @@ -0,0 +1,713 @@ +package doubao_new + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "hash/adler32" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" + "github.com/go-resty/resty/v2" +) + +const ( + BaseURL = "https://my.feishu.cn" + DownloadBaseURL = "https://internal-api-drive-stream.feishu.cn" + DoubaoURL = "https://www.doubao.com" +) + +var defaultObjTypes = []string{"124", "0", "12", "30", "123", "22"} + +func (d *DoubaoNew) request(ctx context.Context, path string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, method, BaseURL+path); err != nil { + return nil, err + } + + if callback != nil { + callback(req) + } + + res, err := req.Execute(method, BaseURL+path) + if err != nil { + return nil, err + } + if res != nil { + if v := res.Header().Get("X-Tt-Logid"); v != "" { + d.TtLogid = v + } else if v := res.Header().Get("x-tt-logid"); v != "" { + d.TtLogid = v + } + } + + body := res.Body() + var common BaseResp + if err = json.Unmarshal(body, &common); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return body, fmt.Errorf("%s", msg) + } + if common.Code != 0 { + errMsg := common.Msg + if errMsg == "" { + errMsg = common.Message + } + return body, fmt.Errorf("[doubao_new] API error (code: %d): %s", common.Code, errMsg) + } + if resp != nil { + if err = json.Unmarshal(body, resp); err != nil { + return body, err + } + } + + return body, nil +} + +func adler32String(data []byte) string { + sum := adler32.Checksum(data) + return strconv.FormatUint(uint64(sum), 10) +} + +func buildCommaHeader(items []string) string { + return strings.Join(items, ",") +} + +func joinIntComma(items []int) string { + if len(items) == 0 { + return "" + } + var sb strings.Builder + for i, v := range items { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteString(strconv.Itoa(v)) + } + return sb.String() +} + +func previewList(items []string, n int) string { + if n <= 0 || len(items) == 0 { + return "" + } + if len(items) < n { + n = len(items) + } + return strings.Join(items[:n], ",") +} + +func parseSize(size string) int64 { + if size == "" { + return 0 + } + val, err := strconv.ParseInt(size, 10, 64) + if err != nil { + return 0 + } + return val +} + +func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLabel string, length int) (ListData, error) { + var resp ListResp + _, err := d.request(ctx, "/space/api/explorer/doubao/children/list/", http.MethodGet, func(req *resty.Request) { + values := url.Values{} + for _, t := range defaultObjTypes { + values.Add("obj_type", t) + } + values.Set("length", strconv.Itoa(length)) + values.Set("rank", "0") + values.Set("asc", "0") + values.Set("min_length", "40") + values.Set("thumbnail_width", "1028") + values.Set("thumbnail_height", "1028") + values.Set("thumbnail_policy", "4") + if parentToken != "" { + values.Set("token", parentToken) + } + if lastLabel != "" { + values.Set("last_label", lastLabel) + } + req.SetQueryParamsFromValues(values) + }, &resp) + if err != nil { + return ListData{}, err + } + + return resp.Data, nil +} + +func (d *DoubaoNew) listAllChildren(ctx context.Context, parentToken string) ([]Node, error) { + length := 50 + nodes := make([]Node, 0, length) + lastLabel := "" + for range 100 { + data, err := d.listChildren(ctx, parentToken, lastLabel, length) + if err != nil { + return nil, err + } + + if len(data.NodeList) > 0 { + for _, token := range data.NodeList { + node, ok := data.Entities.Nodes[token] + if !ok { + continue + } + nodes = append(nodes, node) + } + } else { + for _, node := range data.Entities.Nodes { + nodes = append(nodes, node) + } + } + + if !data.HasMore || data.LastLabel == "" || data.LastLabel == lastLabel { + break + } + lastLabel = data.LastLabel + } + + if len(nodes) == 0 { + return nil, nil + } + return nodes, nil +} + +func (d *DoubaoNew) getFileInfo(ctx context.Context, fileToken string) (FileInfo, error) { + var resp FileInfoResp + _, err := d.request(ctx, "/space/api/box/file/info/", http.MethodPost, func(req *resty.Request) { + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "caller": "explorer", + "file_token": fileToken, + "mount_point": "explorer", + "option_params": []string{"preview_meta", "check_cipher"}, + }) + }, &resp) + if err != nil { + return FileInfo{}, err + } + + return resp.Data, nil +} + +func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.LinkArgs) (*model.Link, error) { + auth := d.resolveAuthorization() + dpop, err := d.resolveDpopForRequest(http.MethodGet, fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s", BaseURL, obj.ObjToken)) + if auth == "" || dpop == "" { + return nil, errors.New("missing authorization or dpop") + } + if obj.ObjToken == "" { + return nil, errors.New("missing obj_token") + } + info, err := d.getFileInfo(ctx, obj.ObjToken) + if err != nil { + return nil, err + } + + entry, ok := info.PreviewMeta.Data["22"] + if !ok || entry.Status != 0 { + return nil, errors.New("preview not available") + } + + subID := "" + pageIndex := 0 + + if subID == "" { + imgExt := ".webp" + pageNums := 0 + if entry.Extra != "" { + var extra PreviewImageExtra + if err := json.Unmarshal([]byte(entry.Extra), &extra); err == nil { + if extra.ImgExt != "" { + imgExt = extra.ImgExt + } + pageNums = extra.PageNums + } + } + if pageNums > 0 && pageIndex >= pageNums { + pageIndex = pageNums - 1 + } + subID = fmt.Sprintf("img_%d%s", pageIndex, imgExt) + } + + query := url.Values{} + query.Set("preview_type", "22") + query.Set("sub_id", subID) + if info.Version != "" { + query.Set("version", info.Version) + } + previewURL := fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s?%s", BaseURL, obj.ObjToken, query.Encode()) + + headers := http.Header{ + "Referer": []string{DoubaoURL + "/"}, + "User-Agent": []string{base.UserAgent}, + "Authorization": []string{auth}, + "Dpop": []string{dpop}, + } + + return &model.Link{ + URL: previewURL, + Header: headers, + }, nil +} + +func (d *DoubaoNew) createShare(ctx context.Context, obj *Object) error { + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/suite/permission/public/update.v5/"); err != nil { + return nil, nil, err + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "external_access_entity": 1, + "link_share_entity": 4, + "token": obj.ObjToken, + "type": obj.ObjType, + }) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/suite/permission/public/update.v5/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + if err := decodeBaseResp(body, res); err != nil { + return err + } + return nil +} + +func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) (Node, error) { + data := url.Values{} + data.Set("name", name) + data.Set("source", "0") + if parentToken != "" { + data.Set("parent_token", parentToken) + } + + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/create/folder/"); err != nil { + return nil, nil, err + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + req.SetBody(data.Encode()) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v2/create/folder/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return Node{}, err + } + if err := decodeBaseResp(body, res); err != nil { + return Node{}, err + } + + var resp CreateFolderResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return Node{}, fmt.Errorf("%s", msg) + } + + var node Node + if len(resp.Data.NodeList) > 0 { + if n, ok := resp.Data.Entities.Nodes[resp.Data.NodeList[0]]; ok { + node = n + } + } + if node.Token == "" { + for _, n := range resp.Data.Entities.Nodes { + node = n + break + } + } + if node.Token == "" && node.ObjToken == "" && node.NodeToken == "" { + return Node{}, fmt.Errorf("[doubao_new] create folder failed: empty response") + } + if node.NodeToken == "" { + if node.Token != "" { + node.NodeToken = node.Token + } else if node.ObjToken != "" { + node.NodeToken = node.ObjToken + } + } + if node.ObjToken == "" && node.Token != "" { + node.ObjToken = node.Token + } + return node, nil +} + +func (d *DoubaoNew) renameFolder(ctx context.Context, token, name string) error { + if token == "" { + return fmt.Errorf("[doubao_new] rename folder missing token") + } + data := url.Values{} + data.Set("token", token) + data.Set("name", name) + + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/rename/"); err != nil { + return nil, nil, err + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + req.SetBody(data.Encode()) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v2/rename/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + return decodeBaseResp(body, res) +} + +func isCsrfTokenError(body []byte, res *resty.Response) bool { + if len(body) == 0 { + return false + } + if strings.Contains(strings.ToLower(string(body)), "csrf token error") { + return true + } + if res != nil && res.StatusCode() == http.StatusForbidden { + return true + } + return false +} + +func doRequestWithCsrf(doRequest func(csrfToken string) (*resty.Response, []byte, error)) (*resty.Response, []byte, error) { + res, body, err := doRequest("") + if err != nil { + return res, body, err + } + if isCsrfTokenError(body, res) { + csrfToken := extractCsrfTokenFromResponse(res) + if csrfToken != "" { + return doRequest(csrfToken) + } + } + return res, body, err +} + +func extractCsrfTokenFromResponse(res *resty.Response) string { + if res == nil || res.Request == nil { + return "" + } + if res.Request.RawRequest != nil { + if csrf := cookie.GetStr(res.Request.RawRequest.Header.Get("Cookie"), "_csrf_token"); csrf != "" { + return csrf + } + } + if csrf := cookie.GetStr(res.Request.Header.Get("Cookie"), "_csrf_token"); csrf != "" { + return csrf + } + for _, c := range res.Cookies() { + if c.Name == "_csrf_token" { + return c.Value + } + } + return "" +} + +func decodeBaseResp(body []byte, res *resty.Response) error { + var common BaseResp + if err := json.Unmarshal(body, &common); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return fmt.Errorf("%s", msg) + } + if common.Code != 0 { + errMsg := common.Msg + if errMsg == "" { + errMsg = common.Message + } + return fmt.Errorf("[doubao_new] API error (code: %d): %s", common.Code, errMsg) + } + return nil +} + +func (d *DoubaoNew) renameFile(ctx context.Context, fileToken, name string) error { + if fileToken == "" { + return fmt.Errorf("[doubao_new] rename file missing file token") + } + _, err := d.request(ctx, "/space/api/box/file/update_info/", http.MethodPost, func(req *resty.Request) { + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "file_token": fileToken, + "name": name, + }) + }, nil) + return err +} + +func (d *DoubaoNew) moveObj(ctx context.Context, srcToken, destToken string) error { + if srcToken == "" { + return fmt.Errorf("[doubao_new] move missing src token") + } + data := url.Values{} + data.Set("src_token", srcToken) + if destToken != "" { + data.Set("dest_token", destToken) + } + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/move/"); err != nil { + return nil, nil, err + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + req.SetBody(data.Encode()) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v2/move/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + return decodeBaseResp(body, res) +} + +func (d *DoubaoNew) removeObj(ctx context.Context, tokens []string) error { + if len(tokens) == 0 { + return fmt.Errorf("[doubao_new] remove missing tokens") + } + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v3/remove/"); err != nil { + return nil, nil, err + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "tokens": tokens, + "apply": 1, + }) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v3/remove/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + var resp RemoveResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return fmt.Errorf("%s", msg) + } + if resp.Code != 0 { + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + if resp.Data.TaskID == "" { + return nil + } + return d.waitTask(ctx, resp.Data.TaskID) +} + +func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + req.SetHeader("agw-js-conv", "str") + req.SetHeader("content-type", "application/json") + if err := d.applyAuthHeaders(req, http.MethodPost, DoubaoURL+"/alice/aispace/facade/get_user_storage"); err != nil { + return UserStorageData{}, err + } + if d.Cookie != "" { + req.SetHeader("cookie", d.Cookie) + } + req.SetBody(base.Json{}) + + res, err := req.Execute(http.MethodPost, DoubaoURL+"/alice/aispace/facade/get_user_storage") + if err != nil { + return UserStorageData{}, err + } + + body := res.Body() + var resp UserStorageResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return UserStorageData{}, fmt.Errorf("%s", msg) + } + if resp.Code != 0 { + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return UserStorageData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + + return resp.Data, nil +} + +func (d *DoubaoNew) waitTask(ctx context.Context, taskID string) error { + const ( + taskPollInterval = time.Second + taskPollMaxAttempts = 120 + ) + var lastErr error + for attempt := 0; attempt < taskPollMaxAttempts; attempt++ { + if attempt > 0 { + if err := waitWithContext(ctx, taskPollInterval); err != nil { + return err + } + } + status, err := d.getTaskStatus(ctx, taskID) + if err != nil { + lastErr = err + continue + } + if status.IsFail { + return fmt.Errorf("[doubao_new] remove task failed: %s", taskID) + } + if status.IsFinish { + return nil + } + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("[doubao_new] remove task timed out: %s", taskID) +} + +func (d *DoubaoNew) getTaskStatus(ctx context.Context, taskID string) (TaskStatusData, error) { + if taskID == "" { + return TaskStatusData{}, fmt.Errorf("[doubao_new] task status missing task_id") + } + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodGet, BaseURL+"/space/api/explorer/v2/task/"); err != nil { + return TaskStatusData{}, err + } + req.SetQueryParam("task_id", taskID) + res, err := req.Execute(http.MethodGet, BaseURL+"/space/api/explorer/v2/task/") + if err != nil { + return TaskStatusData{}, err + } + body := res.Body() + var resp TaskStatusResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return TaskStatusData{}, fmt.Errorf("%s", msg) + } + if resp.Code != 0 { + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return TaskStatusData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + return resp.Data, nil +} + +func waitWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/drivers/openlist/driver.go b/drivers/openlist/driver.go index 2ca60ff6..79fc5118 100644 --- a/drivers/openlist/driver.go +++ b/drivers/openlist/driver.go @@ -84,7 +84,7 @@ func (d *OpenList) List(ctx context.Context, dir model.Obj, args model.ListArgs) }, Path: dir.GetPath(), Password: d.MetaPassword, - Refresh: false, + Refresh: d.PassRefreshFlagToUpsteam && args.Refresh, }) }) if err != nil { diff --git a/drivers/openlist/meta.go b/drivers/openlist/meta.go index 16c6a155..3c4d0801 100644 --- a/drivers/openlist/meta.go +++ b/drivers/openlist/meta.go @@ -7,14 +7,15 @@ import ( type Addition struct { driver.RootPath - Address string `json:"url" required:"true"` - MetaPassword string `json:"meta_password"` - Username string `json:"username"` - Password string `json:"password"` - Token string `json:"token"` - PassIPToUpsteam bool `json:"pass_ip_to_upsteam" default:"true"` - PassUAToUpsteam bool `json:"pass_ua_to_upsteam" default:"true"` - ForwardArchiveReq bool `json:"forward_archive_requests" default:"true"` + Address string `json:"url" required:"true"` + MetaPassword string `json:"meta_password"` + Username string `json:"username"` + Password string `json:"password"` + Token string `json:"token"` + PassIPToUpsteam bool `json:"pass_ip_to_upsteam" default:"true"` + PassUAToUpsteam bool `json:"pass_ua_to_upsteam" default:"true"` + ForwardArchiveReq bool `json:"forward_archive_requests" default:"true"` + PassRefreshFlagToUpsteam bool `json:"pass_refresh_flag_to_upsteam" default:"false"` } var config = driver.Config{ diff --git a/drivers/pikpak/util.go b/drivers/pikpak/util.go index 9b7207fa..1d091217 100644 --- a/drivers/pikpak/util.go +++ b/drivers/pikpak/util.go @@ -19,6 +19,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" + netutil "github.com/OpenListTeam/OpenList/v4/internal/net" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/aliyun/aliyun-oss-go-sdk/oss" @@ -418,7 +419,7 @@ func (d *PikPak) refreshCaptchaToken(action string, metas map[string]string) err } func (d *PikPak) UploadByOSS(ctx context.Context, params *S3Params, s model.FileStreamer, up driver.UpdateProgress) error { - ossClient, err := oss.New(params.Endpoint, params.AccessKeyID, params.AccessKeySecret) + ossClient, err := netutil.NewOSSClient(params.Endpoint, params.AccessKeyID, params.AccessKeySecret) if err != nil { return err } @@ -451,7 +452,7 @@ func (d *PikPak) UploadByMultipart(ctx context.Context, params *S3Params, fileSi bucket *oss.Bucket ) - if ossClient, err = oss.New(params.Endpoint, params.AccessKeyID, params.AccessKeySecret); err != nil { + if ossClient, err = netutil.NewOSSClient(params.Endpoint, params.AccessKeyID, params.AccessKeySecret); err != nil { return err } diff --git a/drivers/quark_uc/util.go b/drivers/quark_uc/util.go index 9f481ad1..f125f5b5 100644 --- a/drivers/quark_uc/util.go +++ b/drivers/quark_uc/util.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/token" + "html" "io" "net/http" "strconv" @@ -112,6 +113,7 @@ func (d *QuarkOrUC) GetFiles(parent string) ([]model.Obj, error) { return nil, err } for _, file := range resp.Data.List { + file.FileName = html.UnescapeString(file.FileName) if d.OnlyListVideoFile { // 开启后 只列出视频文件和文件夹 if file.IsDir() || file.Category == 1 { diff --git a/drivers/seafile/driver.go b/drivers/seafile/driver.go index 646d6805..221ac436 100644 --- a/drivers/seafile/driver.go +++ b/drivers/seafile/driver.go @@ -6,9 +6,9 @@ import ( "net/http" stdpath "path" "strings" - "time" "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" @@ -19,7 +19,7 @@ type Seafile struct { Addition authorization string - libraryMap map[string]*LibraryInfo + root model.Obj } func (d *Seafile) Config() driver.Config { @@ -32,45 +32,103 @@ func (d *Seafile) GetAddition() driver.Additional { func (d *Seafile) Init(ctx context.Context) error { d.Address = strings.TrimSuffix(d.Address, "/") + err := d.getToken() + if err != nil { + return err + } d.RootFolderPath = utils.FixAndCleanPath(d.RootFolderPath) - d.libraryMap = make(map[string]*LibraryInfo) - return d.getToken() + if d.RepoId != "" { + library, err := d.getLibraryInfo(d.RepoId) + if err != nil { + return err + } + library.path = d.RootFolderPath + library.ObjMask = model.Locked + d.root = &LibraryInfo{ + LibraryItemResp: library, + } + return nil + } + if len(d.RootFolderPath) <= 1 { + d.root = &model.Object{ + Name: "root", + Path: d.RootFolderPath, + IsFolder: true, + Modified: d.Modified, + Mask: model.Locked, + } + return nil + } + + var resp []LibraryItemResp + _, err = d.request(http.MethodGet, "/api2/repos/", func(req *resty.Request) { + req.SetResult(&resp) + }) + if err != nil { + return err + } + for _, library := range resp { + p, found := strings.CutPrefix(d.RootFolderPath[1:], library.Name) + if !found { + continue + } + if p == "" { + p = "/" + } else if p[0] != '/' { + continue + } + // d.RepoId = library.Id + // d.RootFolderPath = p + + library.path = p + library.ObjMask = model.Locked + d.root = &LibraryInfo{ + LibraryItemResp: library, + } + return nil + } + return fmt.Errorf("Library for root folder path %q not found", d.RootFolderPath) } func (d *Seafile) Drop(ctx context.Context) error { + d.root = nil return nil } +func (d *Seafile) GetRoot(ctx context.Context) (model.Obj, error) { + if d.root == nil { + return nil, errs.StorageNotInit + } + return d.root, nil +} + func (d *Seafile) List(ctx context.Context, dir model.Obj, args model.ListArgs) (result []model.Obj, err error) { path := dir.GetPath() - if path == "/" && d.RepoId == "" { - libraries, err := d.listLibraries() - if err != nil { - return nil, err - } - return utils.SliceConvert(libraries, func(f LibraryItemResp) (model.Obj, error) { - return &model.Object{ - Path: stdpath.Join(path, f.Name), - Name: f.Name, - Modified: time.Unix(f.Modified, 0), - Size: f.Size, - IsFolder: true, + switch o := dir.(type) { + default: + var resp []LibraryItemResp + _, err = d.request(http.MethodGet, "/api2/repos/", func(req *resty.Request) { + req.SetResult(&resp) + }) + return utils.SliceConvert(resp, func(f LibraryItemResp) (model.Obj, error) { + f.path = path + return &LibraryInfo{ + LibraryItemResp: f, }, nil }) - } - var repo *LibraryInfo - repo, path, err = d.getRepoAndPath(path) - if err != nil { - return nil, err - } - if repo.Encrypted { - err = d.decryptLibrary(repo) - if err != nil { - return nil, err + case *LibraryInfo: + if o.Encrypted { + err = d.decryptLibrary(o) + if err != nil { + return nil, err + } } + case *RepoItemResp: + // do nothing } - var resp []RepoDirItemResp - _, err = d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/dir/", repo.Id), func(req *resty.Request) { + + var resp []RepoItemResp + _, err = d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/dir/", dir.GetID()), func(req *resty.Request) { req.SetResult(&resp).SetQueryParams(map[string]string{ "p": path, }) @@ -78,25 +136,17 @@ func (d *Seafile) List(ctx context.Context, dir model.Obj, args model.ListArgs) if err != nil { return nil, err } - return utils.SliceConvert(resp, func(f RepoDirItemResp) (model.Obj, error) { - return &model.Object{ - Path: stdpath.Join(dir.GetPath(), f.Name), - Name: f.Name, - Modified: time.Unix(f.Modified, 0), - Size: f.Size, - IsFolder: f.Type == "dir", - }, nil + return utils.SliceConvert(resp, func(f RepoItemResp) (model.Obj, error) { + f.path = stdpath.Join(path, f.Name) + f.repoID = dir.GetID() + return &f, nil }) } func (d *Seafile) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { - repo, path, err := d.getRepoAndPath(file.GetPath()) - if err != nil { - return nil, err - } - res, err := d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/file/", repo.Id), func(req *resty.Request) { + res, err := d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/file/", file.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": file.GetPath(), "reuse": "1", }) }) @@ -109,14 +159,9 @@ func (d *Seafile) Link(ctx context.Context, file model.Obj, args model.LinkArgs) } func (d *Seafile) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { - repo, path, err := d.getRepoAndPath(parentDir.GetPath()) - if err != nil { - return err - } - path, _ = utils.JoinBasePath(path, dirName) - _, err = d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/dir/", repo.Id), func(req *resty.Request) { + _, err := d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/dir/", parentDir.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": stdpath.Join(parentDir.GetPath(), dirName), }).SetFormData(map[string]string{ "operation": "mkdir", }) @@ -125,34 +170,22 @@ func (d *Seafile) MakeDir(ctx context.Context, parentDir model.Obj, dirName stri } func (d *Seafile) Move(ctx context.Context, srcObj, dstDir model.Obj) error { - repo, path, err := d.getRepoAndPath(srcObj.GetPath()) - if err != nil { - return err - } - dstRepo, dstPath, err := d.getRepoAndPath(dstDir.GetPath()) - if err != nil { - return err - } - _, err = d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/file/", repo.Id), func(req *resty.Request) { + _, err := d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/file/", srcObj.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": srcObj.GetPath(), }).SetFormData(map[string]string{ "operation": "move", - "dst_repo": dstRepo.Id, - "dst_dir": dstPath, + "dst_repo": dstDir.GetID(), + "dst_dir": dstDir.GetPath(), }) }, true) return err } func (d *Seafile) Rename(ctx context.Context, srcObj model.Obj, newName string) error { - repo, path, err := d.getRepoAndPath(srcObj.GetPath()) - if err != nil { - return err - } - _, err = d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/file/", repo.Id), func(req *resty.Request) { + _, err := d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/file/", srcObj.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": srcObj.GetPath(), }).SetFormData(map[string]string{ "operation": "rename", "newname": newName, @@ -162,47 +195,31 @@ func (d *Seafile) Rename(ctx context.Context, srcObj model.Obj, newName string) } func (d *Seafile) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { - repo, path, err := d.getRepoAndPath(srcObj.GetPath()) - if err != nil { - return err - } - dstRepo, dstPath, err := d.getRepoAndPath(dstDir.GetPath()) - if err != nil { - return err - } - _, err = d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/file/", repo.Id), func(req *resty.Request) { + _, err := d.request(http.MethodPost, fmt.Sprintf("/api2/repos/%s/file/", srcObj.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": srcObj.GetPath(), }).SetFormData(map[string]string{ "operation": "copy", - "dst_repo": dstRepo.Id, - "dst_dir": dstPath, + "dst_repo": dstDir.GetID(), + "dst_dir": dstDir.GetPath(), }) }) return err } func (d *Seafile) Remove(ctx context.Context, obj model.Obj) error { - repo, path, err := d.getRepoAndPath(obj.GetPath()) - if err != nil { - return err - } - _, err = d.request(http.MethodDelete, fmt.Sprintf("/api2/repos/%s/file/", repo.Id), func(req *resty.Request) { + _, err := d.request(http.MethodDelete, fmt.Sprintf("/api2/repos/%s/file/", obj.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": obj.GetPath(), }) }) return err } func (d *Seafile) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) error { - repo, path, err := d.getRepoAndPath(dstDir.GetPath()) - if err != nil { - return err - } - res, err := d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/upload-link/", repo.Id), func(req *resty.Request) { + res, err := d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/upload-link/", dstDir.GetID()), func(req *resty.Request) { req.SetQueryParams(map[string]string{ - "p": path, + "p": dstDir.GetPath(), }) }) if err != nil { @@ -218,7 +235,7 @@ func (d *Seafile) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame }) req.SetFileReader("file", s.GetName(), r). SetFormData(map[string]string{ - "parent_dir": path, + "parent_dir": dstDir.GetPath(), "replace": "1", }). SetContext(ctx) diff --git a/drivers/seafile/types.go b/drivers/seafile/types.go index 47cb322d..29a51ce8 100644 --- a/drivers/seafile/types.go +++ b/drivers/seafile/types.go @@ -1,6 +1,11 @@ package seafile -import "time" +import ( + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) type AuthTokenResp struct { Token string `json:"token"` @@ -13,7 +18,41 @@ type RepoItemResp struct { Size int64 `json:"size"` Modified int64 `json:"mtime"` Permission string `json:"permission"` + + path string + model.ObjMask + repoID string +} + +func (l *RepoItemResp) IsDir() bool { + return l.Type == "dir" +} +func (l *RepoItemResp) GetPath() string { + return l.path +} +func (l *RepoItemResp) GetName() string { + return l.Name +} +func (l *RepoItemResp) ModTime() time.Time { + return time.Unix(l.Modified, 0) +} +func (l *RepoItemResp) CreateTime() time.Time { + return l.ModTime() +} +func (l *RepoItemResp) GetSize() int64 { + return l.Size } +func (l *RepoItemResp) GetID() string { + if l.repoID != "" { + return l.repoID + } + return l.Id +} +func (l *RepoItemResp) GetHash() utils.HashInfo { + return utils.HashInfo{} +} + +var _ model.Obj = (*RepoItemResp)(nil) type LibraryItemResp struct { RepoItemResp @@ -33,12 +72,12 @@ type LibraryItemResp struct { SizeFormatted string `json:"size_formatted"` } -type RepoDirItemResp struct { - RepoItemResp -} - type LibraryInfo struct { LibraryItemResp decryptedTime time.Time decryptedSuccess bool -} \ No newline at end of file +} + +func (l *LibraryInfo) IsDir() bool { + return true +} diff --git a/drivers/seafile/util.go b/drivers/seafile/util.go index 6b5d0993..f2c0b665 100644 --- a/drivers/seafile/util.go +++ b/drivers/seafile/util.go @@ -7,9 +7,6 @@ import ( "strings" "time" - "github.com/OpenListTeam/OpenList/v4/internal/errs" - "github.com/OpenListTeam/OpenList/v4/pkg/utils" - "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/go-resty/resty/v2" ) @@ -71,73 +68,12 @@ func (d *Seafile) request(method string, pathname string, callback base.ReqCallb return res.Body(), nil } -func (d *Seafile) getRepoAndPath(fullPath string) (repo *LibraryInfo, path string, err error) { - libraryMap := d.libraryMap - repoId := d.Addition.RepoId - if repoId != "" { - if len(repoId) == 36 /* uuid */ { - for _, library := range libraryMap { - if library.Id == repoId { - return library, fullPath, nil - } - } - } - } else { - var repoName string - str := fullPath[1:] - pos := strings.IndexRune(str, '/') - if pos == -1 { - repoName = str - } else { - repoName = str[:pos] - } - path = utils.FixAndCleanPath(fullPath[1+len(repoName):]) - if library, ok := libraryMap[repoName]; ok { - return library, path, nil - } - } - return nil, "", errs.ObjectNotFound -} - -func (d *Seafile) listLibraries() (resp []LibraryItemResp, err error) { - repoId := d.Addition.RepoId - if repoId == "" { - _, err = d.request(http.MethodGet, "/api2/repos/", func(req *resty.Request) { - req.SetResult(&resp) - }) - } else { - var oneResp LibraryItemResp - _, err = d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/", repoId), func(req *resty.Request) { - req.SetResult(&oneResp) - }) - if err == nil { - resp = append(resp, oneResp) - } - } - if err != nil { - return nil, err - } - libraryMap := make(map[string]*LibraryInfo) - var putLibraryMap func(library LibraryItemResp, index int) - putLibraryMap = func(library LibraryItemResp, index int) { - name := library.Name - if index > 0 { - name = fmt.Sprintf("%s (%d)", name, index) - } - if _, exist := libraryMap[name]; exist { - putLibraryMap(library, index+1) - } else { - libraryInfo := LibraryInfo{} - data, _ := utils.Json.Marshal(library) - _ = utils.Json.Unmarshal(data, &libraryInfo) - libraryMap[name] = &libraryInfo - } - } - for _, library := range resp { - putLibraryMap(library, 0) - } - d.libraryMap = libraryMap - return resp, nil +func (d *Seafile) getLibraryInfo(repoId string) (LibraryItemResp, error) { + var oneResp LibraryItemResp + _, err := d.request(http.MethodGet, fmt.Sprintf("/api2/repos/%s/", repoId), func(req *resty.Request) { + req.SetResult(&oneResp) + }) + return oneResp, err } var repoPwdNotConfigured = errors.New("library password not configured") diff --git a/drivers/teldrive/driver.go b/drivers/teldrive/driver.go index 11ba0971..d420eb4d 100644 --- a/drivers/teldrive/driver.go +++ b/drivers/teldrive/driver.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "path" + "strconv" "strings" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -17,6 +18,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/go-resty/resty/v2" "github.com/google/uuid" + "golang.org/x/sync/errgroup" ) type Teldrive struct { @@ -53,18 +55,58 @@ func (d *Teldrive) Drop(ctx context.Context) error { } func (d *Teldrive) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { - var listResp ListResp + var firstResp ListResp err := d.request(http.MethodGet, "/api/files", func(req *resty.Request) { req.SetQueryParams(map[string]string{ "path": dir.GetPath(), - "limit": "1000", // overide default 500, TODO pagination + "limit": "500", + "page": "1", }) - }, &listResp) + }, &firstResp) + if err != nil { return nil, err } - return utils.SliceConvert(listResp.Items, func(src Object) (model.Obj, error) { + pagesData := make([][]Object, firstResp.Meta.TotalPages) + pagesData[0] = firstResp.Items + + if firstResp.Meta.TotalPages > 1 { + g, _ := errgroup.WithContext(ctx) + g.SetLimit(8) + + for i := 2; i <= firstResp.Meta.TotalPages; i++ { + page := i + g.Go(func() error { + var resp ListResp + err := d.request(http.MethodGet, "/api/files", func(req *resty.Request) { + req.SetQueryParams(map[string]string{ + "path": dir.GetPath(), + "limit": "500", + "page": strconv.Itoa(page), + }) + }, &resp) + + if err != nil { + return err + } + + pagesData[page-1] = resp.Items + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + } + + var allItems []Object + for _, items := range pagesData { + allItems = append(allItems, items...) + } + + return utils.SliceConvert(allItems, func(src Object) (model.Obj, error) { return &model.Object{ Path: path.Join(dir.GetPath(), src.Name), ID: src.ID, @@ -184,7 +226,7 @@ func (d *Teldrive) Put(ctx context.Context, dstDir model.Obj, file model.FileStr } if totalParts <= 1 { - return d.doSingleUpload(ctx, dstDir, file, up, totalParts, chunkSize, fileId) + return d.doSingleUpload(ctx, dstDir, file, up, maxRetried, totalParts, chunkSize, fileId) } return d.doMultiUpload(ctx, dstDir, file, up, maxRetried, totalParts, chunkSize, fileId) diff --git a/drivers/teldrive/meta.go b/drivers/teldrive/meta.go index 23bae5f9..cc7a5dbf 100644 --- a/drivers/teldrive/meta.go +++ b/drivers/teldrive/meta.go @@ -11,6 +11,7 @@ type Addition struct { Cookie string `json:"cookie" type:"string" required:"true" help:"access_token=xxx"` UseShareLink bool `json:"use_share_link" type:"bool" default:"false" help:"Create share link when getting link to support 302. If disabled, you need to enable web proxy."` ChunkSize int64 `json:"chunk_size" type:"number" default:"10" help:"Chunk size in MiB"` + RandomChunkName bool `json:"random_chunk_name" type:"bool" default:"true" help:"Random chunk name"` UploadConcurrency int64 `json:"upload_concurrency" type:"number" default:"4" help:"Concurrency upload requests"` } diff --git a/drivers/teldrive/upload.go b/drivers/teldrive/upload.go index 87cffa1a..b94f5fc9 100644 --- a/drivers/teldrive/upload.go +++ b/drivers/teldrive/upload.go @@ -1,6 +1,8 @@ package teldrive import ( + "crypto/md5" + "encoding/hex" "fmt" "io" "net/http" @@ -16,6 +18,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/avast/retry-go" "github.com/go-resty/resty/v2" + "github.com/google/uuid" "github.com/pkg/errors" "golang.org/x/net/context" "golang.org/x/sync/errgroup" @@ -38,6 +41,11 @@ func (d *Teldrive) touch(name, path string) error { return nil } +func getMD5Hash(text string) string { + hash := md5.Sum([]byte(text)) + return hex.EncodeToString(hash[:]) +} + func (d *Teldrive) createFileOnUploadSuccess(name, id, path string, uploadedFileParts []FilePart, totalSize int64) error { remoteFileParts, err := d.getFilePart(id) if err != nil { @@ -101,12 +109,10 @@ func (d *Teldrive) getFilePart(fileId string) ([]FilePart, error) { return uploadedParts, nil } -func (d *Teldrive) singleUploadRequest(fileId string, callback base.ReqCallback, resp interface{}) error { +func (d *Teldrive) singleUploadRequest(ctx context.Context, fileId string, callback base.ReqCallback, resp any) error { url := d.Address + "/api/uploads/" + fileId client := resty.New().SetTimeout(0) - ctx := context.Background() - req := client.R(). SetContext(ctx) req.SetHeader("Cookie", d.Cookie) @@ -135,16 +141,18 @@ func (d *Teldrive) singleUploadRequest(fileId string, callback base.ReqCallback, } func (d *Teldrive) doSingleUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up model.UpdateProgress, - totalParts int, chunkSize int64, fileId string) error { + maxRetried, totalParts int, chunkSize int64, fileId string) error { totalSize := file.GetSize() var fileParts []FilePart var uploaded int64 = 0 - ss, err := stream.NewStreamSectionReader(file, int(totalSize), &up) + var partName string + chunkSize = min(totalSize, chunkSize) + ss, err := stream.NewStreamSectionReader(file, int(chunkSize), &up) if err != nil { return err } - + chunkCnt := 0 for uploaded < totalSize { if utils.IsCanceled(ctx) { return ctx.Err() @@ -154,6 +162,7 @@ func (d *Teldrive) doSingleUpload(ctx context.Context, dstDir model.Obj, file mo if err != nil { return err } + chunkCnt += 1 filePart := &FilePart{} if err := retry.Do(func() error { @@ -161,13 +170,19 @@ func (d *Teldrive) doSingleUpload(ctx context.Context, dstDir model.Obj, file mo return err } - if err := d.singleUploadRequest(fileId, func(req *resty.Request) { + if d.RandomChunkName { + partName = getMD5Hash(uuid.New().String()) + } else { + partName = file.GetName() + if totalParts > 1 { + partName = fmt.Sprintf("%s.part.%03d", file.GetName(), chunkCnt) + } + } + + if err := d.singleUploadRequest(ctx, fileId, func(req *resty.Request) { uploadParams := map[string]string{ - "partName": func() string { - digits := len(strconv.Itoa(totalParts)) - return file.GetName() + fmt.Sprintf(".%0*d", digits, 1) - }(), - "partNo": strconv.Itoa(1), + "partName": partName, + "partNo": strconv.Itoa(chunkCnt), "fileName": file.GetName(), } req.SetQueryParams(uploadParams) @@ -180,7 +195,7 @@ func (d *Teldrive) doSingleUpload(ctx context.Context, dstDir model.Obj, file mo return nil }, retry.Context(ctx), - retry.Attempts(3), + retry.Attempts(uint(maxRetried)), retry.DelayType(retry.BackOffDelay), retry.Delay(time.Second)); err != nil { return err @@ -189,8 +204,11 @@ func (d *Teldrive) doSingleUpload(ctx context.Context, dstDir model.Obj, file mo if filePart.Name != "" { fileParts = append(fileParts, *filePart) uploaded += curChunkSize - up(float64(uploaded) / float64(totalSize)) + up(float64(uploaded) / float64(totalSize) * 100) ss.FreeSectionReader(rd) + } else { + // For common situation this code won't reach + return fmt.Errorf("[Teldrive] upload chunk %d failed: filePart Somehow missing", chunkCnt) } } @@ -318,6 +336,7 @@ func (d *Teldrive) doMultiUpload(ctx context.Context, dstDir model.Obj, file mod func (d *Teldrive) uploadSingleChunk(ctx context.Context, fileId string, task chunkTask, totalParts, maxRetried int) (*FilePart, error) { filePart := &FilePart{} retryCount := 0 + var partName string defer task.ss.FreeSectionReader(task.reader) for { @@ -331,12 +350,22 @@ func (d *Teldrive) uploadSingleChunk(ctx context.Context, fileId string, task ch return &existingPart, nil } - err := d.singleUploadRequest(fileId, func(req *resty.Request) { + if _, err := task.reader.Seek(0, io.SeekStart); err != nil { + return nil, err + } + + if d.RandomChunkName { + partName = getMD5Hash(uuid.New().String()) + } else { + partName = task.fileName + if totalParts > 1 { + partName = fmt.Sprintf("%s.part.%03d", task.fileName, task.chunkIdx) + } + } + + err := d.singleUploadRequest(ctx, fileId, func(req *resty.Request) { uploadParams := map[string]string{ - "partName": func() string { - digits := len(strconv.Itoa(totalParts)) - return task.fileName + fmt.Sprintf(".%0*d", digits, task.chunkIdx) - }(), + "partName": partName, "partNo": strconv.Itoa(task.chunkIdx), "fileName": task.fileName, } diff --git a/drivers/thunder/driver.go b/drivers/thunder/driver.go index cb352afe..492b9814 100644 --- a/drivers/thunder/driver.go +++ b/drivers/thunder/driver.go @@ -433,6 +433,32 @@ func (xc *XunLeiCommon) Put(ctx context.Context, dstDir model.Obj, file model.Fi return nil } +func (xc *XunLeiCommon) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + var about AboutResponse + _, err := xc.Request(API_URL+"/about", http.MethodGet, func(r *resty.Request) { + r.SetContext(ctx) + }, &about) + if err != nil { + return nil, err + } + + total, err := strconv.ParseInt(about.Quota.Limit, 10, 64) + if err != nil { + return nil, err + } + used, err := strconv.ParseInt(about.Quota.Usage, 10, 64) + if err != nil { + return nil, err + } + + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + UsedSpace: used, + }, + }, nil +} + func (xc *XunLeiCommon) getFiles(ctx context.Context, folderId string) ([]model.Obj, error) { files := make([]model.Obj, 0) var pageToken string diff --git a/drivers/thunder/types.go b/drivers/thunder/types.go index 7b3ad569..fcfa1fb1 100644 --- a/drivers/thunder/types.go +++ b/drivers/thunder/types.go @@ -347,3 +347,21 @@ type ReviewData struct { Deviceid string `json:"deviceid"` Devicesign string `json:"devicesign"` } + +type AboutResponse struct { + // Kind string `json:"kind"` + Quota struct { + // Kind string `json:"kind"` + Limit string `json:"limit"` + Usage string `json:"usage"` + // UsageInTrash string `json:"usage_in_trash"` + // PlayTimesLimit string `json:"play_times_limit"` + // PlayTimesUsage string `json:"play_times_usage"` + // IsUnlimited bool `json:"is_unlimited"` + // UpgradeType string `json:"upgrade_type"` + } `json:"quota"` + // ExpiresAt string `json:"expires_at"` + // Quotas struct { + // } `json:"quotas"` + // IsSearchFlushed bool `json:"is_search_flushed"` +} diff --git a/drivers/thunder_browser/driver.go b/drivers/thunder_browser/driver.go index 22189a69..c4de4946 100644 --- a/drivers/thunder_browser/driver.go +++ b/drivers/thunder_browser/driver.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" @@ -555,6 +556,32 @@ func (xc *XunLeiBrowserCommon) Put(ctx context.Context, dstDir model.Obj, stream return nil } +func (xc *XunLeiBrowserCommon) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + var about AboutResponse + _, err := xc.Request(API_URL+"/about", http.MethodGet, func(r *resty.Request) { + r.SetContext(ctx) + }, &about) + if err != nil { + return nil, err + } + + total, err := strconv.ParseInt(about.Quota.Limit, 10, 64) + if err != nil { + return nil, err + } + used, err := strconv.ParseInt(about.Quota.Usage, 10, 64) + if err != nil { + return nil, err + } + + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + UsedSpace: used, + }, + }, nil +} + func (xc *XunLeiBrowserCommon) getFiles(ctx context.Context, dir model.Obj, path string) ([]model.Obj, error) { files := make([]model.Obj, 0) var pageToken string diff --git a/drivers/thunder_browser/types.go b/drivers/thunder_browser/types.go index 29e130a5..a3d5aaf2 100644 --- a/drivers/thunder_browser/types.go +++ b/drivers/thunder_browser/types.go @@ -388,3 +388,10 @@ type ReviewData struct { Deviceid string `json:"deviceid"` Devicesign string `json:"devicesign"` } + +type AboutResponse struct { + Quota struct { + Limit string `json:"limit"` + Usage string `json:"usage"` + } `json:"quota"` +} diff --git a/drivers/thunderx/driver.go b/drivers/thunderx/driver.go index 86ff22bd..acbb8251 100644 --- a/drivers/thunderx/driver.go +++ b/drivers/thunderx/driver.go @@ -423,6 +423,32 @@ func (xc *XunLeiXCommon) Put(ctx context.Context, dstDir model.Obj, file model.F return nil } +func (xc *XunLeiXCommon) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + var about AboutResponse + _, err := xc.Request(API_URL+"/about", http.MethodGet, func(r *resty.Request) { + r.SetContext(ctx) + }, &about) + if err != nil { + return nil, err + } + + total, err := strconv.ParseInt(about.Quota.Limit, 10, 64) + if err != nil { + return nil, err + } + used, err := strconv.ParseInt(about.Quota.Usage, 10, 64) + if err != nil { + return nil, err + } + + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: total, + UsedSpace: used, + }, + }, nil +} + func (xc *XunLeiXCommon) getFiles(ctx context.Context, folderId string) ([]model.Obj, error) { files := make([]model.Obj, 0) var pageToken string diff --git a/drivers/thunderx/types.go b/drivers/thunderx/types.go index e5fbaa24..728fdea3 100644 --- a/drivers/thunderx/types.go +++ b/drivers/thunderx/types.go @@ -303,3 +303,10 @@ type Media struct { IsVisible bool `json:"is_visible"` Category string `json:"category"` } + +type AboutResponse struct { + Quota struct { + Limit string `json:"limit"` + Usage string `json:"usage"` + } `json:"quota"` +} diff --git a/go.mod b/go.mod index f2478e12..deeddf3e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/OpenListTeam/OpenList/v4 -go 1.23.4 +go 1.24.0 + +toolchain go1.24.13 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 @@ -38,6 +40,7 @@ require ( github.com/foxxorcat/weiyun-sdk-go v0.1.4 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 + github.com/glebarez/sqlite v1.11.0 github.com/go-resty/resty/v2 v2.16.5 github.com/go-webauthn/webauthn v0.13.4 github.com/golang-jwt/jwt/v4 v4.5.2 @@ -73,11 +76,11 @@ require ( github.com/upyun/go-sdk/v3 v3.0.4 github.com/winfsp/cgofuse v1.6.0 github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.46.0 golang.org/x/image v0.29.0 - golang.org/x/net v0.42.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/time v0.12.0 + golang.org/x/net v0.48.0 + golang.org/x/oauth2 v0.34.0 + golang.org/x/time v0.14.0 google.golang.org/appengine v1.6.8 gopkg.in/ldap.v3 v3.1.0 gorm.io/driver/mysql v1.5.7 @@ -87,8 +90,8 @@ require ( ) require ( - cloud.google.com/go/compute/metadata v0.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect @@ -102,10 +105,12 @@ require ( github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cronokirby/saferith v0.33.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect github.com/geoffgarside/ber v1.2.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect @@ -121,11 +126,16 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/relvacode/iso8601 v1.6.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect - golang.org/x/mod v0.27.0 // indirect + golang.org/x/mod v0.30.0 // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) require ( @@ -159,7 +169,7 @@ require ( github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 // indirect github.com/ulikunitz/xz v0.5.12 // indirect github.com/yuin/goldmark v1.7.13 - go4.org v0.0.0-20230225012048-214862532bf5 + go4.org v0.0.0-20260112195520-a5071408f32f resty.dev/v3 v3.0.0-beta.2 // indirect ) @@ -285,14 +295,14 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.4.0 // indirect golang.org/x/arch v0.18.0 // indirect - golang.org/x/sync v0.16.0 - golang.org/x/sys v0.34.0 - golang.org/x/term v0.33.0 // indirect - golang.org/x/text v0.27.0 - golang.org/x/tools v0.35.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/sync v0.19.0 + golang.org/x/sys v0.40.0 + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 + golang.org/x/tools v0.39.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 0b3ebdd9..78fcf401 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,10 @@ -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.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -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.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= -cloud.google.com/go v0.53.0 h1:MZQCQQaRwOrAcuKjiHWHrgKykt4fZyuwF2dtiG3fGW8= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= -cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -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/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= @@ -34,9 +17,8 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZY github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -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/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Da3zKi7/saferith v0.33.0-fixed h1:fnIWTk7EP9mZAICf7aQjeoAwpfrlCrkOvqmi6CbWdTk= github.com/Da3zKi7/saferith v0.33.0-fixed/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= github.com/KarpelesLab/reflink v1.0.2 h1:hQ1aM3TmjU2kTNUx5p/HaobDoADYk+a6AuEinG4Cv88= @@ -202,7 +184,6 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -223,12 +204,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= -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/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e h1:GLC8iDDcbt1H8+RkNao2nRGjyNTIo81e1rAJT9/uWYA= github.com/city404/v6-public-rpc-proto/go v0.0.0-20240817070657-90f8e24b653e/go.mod h1:ln9Whp+wVY/FTbn2SK0ag+SKD2fC0yQCF/Lqowc1LmU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= @@ -267,6 +244,8 @@ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn 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= +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/dustinxie/ecc v0.0.0-20210511000915-959544187564 h1:I6KUy4CI6hHjqnyJLNCEi7YHVMkwwtfSr2k9splgdSM= github.com/dustinxie/ecc v0.0.0-20210511000915-959544187564/go.mod h1:yekO+3ZShy19S+bsmnERmznGy9Rfg6dWWWpiGJjNAz8= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= @@ -275,8 +254,6 @@ github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7 github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff h1:4N8wnS3f1hNHSmFD5zgFkWCyA4L1kCDkImPAtK7D6tg= github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fclairamb/ftpserverlib v0.26.1-0.20250709223522-4a925d79caf6 h1:q1b+gv6AG2TDPN+f0QAkbRrAvJ3ZosnwRLTKNxSXlaA= @@ -302,12 +279,14 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo= -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-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -345,32 +324,14 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -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 h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 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= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -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/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= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -378,22 +339,17 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b h1:Qcx5LM0fSiks9uCyFZwDBUasd3lxd1RM0GYpL+Li5o4= +github.com/google/pprof v0.0.0-20230405160723-4a4c7d95572b/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.1/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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -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/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= -github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -416,8 +372,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -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/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= @@ -429,7 +383,6 @@ github.com/henrybear327/Proton-API-Bridge v1.0.0 h1:gjKAaWfKu++77WsZTHg6FUyPC5W0 github.com/henrybear327/Proton-API-Bridge v1.0.0/go.mod h1:gunH16hf6U74W2b9CGDaWRadiLICsoJ6KRkSt53zLts= github.com/henrybear327/go-proton-api v1.0.0 h1:zYi/IbjLwFAW7ltCeqXneUGJey0TN//Xo851a/BgLXw= github.com/henrybear327/go-proton-api v1.0.0/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMXYafI8H74D77AxytOBc= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ipfs/boxo v0.12.0 h1:AXHg/1ONZdRQHQLgG5JHsSC3XoE4DjCAMgK+asZvUcQ= @@ -475,8 +428,6 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF github.com/json-iterator/go v1.1.10/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= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 h1:G+9t9cEtnC9jFiTxyptEKuNIAbiN5ZCQzX2a74lj3xg= github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004/go.mod h1:KmHnJWQrgEvbuy0vcvj00gtMqbvNn1L+3YUZLK/B92c= github.com/kdomanski/iso9660 v0.4.0 h1:BPKKdcINz3m0MdjIMwS0wx1nofsOjxOq8TOr45WGHFg= @@ -495,11 +446,8 @@ github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQ github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 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/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -620,7 +568,6 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= @@ -635,16 +582,17 @@ github.com/rclone/rclone v1.70.3 h1:rg/WNh4DmSVZyKP2tHZ4lAaWEyMi7h/F0r7smOMA3IE= github.com/rclone/rclone v1.70.3/go.mod h1:nLyN+hpxAsQn9Rgt5kM774lcRDad82x/KqQeBZ83cMo= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/rfjakob/eme v1.1.2 h1:SxziR8msSOElPayZNFfQw4Tjx/Sbaeeh3eRvrHVMUs4= github.com/rfjakob/eme v1.1.2/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= @@ -728,38 +676,31 @@ github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3 h1:PSRw github.com/zzzhr1990/go-common-entity v0.0.0-20250202070650-1a200048f0d3/go.mod h1:CKriYB8bkNgSbYUQF1khSpejKb5IsV6cR7MdaAR7Fc0= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= -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= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= -go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= @@ -768,59 +709,23 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/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= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -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-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-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= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -832,42 +737,22 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/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-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= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -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/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-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-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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -887,8 +772,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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= @@ -899,11 +784,9 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -915,99 +798,42 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/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/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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-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-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/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-20191012152004-8de300cfc20a/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-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-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-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -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.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= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.236.0 h1:CAiEiDVtO4D/Qja2IA9VzlFrgPnK3XVMmRoJZlSWbc0= -google.golang.org/api v0.236.0/go.mod h1:X1WF9CU2oTc+Jml1tiIxGmWFK/UZezdqEu09gcxZAj4= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -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.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/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-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-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= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs= gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= gopkg.in/ldap.v3 v3.1.0 h1:DIDWEjI7vQWREh0S8X5/NFPCZ3MCVd55LmXKPW4XLGE= @@ -1032,17 +858,17 @@ gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0= lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= resty.dev/v3 v3.0.0-beta.2 h1:xu4mGAdbCLuc3kbk7eddWfWm4JfhwDtdapwss5nCjnQ= resty.dev/v3 v3.0.0-beta.2/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= -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.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/internal/bootstrap/db.go b/internal/bootstrap/db.go index d97cb679..7b91769f 100644 --- a/internal/bootstrap/db.go +++ b/internal/bootstrap/db.go @@ -12,7 +12,6 @@ import ( log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" @@ -41,7 +40,7 @@ func InitDB() { var dB *gorm.DB var err error if flags.Dev { - dB, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), gormConfig) + dB, err = gorm.Open(openSQLite("file::memory:?cache=shared"), gormConfig) conf.Conf.Database.Type = "sqlite3" } else { database := conf.Conf.Database @@ -51,7 +50,7 @@ func InitDB() { if !(strings.HasSuffix(database.DBFile, ".db") && len(database.DBFile) > 3) { log.Fatalf("db name error.") } - dB, err = gorm.Open(sqlite.Open(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental", + dB, err = gorm.Open(openSQLite(fmt.Sprintf("%s?_journal=WAL&_vacuum=incremental", database.DBFile)), gormConfig) } case "mysql": diff --git a/internal/bootstrap/sqlite_driver_glebarez.go b/internal/bootstrap/sqlite_driver_glebarez.go new file mode 100644 index 00000000..a45a8bae --- /dev/null +++ b/internal/bootstrap/sqlite_driver_glebarez.go @@ -0,0 +1,12 @@ +//go:build !sqlite_cgo_compat && !(linux && (mips || mips64 || mips64le || mipsle || loong64)) && !(windows && 386) + +package bootstrap + +import ( + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func openSQLite(dsn string) gorm.Dialector { + return sqlite.Open(dsn) +} diff --git a/internal/bootstrap/sqlite_driver_gorm.go b/internal/bootstrap/sqlite_driver_gorm.go new file mode 100644 index 00000000..e69630ea --- /dev/null +++ b/internal/bootstrap/sqlite_driver_gorm.go @@ -0,0 +1,12 @@ +//go:build sqlite_cgo_compat || (linux && (mips || mips64 || mips64le || mipsle || loong64)) || (windows && 386) + +package bootstrap + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func openSQLite(dsn string) gorm.Dialector { + return sqlite.Open(dsn) +} diff --git a/internal/fs/list.go b/internal/fs/list.go index 1f92c7d4..113ba823 100644 --- a/internal/fs/list.go +++ b/internal/fs/list.go @@ -2,13 +2,14 @@ package fs import ( "context" - "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" - "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/pkg/errors" log "github.com/sirupsen/logrus" + "path" ) // List files @@ -43,7 +44,29 @@ func list(ctx context.Context, path string, args *ListArgs) ([]model.Obj, error) om.InitHideReg(meta.Hide) } objs := om.Merge(_objs, virtualFiles...) - return objs, nil + objs, err = filterReadableObjs(objs, user, path, meta) + return objs, err +} + +func filterReadableObjs(objs []model.Obj, user *model.User, reqPath string, parentMeta *model.Meta) ([]model.Obj, error) { + var result []model.Obj + for _, obj := range objs { + var meta *model.Meta + objPath := path.Join(reqPath, obj.GetName()) + if obj.IsDir() { + var err error + meta, err = op.GetNearestMeta(objPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return result, err + } + } else { + meta = parentMeta + } + if common.CanRead(user, meta, objPath) { + result = append(result, obj) + } + } + return result, nil } func whetherHide(user *model.User, meta *model.Meta, path string) bool { @@ -60,7 +83,7 @@ func whetherHide(user *model.User, meta *model.Meta, path string) bool { return false } // if meta doesn't apply to sub_folder, don't hide - if !utils.PathEqual(meta.Path, path) && !meta.HSub { + if !common.MetaCoversPath(meta.Path, path, meta.HSub) { return false } // if is guest, hide diff --git a/internal/fs/list_test.go b/internal/fs/list_test.go new file mode 100644 index 00000000..ebaf4371 --- /dev/null +++ b/internal/fs/list_test.go @@ -0,0 +1,151 @@ +package fs + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestWhetherHide(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil user", + user: nil, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder", + want: false, + reason: "nil user (treated as admin) should not hide", + }, + { + name: "user with can_see_hides permission", + user: &model.User{ + Role: model.GENERAL, + Permission: 1, // bit 0 set = can see hides + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder", + want: false, + reason: "user with can_see_hides permission should not hide", + }, + { + name: "nil meta", + user: &model.User{ + Role: model.GUEST, + }, + meta: nil, + path: "/folder", + want: false, + reason: "nil meta should not hide", + }, + { + name: "empty hide string", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "", + HSub: true, + }, + path: "/folder", + want: false, + reason: "empty hide string should not hide", + }, + { + name: "exact path match with HSub=false", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: false, + }, + path: "/folder", + want: true, + reason: "exact path match should hide for guest", + }, + { + name: "sub path with HSub=true", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "sub path with HSub=true should hide for guest", + }, + { + name: "sub path with HSub=false", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "sub path with HSub=false should not hide", + }, + { + name: "non-sub path with HSub=true", + user: &model.User{ + Role: model.GUEST, + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/other", + want: false, + reason: "non-sub path should not hide even with HSub=true", + }, + { + name: "user without can_see_hides permission", + user: &model.User{ + Role: model.GENERAL, + Permission: 0, // bit 0 not set = cannot see hides + }, + meta: &model.Meta{ + Path: "/folder", + Hide: "secret", + HSub: true, + }, + path: "/folder", + want: true, + reason: "user without can_see_hides permission should hide", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := whetherHide(tt.user, tt.meta, tt.path) + if got != tt.want { + t.Errorf("whetherHide() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} diff --git a/internal/model/meta.go b/internal/model/meta.go index 0446137a..a105f38c 100644 --- a/internal/model/meta.go +++ b/internal/model/meta.go @@ -1,16 +1,20 @@ package model type Meta struct { - ID uint `json:"id" gorm:"primaryKey"` - Path string `json:"path" gorm:"unique" binding:"required"` - Password string `json:"password"` - PSub bool `json:"p_sub"` - Write bool `json:"write"` - WSub bool `json:"w_sub"` - Hide string `json:"hide"` - HSub bool `json:"h_sub"` - Readme string `json:"readme"` - RSub bool `json:"r_sub"` - Header string `json:"header"` - HeaderSub bool `json:"header_sub"` + ID uint `json:"id" gorm:"primaryKey"` + Path string `json:"path" gorm:"unique" binding:"required"` + ReadUsers []uint `json:"read_users" gorm:"serializer:json"` + ReadUsersSub bool `json:"read_users_sub"` + WriteUsers []uint `json:"write_users" gorm:"serializer:json"` + WriteUsersSub bool `json:"write_users_sub"` + Password string `json:"password"` + PSub bool `json:"p_sub"` + Write bool `json:"write"` + WSub bool `json:"w_sub"` + Hide string `json:"hide"` + HSub bool `json:"h_sub"` + Readme string `json:"readme"` + RSub bool `json:"r_sub"` + Header string `json:"header"` + HeaderSub bool `json:"header_sub"` } diff --git a/internal/model/user.go b/internal/model/user.go index 640e3b2e..55240711 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -115,12 +115,12 @@ func (u *User) CanAddOfflineDownloadTasks() bool { return CanAddOfflineDownloadTasks(u.Permission) } -func CanWrite(permission int32) bool { +func CanWriteContent(permission int32) bool { return (permission>>3)&1 == 1 } -func (u *User) CanWrite() bool { - return CanWrite(u.Permission) +func (u *User) CanWriteContent() bool { + return CanWriteContent(u.Permission) } func CanRename(permission int32) bool { diff --git a/internal/net/oss.go b/internal/net/oss.go new file mode 100644 index 00000000..a897161f --- /dev/null +++ b/internal/net/oss.go @@ -0,0 +1,9 @@ +package net + +import "github.com/aliyun/aliyun-oss-go-sdk/oss" + +func NewOSSClient(endpoint, accessKeyID, accessKeySecret string, options ...oss.ClientOption) (*oss.Client, error) { + clientOptions := []oss.ClientOption{oss.HTTPClient(NewHttpClient())} + clientOptions = append(clientOptions, options...) + return oss.New(endpoint, accessKeyID, accessKeySecret, clientOptions...) +} diff --git a/internal/net/oss_test.go b/internal/net/oss_test.go new file mode 100644 index 00000000..9001cd39 --- /dev/null +++ b/internal/net/oss_test.go @@ -0,0 +1,54 @@ +package net + +import ( + "net/http" + "net/url" + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" +) + +func TestNewOSSClientUsesEnvironmentHTTPSProxy(t *testing.T) { + oldConf := conf.Conf + conf.Conf = conf.DefaultConfig("data") + defer func() { + conf.Conf = oldConf + }() + + t.Setenv("HTTP_PROXY", "") + t.Setenv("http_proxy", "") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:7890") + t.Setenv("https_proxy", "") + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + client, err := NewOSSClient("https://oss-cn-hangzhou.aliyuncs.com", "test-access-key", "test-access-secret") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if client.HTTPClient == nil { + t.Fatal("expected OSS client to use a custom HTTP client") + } + + transport, ok := client.HTTPClient.Transport.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", client.HTTPClient.Transport) + } + + if transport.Proxy == nil { + t.Fatal("expected proxy function to be configured") + } + + req := &http.Request{URL: &url.URL{Scheme: "https", Host: "oss-cn-hangzhou.aliyuncs.com"}} + proxyURL, err := transport.Proxy(req) + if err != nil { + t.Fatalf("expected no proxy lookup error, got %v", err) + } + if proxyURL == nil { + t.Fatal("expected HTTPS proxy to be used") + } + if got, want := proxyURL.String(), "http://127.0.0.1:7890"; got != want { + t.Fatalf("expected proxy %q, got %q", want, got) + } +} diff --git a/internal/net/request_test.go b/internal/net/request_test.go index 00ba8a13..da16a316 100644 --- a/internal/net/request_test.go +++ b/internal/net/request_test.go @@ -153,7 +153,7 @@ func (c *downloadCaptureClient) HttpRequest(ctx context.Context, params *HttpReq c.GetObjectInvocations++ - if ¶ms.Range != nil { + if params.Range.Length != 0 { c.RetrievedRanges = append(c.RetrievedRanges, fmt.Sprintf("%d-%d", params.Range.Start, params.Range.Length)) } diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go index 50a4f634..5ee6ef4f 100644 --- a/internal/offline_download/tool/download.go +++ b/internal/offline_download/tool/download.go @@ -147,11 +147,11 @@ func (t *DownloadTask) Update() (bool, error) { if err != nil { t.callStatusRetried++ log.Errorf("failed to get status of %s, retried %d times", t.ID, t.callStatusRetried) + if t.callStatusRetried > 5 { + return true, errors.Errorf("failed to get status of %s, retried %d times", t.ID, t.callStatusRetried) + } return false, nil } - if t.callStatusRetried > 5 { - return true, errors.Errorf("failed to get status of %s, retried %d times", t.ID, t.callStatusRetried) - } t.callStatusRetried = 0 t.SetProgress(info.Progress) t.SetTotalBytes(info.TotalBytes) diff --git a/internal/op/meta.go b/internal/op/meta.go index ed9e422a..b7d86730 100644 --- a/internal/op/meta.go +++ b/internal/op/meta.go @@ -78,6 +78,7 @@ func UpdateMeta(u *model.Meta) error { return err } metaCache.Del(old.Path) + metaCache.Del(u.Path) return db.UpdateMeta(u) } diff --git a/internal/op/storage_test.go b/internal/op/storage_test.go index 2b191bd5..d7db2504 100644 --- a/internal/op/storage_test.go +++ b/internal/op/storage_test.go @@ -10,7 +10,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/pkg/utils" mapset "github.com/deckarep/golang-set/v2" - "gorm.io/driver/sqlite" + "github.com/glebarez/sqlite" "gorm.io/gorm" ) diff --git a/server/common/check.go b/server/common/check.go index 90074aee..27be3103 100644 --- a/server/common/check.go +++ b/server/common/check.go @@ -2,6 +2,7 @@ package common import ( "path" + "slices" "strings" "github.com/OpenListTeam/OpenList/v4/internal/conf" @@ -17,24 +18,39 @@ func IsStorageSignEnabled(rawPath string) bool { return storage != nil && storage.GetStorage().EnableSign } -func CanWrite(meta *model.Meta, path string) bool { - if meta == nil || !meta.Write { +func CanRead(user *model.User, meta *model.Meta, path string) bool { + // nil user is treated as internal/system context and bypasses per-user read restrictions + if user == nil { + return true + } + if meta != nil && len(meta.ReadUsers) > 0 && !slices.Contains(meta.ReadUsers, user.ID) && MetaCoversPath(meta.Path, path, meta.ReadUsersSub) { return false } - return meta.WSub || meta.Path == path + return true } -func IsApply(metaPath, reqPath string, applySub bool) bool { - if utils.PathEqual(metaPath, reqPath) { +func CanWrite(user *model.User, meta *model.Meta, path string) bool { + // nil user is treated as internal/system context and bypasses per-user write restrictions + if user == nil { return true } - return utils.IsSubPath(metaPath, reqPath) && applySub + if meta != nil && len(meta.WriteUsers) > 0 && !slices.Contains(meta.WriteUsers, user.ID) && MetaCoversPath(meta.Path, path, meta.WriteUsersSub) { + return false + } + return true +} + +func CanWriteContentBypassUserPerms(meta *model.Meta, path string) bool { + if meta == nil || !meta.Write { + return false + } + return MetaCoversPath(meta.Path, path, meta.WSub) } func CanAccess(user *model.User, meta *model.Meta, reqPath string, password string) bool { // if the reqPath is in hide (only can check the nearest meta) and user can't see hides, can't access if meta != nil && !user.CanSeeHides() && meta.Hide != "" && - IsApply(meta.Path, path.Dir(reqPath), meta.HSub) { // the meta should apply to the parent of current path + MetaCoversPath(meta.Path, path.Dir(reqPath), meta.HSub) { // the meta should apply to the parent of current path for _, hide := range strings.Split(meta.Hide, "\n") { re := regexp2.MustCompile(hide, regexp2.None) if isMatch, _ := re.MatchString(path.Base(reqPath)); isMatch { @@ -42,6 +58,9 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri } } } + if !CanRead(user, meta, reqPath) { + return false + } // if is not guest and can access without password if user.CanAccessWithoutPassword() { return true @@ -51,13 +70,20 @@ func CanAccess(user *model.User, meta *model.Meta, reqPath string, password stri return true } // if meta doesn't apply to sub_folder, can access - if !utils.PathEqual(meta.Path, reqPath) && !meta.PSub { + if !MetaCoversPath(meta.Path, reqPath, meta.PSub) { return true } // validate password return meta.Password == password } +func MetaCoversPath(metaPath, reqPath string, applyToSubFolder bool) bool { + if utils.PathEqual(metaPath, reqPath) { + return true + } + return utils.IsSubPath(metaPath, reqPath) && applyToSubFolder +} + // ShouldProxy TODO need optimize // when should be proxy? // 1. config.MustProxy() diff --git a/server/common/check_test.go b/server/common/check_test.go index 33114603..18abca8e 100644 --- a/server/common/check_test.go +++ b/server/common/check_test.go @@ -1,24 +1,986 @@ package common -import "testing" +import ( + "testing" -func TestIsApply(t *testing.T) { - datas := []struct { + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestCoversPath(t *testing.T) { + tests := []struct { + name string metaPath string reqPath string applySub bool - result bool + want bool }{ { + name: "exact path match with applySub=false", + metaPath: "/folder", + reqPath: "/folder", + applySub: false, + want: true, + }, + { + name: "exact path match with applySub=true", + metaPath: "/folder", + reqPath: "/folder", + applySub: true, + want: true, + }, + { + name: "sub path with applySub=true", + metaPath: "/folder", + reqPath: "/folder/subfolder", + applySub: true, + want: true, + }, + { + name: "sub path with applySub=false", + metaPath: "/folder", + reqPath: "/folder/subfolder", + applySub: false, + want: false, + }, + { + name: "non-sub path with applySub=true", + metaPath: "/folder", + reqPath: "/other", + applySub: true, + want: false, + }, + { + name: "non-sub path with applySub=false", + metaPath: "/folder", + reqPath: "/other", + applySub: false, + want: false, + }, + { + name: "root path covers all with applySub=true", metaPath: "/", - reqPath: "/test", + reqPath: "/any/deep/path", + applySub: true, + want: true, + }, + { + name: "root path exact match", + metaPath: "/", + reqPath: "/", + applySub: false, + want: true, + }, + { + name: "deep sub path with applySub=true", + metaPath: "/folder", + reqPath: "/folder/sub1/sub2/file.txt", applySub: true, - result: true, + want: true, + }, + { + name: "sibling paths with applySub=true", + metaPath: "/folder1", + reqPath: "/folder2", + applySub: true, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MetaCoversPath(tt.metaPath, tt.reqPath, tt.applySub) + if got != tt.want { + t.Errorf("MetaCoversPath(%q, %q, %v) = %v, want %v", + tt.metaPath, tt.reqPath, tt.applySub, got, tt.want) + } + }) + } +} + +func TestCanWriteContentIgnoringUserPerms(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: false, + reason: "nil meta should deny write", + }, + { + name: "meta.Write=false", + meta: &model.Meta{ + Path: "/folder", + Write: false, + }, + path: "/folder", + want: false, + reason: "Write=false should deny write", + }, + { + name: "exact path match with WSub=false", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, + }, + path: "/folder", + want: true, + reason: "exact path match should allow write", + }, + { + name: "sub path with WSub=true", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "sub path with WSub=true should allow write", + }, + { + name: "sub path with WSub=false (BEHAVIOR CHANGE)", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "sub path with WSub=false should deny write (fixed bug)", + }, + { + name: "non-sub path with WSub=true", + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: true, + }, + path: "/other", + want: false, + reason: "non-sub path should deny write even with WSub=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanWriteContentBypassUserPerms(tt.meta, tt.path) + if got != tt.want { + t.Errorf("CanWriteContentBypassUserPerms() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +func TestCanRead(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil user should allow access", + user: nil, + meta: nil, + path: "/any", + want: true, + reason: "nil user represents internal/system context and bypasses per-user read restrictions", + }, + { + name: "nil meta should allow access", + user: &model.User{ + ID: 1, + }, + meta: nil, + path: "/any", + want: true, + reason: "nil meta means no restrictions", + }, + { + name: "empty ReadUsers list should allow access", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{}, + }, + path: "/folder", + want: true, + reason: "empty ReadUsers means no user-level restrictions", + }, + { + name: "user in ReadUsers list with exact path match", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/folder", + want: true, + reason: "user ID 1 is in ReadUsers list", + }, + { + name: "user not in ReadUsers list with exact path match", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/folder", + want: false, + reason: "user ID 5 is not in ReadUsers list and path matches", + }, + { + name: "user not in ReadUsers list with ReadUsersSub=true for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: true, + }, + path: "/folder/subfolder", + want: false, + reason: "user ID 5 is not in ReadUsers list and ReadUsersSub applies to sub paths", + }, + { + name: "user not in ReadUsers list with ReadUsersSub=false for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/folder/subfolder", + want: true, + reason: "ReadUsersSub=false means restriction doesn't apply to sub paths", + }, + { + name: "user in ReadUsers list with ReadUsersSub=true for sub path", + user: &model.User{ + ID: 2, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: true, + }, + path: "/folder/subfolder/deep", + want: true, + reason: "user ID 2 is in ReadUsers list so can access sub paths", + }, + { + name: "user not in ReadUsers list for different path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: false, + }, + path: "/other", + want: true, + reason: "meta path doesn't match request path, so restriction doesn't apply", + }, + { + name: "root level restriction with ReadUsersSub=true", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/", + ReadUsers: []uint{1, 2, 3}, + ReadUsersSub: true, + }, + path: "/any/deep/path", + want: false, + reason: "root level restriction with ReadUsersSub affects all paths", }, } - for i, data := range datas { - if IsApply(data.metaPath, data.reqPath, data.applySub) != data.result { - t.Errorf("TestIsApply %d failed", i) - } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanRead(tt.user, tt.meta, tt.path) + if got != tt.want { + t.Errorf("CanRead() = %v, want %v\nReason: %s\nUser ID: %v, Meta: %+v, Path: %s", + got, tt.want, tt.reason, getUserID(tt.user), tt.meta, tt.path) + } + }) + } +} + +func TestCanWrite(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil user should allow access", + user: nil, + meta: nil, + path: "/any", + want: true, + reason: "nil user represents internal/system context and bypasses per-user write restrictions", + }, + { + name: "nil meta should allow access", + user: &model.User{ + ID: 1, + }, + meta: nil, + path: "/any", + want: true, + reason: "nil meta means no restrictions", + }, + { + name: "empty WriteUsers list should allow access", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{}, + }, + path: "/folder", + want: true, + reason: "empty WriteUsers means no user-level restrictions", + }, + { + name: "user in WriteUsers list with exact path match", + user: &model.User{ + ID: 1, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "user ID 1 is in WriteUsers list", + }, + { + name: "user not in WriteUsers list with exact path match", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "user ID 5 is not in WriteUsers list and path matches", + }, + { + name: "user not in WriteUsers list with WriteUsersSub=true for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: true, + }, + path: "/folder/subfolder", + want: false, + reason: "user ID 5 is not in WriteUsers list and WriteUsersSub applies to sub paths", + }, + { + name: "user not in WriteUsers list with WriteUsersSub=false for sub path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/folder/subfolder", + want: true, + reason: "WriteUsersSub=false means restriction doesn't apply to sub paths", + }, + { + name: "user in WriteUsers list with WriteUsersSub=true for sub path", + user: &model.User{ + ID: 2, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: true, + }, + path: "/folder/subfolder/deep", + want: true, + reason: "user ID 2 is in WriteUsers list so can write to sub paths", + }, + { + name: "user not in WriteUsers list for different path", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 2, 3}, + WriteUsersSub: false, + }, + path: "/other", + want: true, + reason: "meta path doesn't match request path, so restriction doesn't apply", + }, + { + name: "multiple users with mixed permissions", + user: &model.User{ + ID: 10, + }, + meta: &model.Meta{ + Path: "/folder", + WriteUsers: []uint{1, 5, 10, 15}, + WriteUsersSub: true, + }, + path: "/folder/file.txt", + want: true, + reason: "user ID 10 is in WriteUsers list", + }, + { + name: "write restriction at root level", + user: &model.User{ + ID: 5, + }, + meta: &model.Meta{ + Path: "/", + WriteUsers: []uint{1}, + WriteUsersSub: true, + }, + path: "/any/path", + want: false, + reason: "only user ID 1 can write when root has WriteUsers restriction", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanWrite(tt.user, tt.meta, tt.path) + if got != tt.want { + t.Errorf("CanWrite() = %v, want %v\nReason: %s\nUser ID: %v, Meta: %+v, Path: %s", + got, tt.want, tt.reason, getUserID(tt.user), tt.meta, tt.path) + } + }) + } +} + +func TestCanAccessWithReadPermissions(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + reqPath string + password string + want bool + reason string + }{ + { + name: "user with read permission and correct password", + user: &model.User{ + ID: 1, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + Password: "secret", + PSub: true, + }, + reqPath: "/folder/file.txt", + password: "secret", + want: true, + reason: "user in ReadUsers list with correct password", + }, + { + name: "user without read permission even with correct password", + user: &model.User{ + ID: 5, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + Password: "secret", + PSub: true, + }, + reqPath: "/folder/file.txt", + password: "secret", + want: false, + reason: "user not in ReadUsers list, should be denied before password check", + }, + { + name: "user with read permission but wrong password", + user: &model.User{ + ID: 1, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + Password: "secret", + PSub: true, + }, + reqPath: "/folder/file.txt", + password: "wrong", + want: false, + reason: "user in ReadUsers list but wrong password", + }, + { + name: "user without read permission and no password", + user: &model.User{ + ID: 5, + Role: model.GENERAL, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + ReadUsers: []uint{1, 2}, + ReadUsersSub: true, + }, + reqPath: "/folder/file.txt", + password: "", + want: false, + reason: "user not in ReadUsers list should be denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CanAccess(tt.user, tt.meta, tt.reqPath, tt.password) + if got != tt.want { + t.Errorf("CanAccess() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +// Helper function to safely get user ID +func getUserID(user *model.User) uint { + if user == nil { + return 0 + } + return user.ID +} + +// TestWritePermissionCombinations tests the combined permission check logic +// that is actually used in the codebase: +// +// if !user.CanWriteContent() && !CanWriteContentBypassUserPerms(meta, path) { +// deny +// } +// if !CanWrite(user, meta, path) { +// deny +// } +// +// This ensures the three-layer permission system works correctly: +// 1. User-level global write permission (CanWriteContent) +// 2. Meta-level global write permission (CanWriteContentBypassUserPerms) +// 3. Meta-level user whitelist (CanWrite) +func TestWritePermissionCombinations(t *testing.T) { + tests := []struct { + name string + user *model.User + meta *model.Meta + path string + want bool + reason string + checkFirstLayer bool // whether first layer should pass + checkSecondLayer bool // whether second layer should pass + expectedDenyReason string + }{ + // === Scenario 1: User has global write permission === + { + name: "user has CanWriteContent + in WriteUsers whitelist", + user: &model.User{ + ID: 1, + Permission: 1 << 3, // CanWriteContent = true + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "user has global write permission AND is in whitelist", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "user has CanWriteContent but NOT in WriteUsers whitelist", + user: &model.User{ + ID: 1, + Permission: 1 << 3, // CanWriteContent = true + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{2, 3}, // user 1 not in list + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "even with global write permission, must pass whitelist check", + checkFirstLayer: true, + checkSecondLayer: false, + expectedDenyReason: "whitelist check failed", + }, + + // === Scenario 2: User lacks global permission but meta.Write=true === + { + name: "no CanWriteContent + meta.Write=true + in WriteUsers", + user: &model.User{ + ID: 1, + Permission: 0, // CanWriteContent = false + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, // bypass enabled + WSub: false, + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "meta.Write bypasses user permission check, and user is in whitelist", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "no CanWriteContent + meta.Write=true + NOT in WriteUsers (KEY TEST)", + user: &model.User{ + ID: 5, + Permission: 0, // CanWriteContent = false + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, // bypass enabled + WSub: false, + WriteUsers: []uint{1, 2, 3}, // user 5 not in list + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "CRITICAL: meta.Write cannot bypass whitelist check (new behavior)", + checkFirstLayer: true, + checkSecondLayer: false, + expectedDenyReason: "whitelist check failed even with meta.Write=true", + }, + + // === Scenario 3: Both checks fail === + { + name: "no CanWriteContent + meta.Write=false", + user: &model.User{ + ID: 1, + Permission: 0, // CanWriteContent = false + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, // no bypass + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder", + want: false, + reason: "denied at first layer: no global permission and no bypass", + checkFirstLayer: false, + checkSecondLayer: false, + expectedDenyReason: "first layer check failed", + }, + + // === Scenario 4: Empty WriteUsers (no whitelist restriction) === + { + name: "user has CanWriteContent + empty WriteUsers", + user: &model.User{ + ID: 1, + Permission: 1 << 3, // CanWriteContent = true + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{}, // empty = no restriction + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "empty WriteUsers means no whitelist restriction", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "no CanWriteContent + meta.Write=true + empty WriteUsers", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, + WriteUsers: []uint{}, // empty = no restriction + WriteUsersSub: false, + }, + path: "/folder", + want: true, + reason: "meta.Write bypasses first check, empty whitelist passes second", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + + // === Scenario 5: Nil meta (no restrictions) === + { + name: "user has CanWriteContent + nil meta", + user: &model.User{ + ID: 1, + Permission: 1 << 3, + }, + meta: nil, + path: "/folder", + want: true, + reason: "nil meta means no restrictions", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "no CanWriteContent + nil meta", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: nil, + path: "/folder", + want: false, + reason: "nil meta cannot bypass lack of user permission", + checkFirstLayer: false, + checkSecondLayer: true, // would pass if first layer passed + expectedDenyReason: "first layer check failed", + }, + + // === Scenario 6: Sub-directory inheritance === + { + name: "meta.Write with WSub=true for subdirectory", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: true, // applies to subdirectories + WriteUsers: []uint{1}, + WriteUsersSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "WSub=true applies meta.Write to subdirectories", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "meta.Write with WSub=false for subdirectory", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/folder", + Write: true, + WSub: false, // does NOT apply to subdirectories + WriteUsers: []uint{1}, + WriteUsersSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "WSub=false means meta.Write doesn't apply to subdirectories", + checkFirstLayer: false, + checkSecondLayer: true, + expectedDenyReason: "first layer check failed (WSub=false)", + }, + { + name: "WriteUsersSub=false for subdirectory bypasses whitelist", + user: &model.User{ + ID: 5, // not in WriteUsers + Permission: 1 << 3, + }, + meta: &model.Meta{ + Path: "/folder", + Write: false, + WriteUsers: []uint{1, 2}, + WriteUsersSub: false, // whitelist does NOT apply to subdirectories + }, + path: "/folder/subfolder", + want: true, + reason: "WriteUsersSub=false means whitelist doesn't apply to subdirectories", + checkFirstLayer: true, + checkSecondLayer: true, // passes because restriction doesn't apply + expectedDenyReason: "", + }, + + // === Scenario 7: Root level restriction === + { + name: "root level meta.Write with user in whitelist", + user: &model.User{ + ID: 1, + Permission: 0, + }, + meta: &model.Meta{ + Path: "/", + Write: true, + WSub: true, + WriteUsers: []uint{1}, + WriteUsersSub: true, + }, + path: "/any/deep/path", + want: true, + reason: "root level permissions apply to all paths", + checkFirstLayer: true, + checkSecondLayer: true, + expectedDenyReason: "", + }, + { + name: "root level restriction denies non-whitelisted user", + user: &model.User{ + ID: 5, + Permission: 1 << 3, // has global permission + }, + meta: &model.Meta{ + Path: "/", + Write: false, + WriteUsers: []uint{1, 2}, + WriteUsersSub: true, + }, + path: "/any/path", + want: false, + reason: "root level whitelist restricts all paths", + checkFirstLayer: true, + checkSecondLayer: false, + expectedDenyReason: "not in root level whitelist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the actual permission check logic + firstLayerPass := tt.user.CanWriteContent() || CanWriteContentBypassUserPerms(tt.meta, tt.path) + secondLayerPass := CanWrite(tt.user, tt.meta, tt.path) + + // Verify our understanding of each layer + if firstLayerPass != tt.checkFirstLayer { + t.Errorf("First layer check mismatch: got %v, expected %v\n"+ + "CanWriteContent()=%v, CanWriteContentBypassUserPerms()=%v", + firstLayerPass, tt.checkFirstLayer, + tt.user.CanWriteContent(), CanWriteContentBypassUserPerms(tt.meta, tt.path)) + } + + if firstLayerPass && secondLayerPass != tt.checkSecondLayer { + t.Errorf("Second layer check mismatch: got %v, expected %v\n"+ + "CanWrite()=%v", + secondLayerPass, tt.checkSecondLayer, + CanWrite(tt.user, tt.meta, tt.path)) + } + + // Final result + got := firstLayerPass && secondLayerPass + + if got != tt.want { + t.Errorf("Permission check failed:\n"+ + " Result: %v, want %v\n"+ + " Reason: %s\n"+ + " First layer (CanWriteContent || CanWriteContentBypassUserPerms): %v\n"+ + " Second layer (CanWrite): %v\n"+ + " User: ID=%d, Permission=%d, CanWriteContent=%v\n"+ + " Meta: Path=%s, Write=%v, WSub=%v, WriteUsers=%v, WriteUsersSub=%v\n"+ + " Check Path: %s", + got, tt.want, + tt.reason, + firstLayerPass, + secondLayerPass, + tt.user.ID, tt.user.Permission, tt.user.CanWriteContent(), + getMetaPath(tt.meta), getMetaWrite(tt.meta), getMetaWSub(tt.meta), + getMetaWriteUsers(tt.meta), getMetaWriteUsersSub(tt.meta), + tt.path) + } + }) + } +} + +// Helper functions to safely extract meta fields +func getMetaPath(meta *model.Meta) string { + if meta == nil { + return "nil" + } + return meta.Path +} + +func getMetaWrite(meta *model.Meta) bool { + if meta == nil { + return false + } + return meta.Write +} + +func getMetaWSub(meta *model.Meta) bool { + if meta == nil { + return false + } + return meta.WSub +} + +func getMetaWriteUsers(meta *model.Meta) []uint { + if meta == nil { + return nil + } + return meta.WriteUsers +} + +func getMetaWriteUsersSub(meta *model.Meta) bool { + if meta == nil { + return false } + return meta.WriteUsersSub } diff --git a/server/ftp/fsmanage.go b/server/ftp/fsmanage.go index 48f72794..3e98d6d1 100644 --- a/server/ftp/fsmanage.go +++ b/server/ftp/fsmanage.go @@ -15,20 +15,23 @@ import ( func Mkdir(ctx context.Context, path string) error { user := ctx.Value(conf.UserKey).(*model.User) + if !user.CanFTPManage() { + return errs.PermissionDenied + } reqPath, err := user.JoinPath(path) if err != nil { return err } - if !user.CanWrite() || !user.CanFTPManage() { - meta, err := op.GetNearestMeta(stdpath.Dir(reqPath)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return err - } - } - if !common.CanWrite(meta, reqPath) { - return errs.PermissionDenied - } + parentPath := stdpath.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return errs.PermissionDenied } return fs.MakeDir(ctx, reqPath) } @@ -42,6 +45,13 @@ func Remove(ctx context.Context, path string) error { if err != nil { return err } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !common.CanWrite(user, meta, reqPath) { + return errs.PermissionDenied + } if err = RemoveStage(reqPath); !errors.Is(err, errs.ObjectNotFound) { return err } @@ -60,8 +70,12 @@ func Rename(ctx context.Context, oldPath, newPath string) error { } srcDir, srcBase := stdpath.Split(srcPath) dstDir, dstBase := stdpath.Split(dstPath) + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } if srcDir == dstDir { - if !user.CanRename() || !user.CanFTPManage() { + if !user.CanRename() || !user.CanFTPManage() || !common.CanWrite(user, dstMeta, dstDir) { return errs.PermissionDenied } if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) { @@ -69,7 +83,11 @@ func Rename(ctx context.Context, oldPath, newPath string) error { } return fs.Rename(ctx, srcPath, dstBase) } else { - if !user.CanFTPManage() || !user.CanMove() || (srcBase != dstBase && !user.CanRename()) { + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanMove() || !user.CanFTPManage() || (srcBase != dstBase && !user.CanRename()) || !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) { return errs.PermissionDenied } if err = MoveStage(srcPath, dstPath); !errors.Is(err, errs.ObjectNotFound) { diff --git a/server/ftp/fsread.go b/server/ftp/fsread.go index 9080bae1..54a3de8f 100644 --- a/server/ftp/fsread.go +++ b/server/ftp/fsread.go @@ -27,10 +27,8 @@ type FileDownloadProxy struct { func OpenDownload(ctx context.Context, reqPath string, offset int64) (*FileDownloadProxy, error) { user := ctx.Value(conf.UserKey).(*model.User) meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, err } ctx = context.WithValue(ctx, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { @@ -121,10 +119,8 @@ func Stat(ctx context.Context, path string) (os.FileInfo, error) { return nil, err } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, err } ctx = context.WithValue(ctx, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { @@ -147,10 +143,8 @@ func List(ctx context.Context, path string) ([]os.FileInfo, error) { return nil, err } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return nil, err - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return nil, err } ctx = context.WithValue(ctx, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, ctx.Value(conf.MetaPassKey).(string)) { diff --git a/server/ftp/fsup.go b/server/ftp/fsup.go index c549a194..7a96a4f6 100644 --- a/server/ftp/fsup.go +++ b/server/ftp/fsup.go @@ -33,14 +33,18 @@ type FileUploadProxy struct { func uploadAuth(ctx context.Context, path string) error { user := ctx.Value(conf.UserKey).(*model.User) - meta, err := op.GetNearestMeta(stdpath.Dir(path)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return err - } + if !user.CanFTPManage() { + return errs.PermissionDenied + } + parentPath := stdpath.Dir(path) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return errs.PermissionDenied } - if !(common.CanAccess(user, meta, path, ctx.Value(conf.MetaPassKey).(string)) && - ((user.CanFTPManage() && user.CanWrite()) || common.CanWrite(meta, stdpath.Dir(path)))) { + if !common.CanWrite(user, parentMeta, parentPath) { return errs.PermissionDenied } return nil diff --git a/server/handles/archive.go b/server/handles/archive.go index 56418de2..96bfd662 100644 --- a/server/handles/archive.go +++ b/server/handles/archive.go @@ -101,11 +101,9 @@ func FsArchiveMeta(c *gin.Context, req *ArchiveMetaReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -186,11 +184,9 @@ func FsArchiveList(c *gin.Context, req *ArchiveListReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -264,6 +260,15 @@ func FsArchiveDecompress(c *gin.Context) { common.ErrorResp(c, err, 403) return } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } tasks := make([]task.TaskExtensionInfo, 0, len(srcPaths)) for _, srcPath := range srcPaths { t, e := fs.ArchiveDecompress(c.Request.Context(), srcPath, dstDir, model.ArchiveDecompressArgs{ diff --git a/server/handles/fsbatch.go b/server/handles/fsbatch.go index 162419f7..28588d66 100644 --- a/server/handles/fsbatch.go +++ b/server/handles/fsbatch.go @@ -22,6 +22,7 @@ type RecursiveMoveReq struct { ConflictPolicy string `json:"conflict_policy"` } +// FsRecursiveMove recursively moves files (individual item permission checks skipped for performance). func FsRecursiveMove(c *gin.Context) { var req RecursiveMoveReq if err := c.ShouldBind(&req); err != nil { @@ -39,20 +40,31 @@ func FsRecursiveMove(c *gin.Context) { common.ErrorResp(c, err, 403) return } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, srcMeta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } + common.GinWithValue(c, conf.MetaKey, srcMeta) + dstDir, err := user.JoinPath(req.DstDir) if err != nil { common.ErrorResp(c, err, 403) return } - - meta, err := op.GetNearestMeta(srcDir) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } - common.GinWithValue(c, conf.MetaKey, meta) rootFiles, err := fs.List(c.Request.Context(), srcDir, &fs.ListArgs{}) if err != nil { @@ -143,6 +155,7 @@ type BatchRenameReq struct { } `json:"rename_objects"` } +// FsBatchRename performs batch rename (individual item permission checks skipped for performance). func FsBatchRename(c *gin.Context) { var req BatchRenameReq if err := c.ShouldBind(&req); err != nil { @@ -162,11 +175,13 @@ func FsBatchRename(c *gin.Context) { } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } common.GinWithValue(c, conf.MetaKey, meta) for _, renameObject := range req.RenameObjects { @@ -193,6 +208,7 @@ type RegexRenameReq struct { NewNameRegex string `json:"new_name_regex"` } +// FsRegexRename renames files by regex (individual item permission checks skipped for performance). func FsRegexRename(c *gin.Context) { var req RegexRenameReq if err := c.ShouldBind(&req); err != nil { @@ -212,11 +228,13 @@ func FsRegexRename(c *gin.Context) { } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } common.GinWithValue(c, conf.MetaKey, meta) diff --git a/server/handles/fsmanage.go b/server/handles/fsmanage.go index 8247fa8c..bda02f50 100644 --- a/server/handles/fsmanage.go +++ b/server/handles/fsmanage.go @@ -36,18 +36,19 @@ func FsMkdir(c *gin.Context) { common.ErrorResp(c, err, 403) return } - if !user.CanWrite() { - meta, err := op.GetNearestMeta(stdpath.Dir(reqPath)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } - } - if !common.CanWrite(meta, reqPath) { - common.ErrorResp(c, errs.PermissionDenied, 403) - return - } + parentPath := stdpath.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } + if !common.CanWrite(user, parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } if err := fs.MakeDir(c.Request.Context(), reqPath); err != nil { common.ErrorResp(c, err, 500) @@ -65,6 +66,7 @@ type MoveCopyReq struct { Merge bool `json:"merge"` } +// FsMove performs batch move (individual item permission checks skipped for performance). func FsMove(c *gin.Context) { var req MoveCopyReq if err := c.ShouldBind(&req); err != nil { @@ -85,11 +87,29 @@ func FsMove(c *gin.Context) { common.ErrorResp(c, err, 403) return } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, srcMeta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } dstDir, err := user.JoinPath(req.DstDir) if err != nil { common.ErrorResp(c, err, 403) return } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } var validNames []string if !req.Overwrite { @@ -132,6 +152,7 @@ func FsMove(c *gin.Context) { } } +// FsCopy performs batch copy (individual item permission checks skipped for performance). func FsCopy(c *gin.Context) { var req MoveCopyReq if err := c.ShouldBind(&req); err != nil { @@ -152,11 +173,29 @@ func FsCopy(c *gin.Context) { common.ErrorResp(c, err, 403) return } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanRead(user, srcMeta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } dstDir, err := user.JoinPath(req.DstDir) if err != nil { common.ErrorResp(c, err, 403) return } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, dstMeta, dstDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } var validNames []string if !req.Overwrite { @@ -233,6 +272,16 @@ func FsRename(c *gin.Context) { common.ErrorResp(c, err, 403) return } + parentPath := stdpath.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } if !req.Overwrite { dstPath := stdpath.Join(stdpath.Dir(reqPath), req.Name) if dstPath != reqPath { @@ -261,6 +310,7 @@ type RemoveReq struct { Names []string `json:"names"` } +// FsRemove performs batch remove (individual item permission checks skipped for performance). func FsRemove(c *gin.Context) { var req RemoveReq if err := c.ShouldBind(&req); err != nil { @@ -281,8 +331,27 @@ func FsRemove(c *gin.Context) { common.ErrorResp(c, err, 403) return } + meta, err := op.GetNearestMeta(reqDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } for _, name := range req.Names { - err := fs.Remove(c.Request.Context(), stdpath.Join(reqDir, name)) + // Skip invalid item names (empty string, whitespace, ".", "/","\t\t","..") to prevent accidental removal of current directory + if strings.TrimSpace(utils.FixAndCleanPath(name)) == "/" { + utils.Log.Warnf("FsRemove: invalid item skipped: %s (parent directory: %s)\n", name, reqDir) + continue + } + fullPath := stdpath.Join(reqDir, name) + if !strings.HasPrefix(fullPath+"/", reqDir+"/") { + utils.Log.Warnf("FsRemove: path traversal attempt skipped: %s (dir: %s)\n", name, req.Dir) + continue + } + err := fs.Remove(c.Request.Context(), fullPath) if err != nil { common.ErrorResp(c, err, 500) return @@ -296,6 +365,7 @@ type RemoveEmptyDirectoryReq struct { SrcDir string `json:"src_dir"` } +// FsRemoveEmptyDirectory recursively removes empty directories (individual item permission checks skipped for performance). func FsRemoveEmptyDirectory(c *gin.Context) { var req RemoveEmptyDirectoryReq if err := c.ShouldBind(&req); err != nil { @@ -315,11 +385,13 @@ func FsRemoveEmptyDirectory(c *gin.Context) { } meta, err := op.GetNearestMeta(srcDir) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, srcDir) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return } common.GinWithValue(c, conf.MetaKey, meta) diff --git a/server/handles/fsread.go b/server/handles/fsread.go index 886da9dc..a90fc108 100644 --- a/server/handles/fsread.go +++ b/server/handles/fsread.go @@ -47,13 +47,14 @@ type ObjResp struct { } type FsListResp struct { - Content []ObjResp `json:"content"` - Total int64 `json:"total"` - Readme string `json:"readme"` - Header string `json:"header"` - Write bool `json:"write"` - Provider string `json:"provider"` - DirectUploadTools []string `json:"direct_upload_tools,omitempty"` + Content []ObjResp `json:"content"` + Total int64 `json:"total"` + Readme string `json:"readme"` + Header string `json:"header"` + Write bool `json:"write"` + WriteContentBypass bool `json:"write_content_bypass"` + Provider string `json:"provider"` + DirectUploadTools []string `json:"direct_upload_tools,omitempty"` } func FsListSplit(c *gin.Context) { @@ -83,18 +84,17 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { common.ErrorStrResp(c, "password is incorrect or you have no permission", 403) return } - if !user.CanWrite() && !common.CanWrite(meta, reqPath) && req.Refresh { + canWriteContentAtPath := common.CanWrite(user, meta, reqPath) && (user.CanWriteContent() || common.CanWriteContentBypassUserPerms(meta, reqPath)) + if req.Refresh && !canWriteContentAtPath { common.ErrorStrResp(c, "Refresh without permission", 403) return } @@ -109,19 +109,20 @@ func FsList(c *gin.Context, req *ListReq, user *model.User) { total, objs := pagination(objs, &req.PageReq) provider := "unknown" var directUploadTools []string - if user.CanWrite() { + if canWriteContentAtPath { if storage, err := fs.GetStorage(reqPath, &fs.GetStoragesArgs{}); err == nil { directUploadTools = op.GetDirectUploadTools(storage) } } common.SuccessResp(c, FsListResp{ - Content: toObjsResp(objs, reqPath, isEncrypt(meta, reqPath)), - Total: int64(total), - Readme: getReadme(meta, reqPath), - Header: getHeader(meta, reqPath), - Write: user.CanWrite() || common.CanWrite(meta, reqPath), - Provider: provider, - DirectUploadTools: directUploadTools, + Content: toObjsResp(objs, reqPath, isEncrypt(meta, reqPath)), + Total: int64(total), + Readme: getReadme(meta, reqPath), + Header: getHeader(meta, reqPath), + Write: common.CanWrite(user, meta, reqPath), + WriteContentBypass: common.CanWriteContentBypassUserPerms(meta, reqPath), + Provider: provider, + DirectUploadTools: directUploadTools, }) } @@ -147,11 +148,9 @@ func FsDirs(c *gin.Context) { reqPath = tmp } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -186,14 +185,14 @@ func filterDirs(objs []model.Obj) []DirResp { } func getReadme(meta *model.Meta, path string) string { - if meta != nil && (utils.PathEqual(meta.Path, path) || meta.RSub) { + if meta != nil && common.MetaCoversPath(meta.Path, path, meta.RSub) { return meta.Readme } return "" } func getHeader(meta *model.Meta, path string) string { - if meta != nil && (utils.PathEqual(meta.Path, path) || meta.HeaderSub) { + if meta != nil && common.MetaCoversPath(meta.Path, path, meta.HeaderSub) { return meta.Header } return "" @@ -206,7 +205,7 @@ func isEncrypt(meta *model.Meta, path string) bool { if meta == nil || meta.Password == "" { return false } - if !utils.PathEqual(meta.Path, path) && !meta.PSub { + if !common.MetaCoversPath(meta.Path, path, meta.PSub) { return false } return true @@ -288,11 +287,9 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) { return } meta, err := op.GetNearestMeta(reqPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, reqPath, req.Password) { @@ -414,11 +411,9 @@ func FsOther(c *gin.Context) { return } meta, err := op.GetNearestMeta(req.Path) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500) + return } common.GinWithValue(c, conf.MetaKey, meta) if !common.CanAccess(user, meta, req.Path, req.Password) { diff --git a/server/handles/fsread_test.go b/server/handles/fsread_test.go new file mode 100644 index 00000000..3947ae27 --- /dev/null +++ b/server/handles/fsread_test.go @@ -0,0 +1,255 @@ +package handles + +import ( + "testing" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func TestGetReadme(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want string + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: "", + reason: "nil meta should return empty", + }, + { + name: "exact path match with RSub=false", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: false, + }, + path: "/folder", + want: "Welcome", + reason: "exact path should show readme", + }, + { + name: "sub path with RSub=true", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: true, + }, + path: "/folder/subfolder", + want: "Welcome", + reason: "sub path with RSub=true should show readme", + }, + { + name: "sub path with RSub=false", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: false, + }, + path: "/folder/subfolder", + want: "", + reason: "sub path with RSub=false should not show readme", + }, + { + name: "non-sub path with RSub=true (BEHAVIOR CHANGE - BUG FIX)", + meta: &model.Meta{ + Path: "/folder", + Readme: "Welcome", + RSub: true, + }, + path: "/other", + want: "", + reason: "non-sub path should not show readme even with RSub=true (fixed bug)", + }, + { + name: "root readme applies to all with RSub=true", + meta: &model.Meta{ + Path: "/", + Readme: "Global Info", + RSub: true, + }, + path: "/any/path", + want: "Global Info", + reason: "root readme with RSub=true should apply to all paths", + }, + { + name: "empty readme", + meta: &model.Meta{ + Path: "/folder", + Readme: "", + RSub: true, + }, + path: "/folder", + want: "", + reason: "empty readme should return empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getReadme(tt.meta, tt.path) + if got != tt.want { + t.Errorf("getReadme() = %q, want %q\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +func TestGetHeader(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want string + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: "", + reason: "nil meta should return empty", + }, + { + name: "exact path match with HeaderSub=false", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: false, + }, + path: "/folder", + want: "Custom Header", + reason: "exact path should show header", + }, + { + name: "sub path with HeaderSub=true", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: true, + }, + path: "/folder/subfolder", + want: "Custom Header", + reason: "sub path with HeaderSub=true should show header", + }, + { + name: "sub path with HeaderSub=false", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: false, + }, + path: "/folder/subfolder", + want: "", + reason: "sub path with HeaderSub=false should not show header", + }, + { + name: "non-sub path with HeaderSub=true (BEHAVIOR CHANGE - BUG FIX)", + meta: &model.Meta{ + Path: "/folder", + Header: "Custom Header", + HeaderSub: true, + }, + path: "/other", + want: "", + reason: "non-sub path should not show header even with HeaderSub=true (fixed bug)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getHeader(tt.meta, tt.path) + if got != tt.want { + t.Errorf("getHeader() = %q, want %q\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} + +func TestIsEncrypt(t *testing.T) { + tests := []struct { + name string + meta *model.Meta + path string + want bool + reason string + }{ + { + name: "nil meta", + meta: nil, + path: "/any", + want: false, + reason: "nil meta should not be encrypted", + }, + { + name: "empty password", + meta: &model.Meta{ + Path: "/folder", + Password: "", + }, + path: "/folder", + want: false, + reason: "empty password should not be encrypted", + }, + { + name: "exact path match with PSub=false", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: false, + }, + path: "/folder", + want: true, + reason: "exact path with password should be encrypted", + }, + { + name: "sub path with PSub=true", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: true, + }, + path: "/folder/subfolder", + want: true, + reason: "sub path with PSub=true should be encrypted", + }, + { + name: "sub path with PSub=false", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: false, + }, + path: "/folder/subfolder", + want: false, + reason: "sub path with PSub=false should not be encrypted", + }, + { + name: "non-sub path with PSub=true", + meta: &model.Meta{ + Path: "/folder", + Password: "secret", + PSub: true, + }, + path: "/other", + want: false, + reason: "non-sub path should not be encrypted even with PSub=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEncrypt(tt.meta, tt.path) + if got != tt.want { + t.Errorf("isEncrypt() = %v, want %v\nReason: %s", + got, tt.want, tt.reason) + } + }) + } +} diff --git a/server/handles/offline_download.go b/server/handles/offline_download.go index 153b2729..32fa64a4 100644 --- a/server/handles/offline_download.go +++ b/server/handles/offline_download.go @@ -12,12 +12,14 @@ import ( "github.com/OpenListTeam/OpenList/v4/drivers/thunder_browser" "github.com/OpenListTeam/OpenList/v4/drivers/thunderx" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/offline_download/tool" "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/task" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" + "github.com/pkg/errors" ) type SetAria2Req struct { @@ -448,6 +450,7 @@ func SetThunderBrowser(c *gin.Context) { case *thunder_browser.ThunderBrowser, *thunder_browser.ThunderBrowserExpert: default: common.ErrorStrResp(c, "unsupported storage driver for offline download, only ThunderBrowser is supported", 400) + return } } items := []model.SettingItem{ @@ -498,6 +501,15 @@ func AddOfflineDownload(c *gin.Context) { common.ErrorResp(c, err, 403) return } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + return + } + if !common.CanWrite(user, meta, reqPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return + } var tasks []task.TaskExtensionInfo for _, url := range req.Urls { // Filter out empty lines and whitespace-only strings diff --git a/server/handles/ssologin.go b/server/handles/ssologin.go index a36e79d3..4baabf6c 100644 --- a/server/handles/ssologin.go +++ b/server/handles/ssologin.go @@ -256,11 +256,13 @@ func OIDCLoginCallback(c *gin.Context) { user, err = autoRegister(userID, userID, err) if err != nil { common.ErrorResp(c, err, 400) + return } } token, err := common.GenerateToken(user) if err != nil { common.ErrorResp(c, err, 400) + return } if useCompatibility { c.Redirect(302, common.GetApiUrl(c)+"/@login?token="+token) @@ -427,6 +429,7 @@ func SSOLoginCallback(c *gin.Context) { token, err := common.GenerateToken(user) if err != nil { common.ErrorResp(c, err, 400) + return } if usecompatibility { c.Redirect(302, common.GetApiUrl(c)+"/@login?token="+token) diff --git a/server/handles/webauthn.go b/server/handles/webauthn.go index c7ad4edf..b2a0fbfb 100644 --- a/server/handles/webauthn.go +++ b/server/handles/webauthn.go @@ -130,17 +130,20 @@ func BeginAuthnRegistration(c *gin.Context) { authnInstance, err := authn.NewAuthnInstance(c) if err != nil { common.ErrorResp(c, err, 400) + return } options, sessionData, err := authnInstance.BeginRegistration(user) if err != nil { common.ErrorResp(c, err, 400) + return } val, err := json.Marshal(sessionData) if err != nil { common.ErrorResp(c, err, 400) + return } common.SuccessResp(c, gin.H{ diff --git a/server/middlewares/down.go b/server/middlewares/down.go index cb87eb3c..c1f81b54 100644 --- a/server/middlewares/down.go +++ b/server/middlewares/down.go @@ -25,11 +25,9 @@ func Down(verifyFunc func(string, string) error) func(c *gin.Context) { return func(c *gin.Context) { rawPath := c.Request.Context().Value(conf.PathKey).(string) meta, err := op.GetNearestMeta(rawPath) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorPage(c, err, 500, true) - return - } + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorPage(c, err, 500, true) + return } common.GinWithValue(c, conf.MetaKey, meta) // verify sign diff --git a/server/middlewares/fsup.go b/server/middlewares/fsup.go index 08b160ee..d99e62ae 100644 --- a/server/middlewares/fsup.go +++ b/server/middlewares/fsup.go @@ -15,7 +15,6 @@ import ( func FsUp(c *gin.Context) { path := c.GetHeader("File-Path") - password := c.GetHeader("Password") path, err := url.PathUnescape(path) if err != nil { common.ErrorResp(c, err, 400) @@ -28,15 +27,19 @@ func FsUp(c *gin.Context) { common.ErrorResp(c, err, 403) return } - meta, err := op.GetNearestMeta(stdpath.Dir(path)) - if err != nil { - if !errors.Is(errors.Cause(err), errs.MetaNotFound) { - common.ErrorResp(c, err, 500, true) - c.Abort() - return - } + parentPath := stdpath.Dir(path) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + common.ErrorResp(c, err, 500, true) + c.Abort() + return + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + common.ErrorResp(c, errs.PermissionDenied, 403) + c.Abort() + return } - if !(common.CanAccess(user, meta, path, password) && (user.CanWrite() || common.CanWrite(meta, stdpath.Dir(path)))) { + if !common.CanWrite(user, parentMeta, parentPath) { common.ErrorResp(c, errs.PermissionDenied, 403) c.Abort() return diff --git a/server/webdav.go b/server/webdav.go index 789236b8..a949068f 100644 --- a/server/webdav.go +++ b/server/webdav.go @@ -117,22 +117,22 @@ func WebDAVAuth(c *gin.Context) { c.Abort() return } - if (c.Request.Method == "PUT" || c.Request.Method == "MKCOL") && (!user.CanWebdavManage() || !user.CanWrite()) { + if (c.Request.Method == "PUT" || c.Request.Method == "MKCOL") && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return } - if c.Request.Method == "MOVE" && (!user.CanWebdavManage() || (!user.CanMove() && !user.CanRename())) { + if c.Request.Method == "MOVE" && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return } - if c.Request.Method == "COPY" && (!user.CanWebdavManage() || !user.CanCopy()) { + if c.Request.Method == "COPY" && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return } - if c.Request.Method == "DELETE" && (!user.CanWebdavManage() || !user.CanRemove()) { + if c.Request.Method == "DELETE" && !user.CanWebdavManage() { c.Status(http.StatusForbidden) c.Abort() return @@ -143,6 +143,11 @@ func WebDAVAuth(c *gin.Context) { return } common.GinWithValue(c, conf.UserKey, user) + if user.IsGuest() { + common.GinWithValue(c, conf.MetaPassKey, password) + } else { + common.GinWithValue(c, conf.MetaPassKey, "") + } c.Next() } diff --git a/server/webdav/file.go b/server/webdav/file.go index debfcfe9..ea609973 100644 --- a/server/webdav/file.go +++ b/server/webdav/file.go @@ -11,9 +11,12 @@ import ( "path/filepath" "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/pkg/errors" ) // slashClean is equivalent to but slightly more efficient than @@ -26,6 +29,7 @@ func slashClean(name string) string { } // moveFiles moves files and/or directories from src to dst. +// Individual item permission checks are skipped for performance reasons. // // See section 9.9.4 for when various HTTP status codes apply. func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) { @@ -40,6 +44,17 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int if srcName != dstName && !user.CanRename() { return http.StatusForbidden, nil } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, srcMeta, srcDir) || !common.CanWrite(user, dstMeta, dstDir) { + return http.StatusForbidden, nil + } if srcDir == dstDir { err = fs.Rename(ctx, src, dstName) } else { @@ -59,10 +74,30 @@ func moveFiles(ctx context.Context, src, dst string, overwrite bool) (status int } // copyFiles copies files and/or directories from src to dst. +// Individual item permission checks are skipped for performance reasons. // // See section 9.8.5 for when various HTTP status codes apply. func copyFiles(ctx context.Context, src, dst string, overwrite bool) (status int, err error) { + srcDir := path.Dir(src) dstDir := path.Dir(dst) + user := ctx.Value(conf.UserKey).(*model.User) + if !user.CanCopy() { + return http.StatusForbidden, nil + } + srcMeta, err := op.GetNearestMeta(srcDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanRead(user, srcMeta, srcDir) { + return http.StatusForbidden, nil + } + dstMeta, err := op.GetNearestMeta(dstDir) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, dstMeta, dstDir) { + return http.StatusForbidden, nil + } _, err = fs.Copy(context.WithValue(ctx, conf.NoTaskKey, struct{}{}), src, dstDir) if err != nil { return http.StatusInternalServerError, err diff --git a/server/webdav/webdav.go b/server/webdav/webdav.go index 504c5fc1..06d1431a 100644 --- a/server/webdav/webdav.go +++ b/server/webdav/webdav.go @@ -7,7 +7,6 @@ package webdav // import "golang.org/x/net/webdav" import ( "context" - "errors" "fmt" "io" "net/http" @@ -20,8 +19,10 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/conf" "github.com/OpenListTeam/OpenList/v4/internal/net" + "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/pkg/errors" "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" @@ -200,7 +201,7 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) (status user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err } allow := "OPTIONS, LOCK, PUT, MKCOL" if fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err == nil { @@ -226,10 +227,18 @@ func (h *Handler) handleGetHeadPost(w http.ResponseWriter, r *http.Request) (sta // TODO: check locks for read-only access?? ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) + password, _ := ctx.Value(conf.MetaPassKey).(string) reqPath, err = user.JoinPath(reqPath) if err != nil { return http.StatusForbidden, err } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanAccess(user, meta, reqPath, password) { + return http.StatusForbidden, errs.PermissionDenied + } fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) if err != nil { return http.StatusNotFound, err @@ -294,9 +303,12 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i ctx := r.Context() user := ctx.Value(conf.UserKey).(*model.User) + if !user.CanRemove() { + return http.StatusForbidden, nil + } reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err } // TODO: return MultiStatus where appropriate. @@ -309,6 +321,14 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) (status i } return http.StatusMethodNotAllowed, err } + parentPath := path.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } if err := fs.Remove(ctx, reqPath); err != nil { return http.StatusMethodNotAllowed, err } @@ -363,6 +383,17 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request) (status int, if setting.GetBool(conf.IgnoreSystemFiles) && utils.IsSystemFile(obj.Name) { return http.StatusForbidden, errs.IgnoredSystemFile } + parentPath := path.Dir(reqPath) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } fsStream := &stream.FileStream{ Obj: &obj, Reader: r.Body, @@ -407,7 +438,7 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err } if r.ContentLength > 0 { @@ -421,13 +452,23 @@ func (h *Handler) handleMkcol(w http.ResponseWriter, r *http.Request) (status in } // RFC 4918 9.3.1 // 409 (Conflict) The server MUST NOT create those intermediate collections automatically. - reqDir := path.Dir(reqPath) - if _, err := fs.Get(ctx, reqDir, &fs.GetArgs{}); err != nil { + parentPath := path.Dir(reqPath) + if _, err := fs.Get(ctx, parentPath, &fs.GetArgs{}); err != nil { if errs.IsObjectNotFound(err) { return http.StatusConflict, err } return http.StatusMethodNotAllowed, err } + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return http.StatusForbidden, errs.PermissionDenied + } if err := fs.MakeDir(ctx, reqPath); err != nil { if os.IsNotExist(err) { return http.StatusConflict, err @@ -471,11 +512,11 @@ func (h *Handler) handleCopyMove(w http.ResponseWriter, r *http.Request) (status user := ctx.Value(conf.UserKey).(*model.User) src, err = user.JoinPath(src) if err != nil { - return 403, err + return http.StatusForbidden, err } dst, err = user.JoinPath(dst) if err != nil { - return 403, err + return http.StatusForbidden, err } if r.Method == "COPY" { @@ -572,7 +613,14 @@ func (h *Handler) handleLock(w http.ResponseWriter, r *http.Request) (retStatus } reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, meta, reqPath) { + return http.StatusForbidden, errs.PermissionDenied } ld = LockDetails{ Root: reqPath, @@ -630,6 +678,24 @@ func (h *Handler) handleUnlock(w http.ResponseWriter, r *http.Request) (status i } t = t[1 : len(t)-1] + reqPath, status, err := h.stripPrefix(r.URL.Path) + if err != nil { + return status, err + } + ctx := r.Context() + user := ctx.Value(conf.UserKey).(*model.User) + reqPath, err = user.JoinPath(reqPath) + if err != nil { + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, meta, reqPath) { + return http.StatusForbidden, errs.PermissionDenied + } + switch err = h.LockSystem.Unlock(time.Now(), t); err { case nil: return http.StatusNoContent, err @@ -653,9 +719,17 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) (status userAgent := r.Header.Get("User-Agent") ctx = context.WithValue(ctx, conf.UserAgentKey, userAgent) user := ctx.Value(conf.UserKey).(*model.User) + password, _ := ctx.Value(conf.MetaPassKey).(string) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanAccess(user, meta, reqPath, password) { + return http.StatusForbidden, errs.PermissionDenied } fi, err := fs.Get(ctx, reqPath, &fs.GetArgs{}) if err != nil { @@ -734,7 +808,14 @@ func (h *Handler) handleProppatch(w http.ResponseWriter, r *http.Request) (statu user := ctx.Value(conf.UserKey).(*model.User) reqPath, err = user.JoinPath(reqPath) if err != nil { - return 403, err + return http.StatusForbidden, err + } + meta, err := op.GetNearestMeta(reqPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return http.StatusInternalServerError, err + } + if !common.CanWrite(user, meta, reqPath) { + return http.StatusForbidden, errs.PermissionDenied } if _, err := fs.Get(ctx, reqPath, &fs.GetArgs{}); err != nil { if errs.IsObjectNotFound(err) {