Compare commits

..

2 Commits

Author SHA1 Message Date
MadDogOwner 80487122ea refactor(aws-sdk)!: migrate to transfermanager
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
2026-07-16 22:08:57 +08:00
MadDogOwner f99a1f9a50 refactor(aws-sdk): bump to aws/aws-sdk-go-v2
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
2026-07-16 22:08:33 +08:00
215 changed files with 2100 additions and 15566 deletions
+4 -12
View File
@@ -82,17 +82,9 @@ body:
label: 复现链接(可选)
description: |
请提供能复现此问题的链接。
- type: checkboxes
- type: textarea
id: aigenerated
attributes:
label: AI生成内容
description: 必须且只能勾选一项,请勿删除或修改声明文字。
options:
- label: 我使用了AI工具生成此内容
- label: 我没有使用AI工具生成此内容
- type: input
id: ai-model
attributes:
label: AI模型是
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
placeholder: xxx
label: AI生成内容(可选)
description: |
如果此问题是由AI辅助您发现的,请提供全部聊天记录,包括使用的模型信息。
+4 -12
View File
@@ -82,17 +82,9 @@ body:
label: Reproduction Link (optional)
description: |
Please provide a link to a repo or page that can reproduce this issue.
- type: checkboxes
- type: textarea
id: aigenerated
attributes:
label: AI Generated Content
description: Select exactly one option. Do not delete or modify the disclosure text.
options:
- label: I used AI tools to generate this content
- label: I did not use AI tools to generate this content
- type: input
id: ai-model
attributes:
label: AI model used
description: If you used AI tools, enter the model name; otherwise leave this blank.
placeholder: xxx
label: AI Generated Content (optional)
description: |
If this issue was identified with the assistance of AI, please provide the complete chat log, including information about the model used.
@@ -48,17 +48,9 @@ body:
label: 附加信息
description: |
相关的任何其他上下文或截图,或者你觉得有帮助的信息
- type: checkboxes
- type: textarea
id: aigenerated
attributes:
label: AI生成内容
description: 必须且只能勾选一项,请勿删除或修改声明文字。
options:
- label: 我使用了AI工具生成此内容
- label: 我没有使用AI工具生成此内容
- type: input
id: ai-model
attributes:
label: AI模型是
description: 如果使用了AI工具,请填写模型名称;未使用则留空。
placeholder: xxx
label: AI生成内容(可选)
description: |
如果此请求是由AI辅助您提交的,请提供全部聊天记录,包括使用的模型信息。
@@ -48,17 +48,9 @@ body:
label: Additional Information
description: |
Any other context or screenshots related to this feature request, or information you find helpful.
- type: checkboxes
- type: textarea
id: aigenerated
attributes:
label: AI Generated Content
description: Select exactly one option. Do not delete or modify the disclosure text.
options:
- label: I used AI tools to generate this content
- label: I did not use AI tools to generate this content
- type: input
id: ai-model
attributes:
label: AI model used
description: If you used AI tools, enter the model name; otherwise leave this blank.
placeholder: xxx
label: AI Generated Content (optional)
description: |
If this request was submitted with the assistance of an AI, please provide the complete chat log, including information about the model used.
+8 -8
View File
@@ -18,13 +18,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Create or update ref
id: create-or-update-ref
uses: ovsds/create-or-update-ref-action@1157b2991820188d5a8e6f22656775d1d171f762 # v1
uses: ovsds/create-or-update-ref-action@v1
with:
ref: tags/beta
sha: ${{ github.sha }}
@@ -51,7 +51,7 @@ jobs:
gh release upload beta "CHANGELOG.md" --clobber
- name: Upload assets to github artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: beta changelog
path: ${{ github.workspace }}/CHANGELOG.md
@@ -108,14 +108,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: "1.27.1"
go-version: "1.26.4"
- name: Setup web
run: bash build.sh dev web
@@ -124,7 +124,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
uses: OpenListTeam/cgo-actions@v1.2.5
with:
targets: ${{ matrix.target }}
flags: ${{ matrix.flags || '-ldflags=' }}
@@ -136,7 +136,7 @@ jobs:
musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/"
x-flags: |
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@openlist.team>
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
@@ -185,7 +185,7 @@ jobs:
echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV
- name: Upload assets to github artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: beta builds for ${{ env.cleaned_target }}
path: ${{ github.workspace }}/build/compress/*
+5 -5
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
- uses: benjlevesque/short-sha@dbe07338b37c456ce06d23409b35a56a7815eef4 # v4.0
id: short-sha
@@ -33,7 +33,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: "1.27.1"
go-version: "1.26.4"
- name: Setup web
run: bash build.sh dev web
@@ -42,7 +42,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Build
uses: OpenListTeam/cgo-actions@d760a8ec1a6be1f8ec181229e11cb2671797f9e0 # v1.3.0
uses: OpenListTeam/cgo-actions@v1.2.5
with:
targets: ${{ matrix.target }}
flags: ${{ contains(matrix.target, '-musl') && '-ldflags=-linkmode external -extldflags ''-static -fpic''' || '-ldflags=' }}
@@ -52,7 +52,7 @@ jobs:
out-dir: build
x-flags: |
github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@oplist.org>
github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors <noreply@openlist.team>
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
@@ -69,7 +69,7 @@ jobs:
fi
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: openlist_${{ steps.short-sha.outputs.sha }}_${{ matrix.target }}
path: build/*
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 0
-75
View File
@@ -1,75 +0,0 @@
name: Issue Auto Reply
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
auto-reply:
runs-on: ubuntu-latest
steps:
- name: Check issue for unchecked tasks and reply
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.payload.issue.title.startsWith('[Announcements]')) return;
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(context.payload.issue.title);
const issueBody = context.payload.issue.body || "";
const aiSection = issueBody.match(/^### (AI生成内容|AI Generated Content)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
const aiOptionsPattern = aiSection?.[1] === 'AI生成内容'
? /^\s*- \[([ xX])\] 我使用了AI工具生成此内容\r?\n- \[([ xX])\] 我没有使用AI工具生成此内容\s*$/
: /^\s*- \[([ xX])\] I used AI tools to generate this content\r?\n- \[([ xX])\] I did not use AI tools to generate this content\s*$/;
const aiOptions = (aiSection?.[2] || '').match(aiOptionsPattern);
const validAiDisclosure = aiOptions !== null && (aiOptions[1] !== ' ') !== (aiOptions[2] !== ' ');
const aiModelSection = issueBody.match(/^### (AI模型是|AI model used)\r?\n([\s\S]*?)(?=^### |$(?![\s\S]))/m);
const aiModel = (aiModelSection?.[2] || '').trim();
const missingAiModel = validAiDisclosure && aiOptions[1] !== ' ' && (aiModel === '' || aiModel === '_No response_');
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
const closeIssue = titleNotEdited || confirmNotRead || !validAiDisclosure || missingAiModel;
let comment;
if (titleNotEdited) {
comment = `⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭并锁定。如需继续提交,请创建新的 Issue。
⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed and locked. If you wish to proceed, please create a new issue.
`;
} else if (confirmNotRead || !validAiDisclosure || missingAiModel) {
comment = `⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭并锁定。如需继续提交,请确认已了解规则后创建新的 Issue。
⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed and locked. If you wish to proceed, please confirm that you have reviewed the rules before creating a new issue.
`;
} else if (/- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody.replace(aiSection[0], ''))) {
comment = `感谢您联系OpenList。我们会尽快回复您。
Thanks for contacting OpenList. We will reply to you as soon as possible.
由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。
如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!
Since your issue contains some unchecked tasks, it may be closed after manual review.
If you can confirm and provide information for the unchecked tasks, feel free to resubmit.
We will pay attention and handle it in a timely manner.
感谢您的理解与支持!
Thank you for your understanding and support!
`;
} else {
return;
}
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
if (closeIssue) {
await github.rest.issues.update({
...context.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
labels: ['invalid']
});
await github.rest.issues.lock({
...context.repo,
issue_number: context.issue.number
});
}
+101
View File
@@ -0,0 +1,101 @@
name: Issue or PR Auto Reply
on:
issues:
types: [opened]
pull_request:
types: [opened]
permissions:
issues: write
pull-requests: write
jobs:
auto-reply:
runs-on: ubuntu-latest
if: github.event_name == 'issues'
steps:
- name: Check issue for unchecked tasks and reply
uses: actions/github-script@v9
with:
script: |
let comment = "";
const issueTitle = context.payload.issue.title || "";
const titleNotEdited = /(请修改标题|Please modify the title)/i.test(issueTitle);
if (titleNotEdited) {
comment = "⚠️ 请修改标题以更好地描述您的问题或需求,并删除示例提示。当前 Issue 将被自动关闭。如需继续提交,请创建新的 Issue。\n";
comment += "⚠️ Please modify the title to better describe your issue or request, and remove the example prompt. This issue will be automatically closed. If you wish to proceed, please create a new issue.\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
await github.rest.issues.update({
...context.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
labels: ['invalid']
});
return;
}
const issueBody = context.payload.issue.body || "";
const confirmHasRead = /- \[ \] (?!我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
const confirmNotRead = /- \[[xX]\] (?:我没有阅读这个清单|I have not read these checkboxes)/.test(issueBody);
if (confirmNotRead) {
comment = "⚠️ 你的 Issue 不符合提交规则。请先阅读相关规范后再重新提交。当前 Issue 将被自动关闭。如需继续提交,请确认已了解规则后重新打开或创建新的 Issue。\n";
comment += "⚠️ Your issue does not comply with the submission rules. Please read the guidelines before submitting again. This issue will be automatically closed. If you wish to proceed, please confirm that you have reviewed the rules before reopening or creating a new issue.\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
await github.rest.issues.update({
...context.repo,
issue_number: context.issue.number,
state: 'closed',
state_reason: 'not_planned',
labels: ['invalid']
});
return;
}
if (confirmHasRead) {
comment = "感谢您联系OpenList。我们会尽快回复您。\n";
comment += "Thanks for contacting OpenList. We will reply to you as soon as possible.\n\n";
comment += "由于您提出的 Issue 中包含部分未确认的项目,为了更好地管理项目,在人工审核后可能会直接关闭此问题。\n";
comment += "如果您能确认并补充相关未确认项目的信息,欢迎随时重新提交。我们会及时关注并处理。感谢您的理解与支持!\n";
comment += "Since your issue contains some unchecked tasks, it may be closed after manual review.\n";
comment += "If you can confirm and provide information for the unchecked tasks, feel free to resubmit.\n";
comment += "We will pay attention and handle it in a timely manner.\n\n";
comment += "感谢您的理解与支持!\n";
comment += "Thank you for your understanding and support!\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
}
pr-title-check:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Check PR title for required prefix and comment
uses: actions/github-script@v9
with:
script: |
const title = context.payload.pull_request.title || "";
const ok = /^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title);
if (!ok) {
let comment = "⚠️ PR 标题需以 `feat(): `, `docs(): `, `fix(): `, `style(): `, `refactor(): `, `chore(): ` 其中之一开头,例如:`feat(component): 新增功能`。\n";
comment += "⚠️ The PR title must start with `feat(): `, `docs(): `, `fix(): `, `style(): `, or `refactor(): `, `chore(): `. For example: `feat(component): add new feature`.\n\n";
comment += "如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。\n";
comment += "If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.\n\n";
comment += "如果是破坏性变更,请在类型后添加 `!`,例如 `feat(component)!: 破坏性变更`。\n";
comment += "For breaking changes, add `!` after the type, e.g., `feat(component)!: breaking change`.\n\n";
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
}
-34
View File
@@ -1,34 +0,0 @@
name: PR Title Check
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
pr-title-check:
runs-on: ubuntu-latest
steps:
- name: Check PR title for required prefix and comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const title = context.payload.pull_request.title;
if (/^(feat|docs|fix|style|refactor|chore)\(.+?\)!?: /i.test(title)) return;
const comment = `⚠️ PR 标题需以 \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, \`refactor(): \`, \`chore(): \` 其中之一开头,例如:\`feat(component): 新增功能\`。
⚠️ The PR title must start with \`feat(): \`, \`docs(): \`, \`fix(): \`, \`style(): \`, or \`refactor(): \`, \`chore(): \`. For example: \`feat(component): add new feature\`.
如果跨多个组件,请使用主要组件作为前缀,并在标题中枚举、描述中说明。
If it spans multiple components, use the main component as the prefix and enumerate in the title, describe in the body.
如果是破坏性变更,请在类型后添加 \`!\`,例如 \`feat(component)!: 破坏性变更\`。
For breaking changes, add \`!\` after the type, e.g., \`feat(component)!: breaking change\`.
`;
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body: comment
});
+4 -4
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Prerelease
uses: irongut/EditRelease@ccf529ad26dddf9996e7dd0f24ca5da4ea507cc2 # v1.2.0
uses: irongut/EditRelease@v1.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
id: ${{ github.event.release.id }}
@@ -33,7 +33,7 @@ jobs:
- name: Free Disk Space (Ubuntu)
if: matrix.target-platform == ''
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: true
@@ -46,10 +46,10 @@ jobs:
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.27.1'
go-version: '1.26.4'
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-tags: true
+20 -20
View File
@@ -43,15 +43,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.27'
go-version: '1.26'
- name: Cache Musl
id: cache-musl
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
uses: actions/cache@v6
with:
path: build/musl-libs
key: docker-musl-libs-v2
@@ -69,7 +69,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: ${{ env.ARTIFACT_NAME }}
overwrite: true
@@ -83,15 +83,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.27.1'
go-version: '1.26.4'
- name: Cache Musl
id: cache-musl
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
uses: actions/cache@v6
with:
path: build/musl-libs
key: docker-musl-libs-v2
@@ -109,7 +109,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: ${{ env.ARTIFACT_NAME_LITE }}
overwrite: true
@@ -146,21 +146,21 @@ jobs:
tag_favor: "suffix=-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: ${{ env.ARTIFACT_NAME }}
path: 'build/'
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to GitHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -168,7 +168,7 @@ jobs:
- name: Login to DockerHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -230,21 +230,21 @@ jobs:
tag_favor: "suffix=-lite-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: ${{ env.ARTIFACT_NAME_LITE }}
path: 'build/'
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to GitHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -252,7 +252,7 @@ jobs:
- name: Login to DockerHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: Sync GitHub to Gitee
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 0
+9 -9
View File
@@ -32,11 +32,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: '1.27.1'
go-version: '1.26.4'
- name: Cache Musl
id: cache-musl
@@ -58,7 +58,7 @@ jobs:
FRONTEND_REPO: ${{ vars.FRONTEND_REPO }}
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@v7
with:
name: ${{ env.ARTIFACT_NAME }}
overwrite: true
@@ -97,21 +97,21 @@ jobs:
tag_favor: "suffix=-aio,onlatest=true"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: ${{ env.ARTIFACT_NAME }}
path: 'build/'
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to GitHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -119,7 +119,7 @@ jobs:
- name: Login to DockerHub Container Registry
if: env.IMAGE_PUSH == 'true'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
username: ${{ vars.DOCKERHUB_ORG_NAME_BACKUP || env.DOCKERHUB_ORG_NAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger Makefile hash update
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
uses: actions/github-script@v9
with:
github-token: ${{ secrets.EXTERNAL_REPO_TOKEN_LUCI_APP_OPENLIST }}
script: |
+2 -4
View File
@@ -22,7 +22,6 @@ output/
# vendor/
/bin/*
*.json
!renovate.json
/build
/data/
/tmp/
@@ -30,7 +29,6 @@ output/
/lang/
/daemon/
/public/dist/*
!/public/dist/README.md
/!public/dist/README.md
.VSCodeCounter
*.syso
.VSCodeCounter
+1 -1
View File
@@ -1,5 +1,5 @@
### Default image is base. You can add other support by modifying BASE_IMAGE_TAG. The following parameters are supported: base (default), aria2, ffmpeg, aio
ARG BASE_IMAGE_TAG=base@sha256:042e7139b7daf131b15bb582c2e9f1ce0f8bf2b3e9ac04a5368bf3088c2d73c2
ARG BASE_IMAGE_TAG=base
FROM alpine:edge AS builder
LABEL stage=go-builder
+1 -1
View File
@@ -67,7 +67,7 @@ Thank you for your support and understanding of the OpenList project.
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -67,7 +67,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3
- [x] [MediaFire](https://www.mediafire.com)
- [x] [分秒帧](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组、分享)
- [x] [和彩云](https://yun.139.com)(个人、家庭、群组)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [百度网盘](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -66,7 +66,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい
- [x] Teambition([中国](https://www.teambition.com), [国際](https://us.teambition.com))
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com)(個人、家族、グループ、共有)
- [x] [139yun](https://yun.139.com)(個人、家族、グループ)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -67,7 +67,7 @@ Dank u voor uw ondersteuning en begrip
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep, Delen)
- [x] [139yun](https://yun.139.com) (Persoonlijk, Familie, Groep)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+1 -1
View File
@@ -50,7 +50,7 @@ OpenList has no official association with third-party derivative projects that u
- [x] [MediaFire](https://www.mediafire.com)
- [x] [Mediatrack](https://www.mediatrack.cn)
- [x] [ProtonDrive](https://proton.me/drive)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group, Share)
- [x] [139yun](https://yun.139.com) (Personal, Family, Group)
- [x] [YandexDisk](https://disk.yandex.com)
- [x] [BaiduNetdisk](http://pan.baidu.com)
- [x] [Terabox](https://www.terabox.com/main)
+4 -4
View File
@@ -1,7 +1,7 @@
set -e
appName="openlist"
builtAt="$(date +'%F %T %z')"
gitAuthor="The OpenList Projects Contributors <noreply@oplist.org>"
gitAuthor="The OpenList Projects Contributors <noreply@openlist.team>"
gitCommit=$(git log --pretty=format:"%h" -1)
# Set frontend repository, default to OpenListTeam/OpenList-Frontend
@@ -97,7 +97,7 @@ AssertStaticBinary() {
}
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/edge\"")
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')
# There is no lite for rolling
@@ -531,8 +531,8 @@ BuildReleaseFreeBSD() {
sed 's/\.0$//')
if [ -z "$freebsd_version" ]; then
echo "Failed to get FreeBSD version, falling back to 14.4"
freebsd_version="14.4"
echo "Failed to get FreeBSD version, falling back to 14.3"
freebsd_version="14.3"
fi
echo "Using FreeBSD version: $freebsd_version"
+1 -1
View File
@@ -23,7 +23,7 @@ type options struct {
pwd string //de/encrypt password
salt string
filenameEncryption string //reference drivers\crypt\meta.go Addition
filenameEncryption string //reference drivers\crypt\meta.go Addtion
dirnameEncryption string
filenameEncode string
suffix string
+7 -17
View File
@@ -8,7 +8,6 @@ import (
"os/exec"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
@@ -32,23 +31,14 @@ func start() {
return
}
}
exe, err := os.Executable()
if err != nil {
log.Fatal("failed to resolve executable path: ", err)
args := os.Args
args[1] = "server"
args = append(args, "--force-bin-dir")
cmd := &exec.Cmd{
Path: args[0],
Args: args,
Env: os.Environ(),
}
childArgs := append([]string{"server"}, os.Args[2:]...)
hasForceBinDir := false
for _, arg := range childArgs {
if arg == "--force-bin-dir" || strings.HasPrefix(arg, "--force-bin-dir=") {
hasForceBinDir = true
break
}
}
if !hasForceBinDir {
childArgs = append(childArgs, "--force-bin-dir")
}
cmd := exec.Command(exe, childArgs...)
cmd.Env = os.Environ()
stdout, err := os.OpenFile(filepath.Join(filepath.Dir(pidFile), "start.log"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
log.Fatal(os.Getpid(), ": failed to open start log file:", err)
+1 -1
View File
@@ -11,4 +11,4 @@ services:
- UMASK=022
- TZ=Asia/Shanghai
container_name: openlist
image: 'openlistteam/openlist:latest@sha256:c555c6e1c8af2aead38ed12ec761ac077fdf046d19cf033414be8e056aec6b64'
image: 'openlistteam/openlist:latest'
+2 -4
View File
@@ -337,11 +337,9 @@ func (d *Pan115) UploadByMultipart(ctx context.Context, params *driver115.Upload
for chunk := range chunksCh {
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
for retry := 0; retry < 3; retry++ {
if utils.IsCanceled(ctx) {
err = ctx.Err()
break
}
select {
case <-ctx.Done():
break
case <-ticker.C:
if ossToken, err = d.client.GetOSSToken(); err != nil { // 到时重新获取ossToken
errCh <- errors.Wrap(err, "刷新token时出现错误")
+3 -44
View File
@@ -172,59 +172,18 @@ func (d *Open115) Get(ctx context.Context, path string) (model.Obj, error) {
resp, err := d.client.GetFolderInfoByPath(ctx, path)
if err != nil {
if errors.Is(err, sdk.ErrObjectNotFound) {
return d.getFromParent(ctx, path, "")
return nil, errs.ObjectNotFound
}
return nil, err
}
obj := &Obj{
return &Obj{
Fid: resp.FileID,
Fn: resp.FileName,
Fc: resp.FileCategory,
Sha1: resp.Sha1,
Pc: resp.PickCode,
FS: resp.SizeByte,
Upt: parseTime(resp.UTime),
UpPt: parseTime(resp.PTime),
}
if !obj.IsDir() && obj.ModTime().Unix() <= 0 {
return d.getFromParent(ctx, path, obj.GetID())
}
return obj, nil
}
func (d *Open115) getFromParent(ctx context.Context, path, id string) (model.Obj, error) {
path = stdpath.Clean(path)
parent, name := stdpath.Split(path)
parent = stdpath.Clean(parent)
parentID := d.GetRootId()
if stdpath.Clean(parent) != stdpath.Clean(d.parentPath) {
if err := d.WaitLimit(ctx); err != nil {
return nil, err
}
parentInfo, err := d.client.GetFolderInfoByPath(ctx, parent)
if err != nil {
if !errors.Is(err, sdk.ErrObjectNotFound) {
return nil, err
}
parentObj, err := d.getFromParent(ctx, parent, "")
if err != nil {
return nil, err
}
parentID = parentObj.GetID()
} else {
parentID = parentInfo.FileID
}
}
files, err := d.List(ctx, &Obj{Fid: parentID, Fc: "0"}, model.ListArgs{})
if err != nil {
return nil, err
}
for _, file := range files {
if (id != "" && file.GetID() == id) || (id == "" && file.GetName() == name) {
return file, nil
}
}
return nil, errs.ObjectNotFound
}, nil
}
func (d *Open115) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+16 -61
View File
@@ -3,7 +3,6 @@ package _115_open
import (
"context"
"encoding/base64"
"errors"
"io"
"time"
@@ -71,19 +70,6 @@ func (d *Open115) singleUpload(ctx context.Context, tempF model.File, tokenResp
// } `json:"data"`
// }
// retryExpiredToken retries only the rejected OSS operation, preserving the upload ID.
func retryExpiredToken(refresh func() error, operation func() error) error {
err := operation()
var serviceErr oss.ServiceError
if !errors.As(err, &serviceErr) || serviceErr.Code != "SecurityTokenExpired" {
return err
}
if err := refresh(); err != nil {
return err
}
return operation()
}
func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer, up driver.UpdateProgress, tokenResp *sdk.UploadGetTokenResp, initResp *sdk.UploadInitResp) error {
ossClient, err := netutil.NewOSSClient(tokenResp.Endpoint, tokenResp.AccessKeyId, tokenResp.AccessKeySecret, oss.SecurityToken(tokenResp.SecurityToken))
if err != nil {
@@ -94,32 +80,7 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
return err
}
refresh := func() error {
if err := d.WaitLimit(ctx); err != nil {
return err
}
token, err := d.client.UploadGetToken(ctx)
if err != nil {
return err
}
client, err := netutil.NewOSSClient(token.Endpoint, token.AccessKeyId, token.AccessKeySecret, oss.SecurityToken(token.SecurityToken))
if err != nil {
return err
}
newBucket, err := client.Bucket(initResp.Bucket)
if err != nil {
return err
}
bucket = newBucket
return nil
}
var imur oss.InitiateMultipartUploadResult
err = retryExpiredToken(refresh, func() error {
var err error
imur, err = bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential(), oss.WithContext(ctx))
return err
})
imur, err := bucket.InitiateMultipartUpload(initResp.Object, oss.Sequential())
if err != nil {
return err
}
@@ -148,17 +109,13 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
return err
}
err = retry.Do(func() error {
return retryExpiredToken(refresh, func() error {
if _, err := rd.Seek(0, io.SeekStart); err != nil {
return err
}
part, err := bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), partSize, int(i), oss.WithContext(ctx))
if err != nil {
return err
}
parts[i-1] = part
return nil
})
rd.Seek(0, io.SeekStart)
part, err := bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), partSize, int(i))
if err != nil {
return err
}
parts[i-1] = part
return nil
},
retry.Context(ctx),
retry.Attempts(3),
@@ -177,16 +134,14 @@ func (d *Open115) multpartUpload(ctx context.Context, stream model.FileStreamer,
up(float64(offset) * 100 / float64(fileSize))
}
err = retryExpiredToken(refresh, func() error {
_, err := bucket.CompleteMultipartUpload(
imur,
parts,
oss.Callback(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.Callback))),
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.CallbackVar))),
oss.WithContext(ctx),
)
return err
})
// callbackRespBytes := make([]byte, 1024)
_, err = bucket.CompleteMultipartUpload(
imur,
parts,
oss.Callback(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.Callback))),
oss.CallbackVar(base64.StdEncoding.EncodeToString([]byte(initResp.Callback.Value.CallbackVar))),
// oss.CallbackResult(&callbackRespBytes),
)
if err != nil {
return err
}
+1 -9
View File
@@ -1,9 +1,6 @@
package _115_open
import (
"encoding/json"
"strconv"
)
import "encoding/json"
func ParseInt64(v json.Number) (int64, error) {
i, err := v.Int64()
@@ -16,8 +13,3 @@ func ParseInt64(v json.Number) (int64, error) {
}
return int64(0), err
}
func parseTime(value string) int64 {
timestamp, _ := strconv.ParseInt(value, 10, 64)
return timestamp
}
+14 -21
View File
@@ -18,10 +18,10 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
)
@@ -212,29 +212,22 @@ func (d *Pan123) Put(ctx context.Context, dstDir model.Obj, file model.FileStrea
err = d.newUpload(ctx, &resp, file, up)
return err
} else {
cfg := &aws.Config{
Credentials: credentials.NewStaticCredentials(resp.Data.AccessKeyId, resp.Data.SecretAccessKey, resp.Data.SessionToken),
Region: aws.String("123pan"),
Endpoint: aws.String(resp.Data.EndPoint),
S3ForcePathStyle: aws.Bool(true),
}
s, err := session.NewSession(cfg)
if err != nil {
return err
}
uploader := s3manager.NewUploader(s)
if file.GetSize() > s3manager.MaxUploadParts*s3manager.DefaultUploadPartSize {
uploader.PartSize = file.GetSize() / (s3manager.MaxUploadParts - 1)
}
input := &s3manager.UploadInput{
s3Client := s3.NewFromConfig(aws.Config{
Credentials: credentials.NewStaticCredentialsProvider(resp.Data.AccessKeyId, resp.Data.SecretAccessKey, resp.Data.SessionToken),
Region: "123pan",
}, func(o *s3.Options) {
o.BaseEndpoint = aws.String(resp.Data.EndPoint)
o.UsePathStyle = true
})
tmClient := transfermanager.New(s3Client)
_, err = tmClient.UploadObject(ctx, &transfermanager.UploadObjectInput{
Bucket: &resp.Data.Bucket,
Key: &resp.Data.Key,
Body: driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
Reader: file,
UpdateProgress: up,
}),
}
_, err = uploader.UploadWithContext(ctx, input)
})
if err != nil {
return err
}
+108 -251
View File
@@ -19,7 +19,6 @@ import (
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
"github.com/avast/retry-go"
log "github.com/sirupsen/logrus"
)
@@ -30,9 +29,7 @@ type Yun139 struct {
Account string
ref *Yun139
PersonalCloudHost string
FamilyCloudHost string
GroupCloudHost string
ProviderRoot string
RootPath string
}
func (d *Yun139) Config() driver.Config {
@@ -45,62 +42,53 @@ func (d *Yun139) GetAddition() driver.Additional {
func (d *Yun139) Init(ctx context.Context) error {
if d.ref == nil {
// Share links may be browsed without account credentials. All other
// storage types need an authorization or a supported login fallback.
if !d.isShare() {
if err := d.validateAndInitCredentials(); err != nil {
return err
if len(d.Authorization) == 0 {
if d.Username != "" && d.Password != "" {
log.Infof("139yun: authorization is empty, trying to login with password.")
newAuth, err := d.loginWithPassword()
log.Debugf("newAuth: Ok: %s", newAuth)
if err != nil {
return fmt.Errorf("login with password failed: %w", err)
}
} else {
return fmt.Errorf("authorization is empty and username/password is not provided")
}
}
if d.Authorization != "" {
err := d.refreshToken()
if err != nil {
return err
}
// Query Route Policy
var resp QueryRoutePolicyResp
_, err = d.requestRoute(base.Json{
"userInfo": base.Json{
"userType": 1,
"accountType": 1,
"accountName": d.Account,
},
"modAddrType": 1,
}, &resp)
if err != nil {
return err
}
for _, policyItem := range resp.Data.RoutePolicyList {
if policyItem.ModName == "personal" {
d.PersonalCloudHost = policyItem.HttpsUrl
break
}
}
if len(d.PersonalCloudHost) == 0 {
return fmt.Errorf("PersonalCloudHost is empty")
}
d.cron = cron.NewCron(time.Hour * 12)
d.cron.Do(func() {
err := d.refreshToken()
if err != nil {
return err
log.Errorf("%+v", err)
}
// Query Route Policy
var resp QueryRoutePolicyResp
_, err = d.requestRoute(base.Json{
"userInfo": base.Json{
"userType": 1,
"accountType": 1,
"accountName": d.Account,
},
"modAddrType": 1,
}, &resp)
if err != nil {
return err
}
for _, policyItem := range resp.Data.RoutePolicyList {
switch policyItem.ModName {
case "personal":
d.PersonalCloudHost = policyItem.HttpsUrl
case "group":
d.GroupCloudHost = policyItem.HttpsUrl
case "family":
d.FamilyCloudHost = policyItem.HttpsUrl
}
}
if len(d.PersonalCloudHost) == 0 {
return fmt.Errorf("PersonalCloudHost is empty")
}
if d.isGroup() || d.isFamily() {
if len(d.GroupCloudHost) == 0 {
return fmt.Errorf("GroupCloudHost is empty")
}
if len(d.FamilyCloudHost) == 0 {
return fmt.Errorf("FamilyCloudHost is empty")
}
}
d.cron = cron.NewCron(time.Hour * 12)
d.cron.Do(func() {
err := d.refreshToken()
if err != nil {
log.Errorf("%+v", err)
}
})
}
})
}
switch d.Addition.Type {
case MetaPersonalNew:
@@ -119,25 +107,15 @@ func (d *Yun139) Init(ctx context.Context) error {
if err != nil {
return err
}
case MetaShare:
if len(d.Addition.RootFolderID) == 0 {
d.RootFolderID = "root"
}
if len(d.shareEntries()) == 0 {
return fmt.Errorf("link_id is empty")
}
case MetaFamily:
// Attempt to obtain data.path as the root via a query and persist it.
root, err := d.getFamilyRootPath(d.CloudID)
if err != nil || root == "" {
return fmt.Errorf("failed to get family root path: %w", err)
}
d.ProviderRoot = root
if len(d.Addition.RootFolderID) == 0 {
d.RootFolderID = root
op.MustSaveDriverStorage(d)
// Attempt to obtain data.path as the root via a query and persist it.
if root, err := d.getFamilyRootPath(d.CloudID); err == nil && root != "" {
d.RootFolderID = root
op.MustSaveDriverStorage(d)
}
}
_, err = d.familyGetFiles(d.RootFolderID)
_, err := d.familyGetFiles(d.RootFolderID)
if err != nil {
return err
}
@@ -156,19 +134,6 @@ func (d *Yun139) InitReference(storage driver.Driver) error {
return errs.NotSupport
}
func (d *Yun139) Get(ctx context.Context, path string) (model.Obj, error) {
if !d.isShare() {
return nil, errs.NotImplement
}
if path == "/" {
return &model.Object{ID: "root", Name: "root", IsFolder: true, Path: "/"}, nil
}
if obj, err := d.shareGetObj(path); err == nil {
return obj, nil
}
return nil, errs.ObjectNotFound
}
func (d *Yun139) Drop(ctx context.Context) error {
if d.cron != nil {
d.cron.Stop()
@@ -187,23 +152,12 @@ func (d *Yun139) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
return d.familyGetFiles(dir.GetID())
case MetaGroup:
return d.groupGetFiles(dir.GetID())
case MetaShare:
if dir.GetID() == "root" {
return d.shareGetMergedFiles(d.shareRootEntries())
}
if refs, ok := decodeShareRefs(dir.GetID()); ok {
return d.shareGetMergedFiles(refs)
}
return d.shareGetFilesWithRef(shareRef{LinkID: d.LinkID, NodeID: dir.GetID()}, dir.GetID())
default:
return nil, errs.NotImplement
}
}
func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, errs.NotFile
}
var url string
var err error
switch d.Addition.Type {
@@ -215,16 +169,6 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
url, err = d.familyGetLink(file.GetID(), file.GetPath())
case MetaGroup:
url, err = d.groupGetLink(file.GetID(), file.GetPath())
case MetaShare:
if refs, ok := decodeShareRefs(file.GetID()); ok && len(refs) > 0 {
return d.shareGetLinkWithRef(refs[0], refs[0].NodeID, args.Type)
}
fallbackRef := shareRef{LinkID: d.LinkID, NodeID: "root"}
if entries := d.shareEntries(); len(entries) > 0 {
fallbackRef.LinkID = entries[0].LinkID
fallbackRef.Password = entries[0].Password
}
return d.shareGetLinkWithRef(fallbackRef, file.GetID(), args.Type)
default:
return nil, errs.NotImplement
}
@@ -235,9 +179,6 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
}
func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
if d.isShare() {
return errs.NotImplement
}
var err error
switch d.Addition.Type {
case MetaPersonalNew:
@@ -271,7 +212,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
"accountType": 1,
},
"docLibName": dirName,
"path": d.dirPath(parentDir),
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
}
pathname := "/orchestration/familyCloud-rebuild/cloudCatalog/v1.0/createCloudDoc"
_, err = d.post(pathname, data, nil)
@@ -284,7 +225,7 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
},
"groupID": d.CloudID,
"parentFileId": parentDir.GetID(),
"path": d.dirPath(parentDir),
"path": path.Join(parentDir.GetPath(), parentDir.GetID()),
}
pathname := "/orchestration/group-rebuild/catalog/v1.0/createGroupCatalog"
_, err = d.post(pathname, data, nil)
@@ -295,9 +236,6 @@ func (d *Yun139) MakeDir(ctx context.Context, parentDir model.Obj, dirName strin
}
func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
if d.isShare() {
return nil, errs.NotImplement
}
switch d.Addition.Type {
case MetaPersonalNew:
data := base.Json{
@@ -372,9 +310,9 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
var contentList []string
var catalogList []string
if srcObj.IsDir() {
catalogList = append(catalogList, d.dirPath(srcObj))
catalogList = append(catalogList, path.Join(srcObj.GetPath(), srcObj.GetID()))
} else {
contentList = append(contentList, d.dirPath(srcObj))
contentList = append(contentList, path.Join(srcObj.GetPath(), srcObj.GetID()))
}
body := base.Json{
@@ -386,7 +324,7 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
"contentList": contentList,
"destCatalogID": dstDir.GetID(),
"destGroupID": d.CloudID,
"destPath": d.dirPath(dstDir),
"destPath": path.Join(dstDir.GetPath(), dstDir.GetID()),
"destType": 0,
"srcGroupID": d.CloudID,
"srcType": 0,
@@ -409,9 +347,6 @@ func (d *Yun139) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
}
func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
if d.isShare() {
return errs.NotImplement
}
var err error
switch d.Addition.Type {
case MetaPersonalNew:
@@ -490,7 +425,7 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
},
"docLibName": newName,
"docLibraryID": srcObj.GetID(),
"path": d.dirPath(srcObj),
"path": path.Join(srcObj.GetPath(), srcObj.GetID()),
}
var resp ModifyCloudDocV2Resp
_, err = d.andAlbumRequest(pathname, data, &resp)
@@ -521,9 +456,6 @@ func (d *Yun139) Rename(ctx context.Context, srcObj model.Obj, newName string) e
}
func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
if d.isShare() {
return errs.NotImplement
}
var err error
switch d.Addition.Type {
case MetaPersonalNew:
@@ -593,9 +525,6 @@ func (d *Yun139) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
}
func (d *Yun139) Remove(ctx context.Context, obj model.Obj) error {
if d.isShare() {
return errs.NotImplement
}
switch d.Addition.Type {
case MetaPersonalNew:
data := base.Json{
@@ -686,25 +615,8 @@ func (d *Yun139) getPartSize(size int64) int64 {
}
func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
if d.isShare() {
return errs.NotImplement
}
// PersonalNew 以及 Group/Family 在非旧流模式时走新上传路径
if d.Addition.Type == MetaPersonalNew ||
((d.isGroup() || d.isFamily()) && !d.UseOldStreamUpload) {
var createPath, getUploadUrlPath, completePath string
if d.isGroup() || d.isFamily() {
// 家庭云和共享群共用同一套新上传 API
createPath = "/dynamic/file/create"
getUploadUrlPath = "/dynamic/file/getUploadUrl"
completePath = "/dynamic/file/complete"
} else {
// MetaPersonalNew
createPath = "/file/create"
getUploadUrlPath = "/file/getUploadUrl"
completePath = "/file/complete"
}
switch d.Addition.Type {
case MetaPersonalNew:
var err error
fullHash := stream.GetHash().GetHash(utils.SHA256)
if len(fullHash) != utils.SHA256.Width {
@@ -759,22 +671,9 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
"type": "file",
"fileRenameMode": "auto_rename",
}
// 家庭云和共享群需要额外的参数
if d.isGroup() || d.isFamily() {
if d.CloudID == "" {
return fmt.Errorf("cloud_id is required for group/family upload")
}
data["groupId"] = d.CloudID
if d.isGroup() {
data["groupType"] = 2
} else if d.isFamily() {
data["groupType"] = 1
}
data["catalogType"] = 3
data["seqNo"] = random.String(32)
}
pathname := "/file/create"
var resp PersonalUploadResp
_, err = d.newPost(createPath, data, &resp)
_, err = d.personalPost(pathname, data, &resp)
if err != nil {
return err
}
@@ -791,19 +690,10 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
if resp.Data.PartInfos != nil {
// Progress
p := driver.NewProgress(size, up)
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
Ctx: ctx,
Reader: rateLimited,
Obj: &model.Object{Size: size},
}, int(partSize), &up)
if err != nil {
return err
}
// 先上传前100个分片
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, ss, p)
err = d.uploadPersonalParts(ctx, partInfos, resp.Data.PartInfos, rateLimited, p)
if err != nil {
return err
}
@@ -821,12 +711,13 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
"accountType": 1,
},
}
pathname := "/file/getUploadUrl"
var moreresp PersonalUploadUrlResp
_, err = d.newPost(getUploadUrlPath, moredata, &moreresp)
_, err = d.personalPost(pathname, moredata, &moreresp)
if err != nil {
return err
}
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, ss, p)
err = d.uploadPersonalParts(ctx, partInfos, moreresp.Data.PartInfos, rateLimited, p)
if err != nil {
return err
}
@@ -839,11 +730,7 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
"fileId": resp.Data.FileId,
"uploadId": resp.Data.UploadId,
}
// 家庭云和共享群需要额外的参数
if d.isGroup() || d.isFamily() {
data["groupId"] = d.CloudID
}
_, err = d.newPost(completePath, data, nil)
_, err = d.personalPost("/file/complete", data, nil)
if err != nil {
return err
}
@@ -888,11 +775,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
}
}
return nil
}
// 旧上传路径
switch d.Addition.Type {
case MetaPersonal, MetaGroup, MetaFamily:
case MetaPersonal:
fallthrough
case MetaGroup:
fallthrough
case MetaFamily:
// 处理冲突
// 获取文件列表
files, err := d.List(ctx, dstDir, model.ListArgs{})
@@ -939,11 +826,11 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
},
}
pathname := "/orchestration/personalCloud/uploadAndDownload/v1.0/pcUploadFileRequest"
if d.isFamily() || d.isGroup() {
uploadPath := d.dirPath(dstDir)
// 共享群的根目录上传路径为 0
if d.isGroup() && dstDir.GetID() == d.RootFolderID {
uploadPath = "0"
if d.isFamily() || d.Addition.Type == MetaGroup {
uploadPath := path.Join(dstDir.GetPath(), dstDir.GetID())
// if dstDir is root folder
if dstDir.GetID() == d.RootFolderID {
uploadPath = d.RootPath
}
data = d.newJson(base.Json{
"fileCount": 1,
@@ -971,86 +858,56 @@ func (d *Yun139) Put(ctx context.Context, dstDir model.Obj, stream model.FileStr
}
size := stream.GetSize()
partSize := d.getPartSize(size)
// Progress
p := driver.NewProgress(size, up)
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
// StreamSectionReader for per-chunk buffering and retry
ss, err := streamPkg.NewStreamSectionReader(&streamPkg.FileStream{
Ctx: ctx,
Reader: rateLimited,
Obj: &model.Object{Size: size},
}, int(partSize), &up)
if err != nil {
return err
}
partSize := d.getPartSize(size)
part := int64(1)
if size > partSize {
part = (size + partSize - 1) / partSize
}
rateLimited := driver.NewLimitedUploadStream(ctx, stream)
for i := int64(0); i < part; i++ {
if utils.IsCanceled(ctx) {
return ctx.Err()
}
start := i * partSize
byteSize := min(size-start, partSize)
rd, getErr := ss.GetSectionReader(start, byteSize)
if getErr != nil {
return getErr
}
err = retry.Do(
func() error {
if _, err := rd.Seek(0, io.SeekStart); err != nil {
return err
}
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL,
io.TeeReader(rd, p))
if reqErr != nil {
return reqErr
}
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
req.Header.Set("rangeType", "0")
req.ContentLength = byteSize
res, doErr := base.HttpClient.Do(req)
if doErr != nil {
return doErr
}
defer res.Body.Close()
bodyBytes, readErr := io.ReadAll(res.Body)
if readErr != nil {
return fmt.Errorf("error reading response body: %v", readErr)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d, body: %s", res.StatusCode, string(bodyBytes))
}
var result InterLayerUploadResult
xmlErr := xml.Unmarshal(bodyBytes, &result)
if xmlErr != nil {
return fmt.Errorf("error parsing XML: %v", xmlErr)
}
if result.ResultCode != 0 {
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
}
return nil
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second),
)
ss.FreeSectionReader(rd)
limitReader := io.LimitReader(rateLimited, byteSize)
// Update Progress
r := io.TeeReader(limitReader, p)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resp.Data.UploadResult.RedirectionURL, r)
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain;name="+unicode(stream.GetName()))
req.Header.Set("contentSize", strconv.FormatInt(size, 10))
req.Header.Set("range", fmt.Sprintf("bytes=%d-%d", start, start+byteSize-1))
req.Header.Set("uploadtaskID", resp.Data.UploadResult.UploadTaskID)
req.Header.Set("rangeType", "0")
req.ContentLength = byteSize
res, err := base.HttpClient.Do(req)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
res.Body.Close()
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
}
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
var result InterLayerUploadResult
err = xml.Unmarshal(bodyBytes, &result)
if err != nil {
return fmt.Errorf("error parsing XML: %v", err)
}
if result.ResultCode != 0 {
return fmt.Errorf("upload failed with result code: %d, message: %s", result.ResultCode, result.Msg)
}
}
return nil
default:
+5 -8
View File
@@ -7,20 +7,17 @@ import (
type Addition struct {
//Account string `json:"account" required:"true"`
Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login."`
Username string `json:"username" help:"Use together with password when Authorization is empty. mail_cookies may be left empty on the first login."`
Password string `json:"password" secret:"true" help:"Use together with username when Authorization is empty. mail_cookies may be left empty on the first login."`
SmsCode string `json:"sms_code" secret:"true" help:"Fill this only after OpenList reports that a 139 Mail SMS verification code was sent, then save the storage again."`
MailCookies string `json:"mail_cookies" type:"text" help:"Optional cookies from mail.10086.cn. Leave empty for a first username/password login; cookies created or updated by password/SMS login are persisted and reused as device context. Existing cookies may also be used alone for fast login."`
Authorization string `json:"authorization" type:"text" required:"true"`
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true" secret:"true"`
MailCookies string `json:"mail_cookies" required:"true" type:"text" help:"Cookies from mail.139.com used for login authentication."`
driver.RootID
Type string `json:"type" type:"select" options:"personal_new,family,group,personal,share" default:"personal_new"`
LinkID string `json:"link_id" type:"text" help:"Multiple shares are separated by commas or new lines. Use link_id#password for password-protected shares."`
Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"`
CloudID string `json:"cloud_id"`
UserDomainID string `json:"user_domain_id" help:"ud_id in Cookie, fill in to show disk usage"`
CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0" help:"0 for auto"`
ReportRealSize bool `json:"report_real_size" type:"bool" default:"true" help:"Enable to report the real file size during upload"`
UseLargeThumbnail bool `json:"use_large_thumbnail" type:"bool" default:"false" help:"Enable to use large thumbnail for images"`
UseOldStreamUpload bool `json:"use_old_stream_upload" type:"bool" default:"false" help:"Enable to use old stream upload method (not support rapid upload)"`
}
var config = driver.Config{
-35
View File
@@ -1,35 +0,0 @@
package _139
import (
"testing"
)
func TestShareEntriesAndRefEncoding(t *testing.T) {
d := &Yun139{Addition: Addition{
Type: MetaShare,
LinkID: "share-a,share-b,share-c#pass",
}}
entries := d.shareEntries()
if len(entries) != 3 {
t.Fatalf("expected 3 share entries, got %d", len(entries))
}
if entries[2].LinkID != "share-c" || entries[2].Password != "pass" {
t.Fatalf("unexpected password share entry: %+v", entries[2])
}
refs := []shareRef{
{LinkID: entries[0].LinkID, Password: entries[0].Password, NodeID: "root-a"},
{LinkID: entries[2].LinkID, Password: entries[2].Password, NodeID: "root-b"},
}
encoded := encodeShareRefs(refs)
decoded, ok := decodeShareRefs(encoded)
if !ok || len(decoded) != len(refs) {
t.Fatalf("failed to decode merged share refs: %q", encoded)
}
for i := range refs {
if decoded[i] != refs[i] {
t.Fatalf("unexpected decoded ref at %d: %+v", i, decoded[i])
}
}
}
-51
View File
@@ -9,7 +9,6 @@ const (
MetaFamily string = "family"
MetaGroup string = "group"
MetaPersonalNew string = "personal_new"
MetaShare string = "share"
)
type BaseResp struct {
@@ -286,56 +285,6 @@ type PersonalUploadUrlResp struct {
}
}
type ShareCatalog struct {
CaID string `json:"caId"`
CaName string `json:"caName"`
UdTime string `json:"udTime"`
}
type ShareContent struct {
CoID string `json:"coId"`
CoName string `json:"coName"`
CoSize int64 `json:"coSize"`
CoType int `json:"coType"`
UdTime string `json:"udTime"`
CoPath string `json:"coPath"`
PresentURL string `json:"presentURL"`
DownloadURL string `json:"downloadURL"`
}
type ShareListResp struct {
BaseResp
Data struct {
LKName string `json:"lkName"`
Passwd string `json:"password"`
CaLst []ShareCatalog `json:"caLst"`
CoLst []ShareContent `json:"coLst"`
} `json:"data"`
}
type ShareContentInfo struct {
PresentURL string `json:"presentURL"`
DownloadURL string `json:"cdnDownLoadUrl"`
}
type ShareDownloadResp struct {
BaseResp
Data struct {
DownloadURL string `json:"downloadURL"`
RedrURL string `json:"redrUrl"`
ExtInfo struct {
CDNDownloadURL string `json:"cdnDownloadUrl"`
} `json:"extInfo"`
} `json:"data"`
}
type ShareContentInfoResp struct {
BaseResp
Data struct {
ContentInfo ShareContentInfo `json:"contentInfo"`
} `json:"data"`
}
type QueryRoutePolicyResp struct {
Success bool `json:"success"`
Code string `json:"code"`
+114 -826
View File
File diff suppressed because it is too large Load Diff
-474
View File
@@ -1,474 +0,0 @@
package _139
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/go-resty/resty/v2"
)
func useRetryingTestClient(t *testing.T) {
t.Helper()
oldClient := base.RestyClient
base.RestyClient = resty.New().SetRetryCount(3)
t.Cleanup(func() {
base.RestyClient = oldClient
})
}
func TestMergeMailCookiesPreservesExistingOrderAndAppendsNewNames(t *testing.T) {
got := mergeMailCookieHeader("z=zv; behaviorid=b; Os_SSo_Sid=old", []*http.Cookie{
{Name: "RMKEY", Value: "rm"},
{Name: "Os_SSo_Sid", Value: "sid"},
{Name: "a", Value: "av"},
})
want := "z=zv;behaviorid=b;Os_SSo_Sid=sid;RMKEY=rm;a=av"
if got != want {
t.Fatalf("mergeMailCookieHeader() = %q, want %q", got, want)
}
}
func TestExtractFastLoginCookies(t *testing.T) {
sid, rmkey := extractFastLoginCookies("RMKEY=rm; Os_SSo_Sid=sid")
if sid != "sid" || rmkey != "rm" {
t.Fatalf("extractFastLoginCookies() = %q, %q; want sid, rm", sid, rmkey)
}
}
func TestCredentialState(t *testing.T) {
tests := []struct {
name string
d Yun139
want credentialState
err bool
}{
{
name: "authorization",
d: Yun139{Addition: Addition{Authorization: " auth "}},
want: credentialStateAuthorization,
},
{
name: "full login",
d: Yun139{Addition: Addition{
MailCookies: "RMKEY=rm; Os_SSo_Sid=sid",
Username: "user",
Password: "password",
}},
want: credentialStateFullLogin,
},
{
name: "full login without initial cookies",
d: Yun139{Addition: Addition{
Username: "user",
Password: "password",
}},
want: credentialStateFullLogin,
},
{
name: "cookies only",
d: Yun139{Addition: Addition{MailCookies: "RMKEY=rm; Os_SSo_Sid=sid"}},
want: credentialStateCookiesOnly,
},
{
name: "partial password login",
d: Yun139{Addition: Addition{Username: "user"}},
err: true,
},
{
name: "missing credentials",
d: Yun139{},
err: true,
},
{
name: "invalid cookie",
d: Yun139{Addition: Addition{MailCookies: "invalid-cookie"}},
err: true,
},
{
name: "authorization with basic prefix",
d: Yun139{Addition: Addition{Authorization: "Basic abc"}},
err: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.d.credentialState()
if tt.err {
if err == nil {
t.Fatal("credentialState() expected error")
}
return
}
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("credentialState() = %v, want %v", got, tt.want)
}
})
}
}
func TestIntegrationLoginObtainsAuthorization(t *testing.T) {
if os.Getenv("OPENLIST_139_INTEGRATION") != "1" {
t.Skip("set OPENLIST_139_INTEGRATION=1 to run live 139Yun login checks")
}
base.RestyClient = resty.New().
SetHeader("user-agent", base.UserAgent).
SetRetryCount(3).
SetRetryResetReaders(true).
SetTimeout(30 * time.Second)
username := os.Getenv("OPENLIST_139_USERNAME")
password := os.Getenv("OPENLIST_139_PASSWORD")
mailCookies := os.Getenv("OPENLIST_139_MAIL_COOKIES")
authorization := strings.TrimSpace(os.Getenv("OPENLIST_139_AUTHORIZATION"))
if authorization != "" {
t.Run("authorization", func(t *testing.T) {
d := Yun139{Addition: Addition{Authorization: authorization}}
state, err := d.credentialState()
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if state != credentialStateAuthorization {
t.Fatalf("credentialState() = %v, want authorization", state)
}
if d.Authorization == "" || strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
t.Fatal("authorization should be present without Basic prefix")
}
})
}
if mailCookies == "" {
t.Fatal("OPENLIST_139_MAIL_COOKIES is required")
}
runFastLogin := func(mailCookies string) error {
d := Yun139{Addition: Addition{MailCookies: mailCookies}}
state, err := d.credentialState()
if err != nil {
return fmt.Errorf("credentialState() error: %w", err)
}
if state != credentialStateCookiesOnly {
return fmt.Errorf("credentialState() = %v, want cookies only", state)
}
sid, rmkey := extractFastLoginCookies(d.MailCookies)
if sid == "" || rmkey == "" {
return fmt.Errorf("mail cookies are missing Os_SSo_Sid or RMKEY")
}
token, err := d.step2_get_single_token(sid)
if err != nil {
return fmt.Errorf("step2_get_single_token() error: %w", err)
}
auth, err := d.step3_third_party_login(token)
if err != nil {
return fmt.Errorf("step3_third_party_login() error: %w", err)
}
if auth == "" {
return fmt.Errorf("authorization is empty after fast login")
}
return nil
}
if username == "" || password == "" {
t.Fatal("OPENLIST_139_USERNAME and OPENLIST_139_PASSWORD are required for password fallback")
}
var refreshedMailCookies string
var generatedAuthorization string
t.Run("password login fallback", func(t *testing.T) {
d := Yun139{Addition: Addition{
MailCookies: mailCookies,
Username: username,
Password: password,
}}
state, err := d.credentialState()
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if state != credentialStateFullLogin {
t.Fatalf("credentialState() = %v, want full login", state)
}
passId, err := d.step1_password_login()
if err != nil {
t.Fatalf("step1_password_login() error: %v", err)
}
token, err := d.step2_get_single_token(passId)
if err != nil {
t.Fatalf("step2_get_single_token() error: %v", err)
}
auth, err := d.step3_third_party_login(token)
if err != nil {
t.Fatalf("step3_third_party_login() error: %v", err)
}
d.Authorization = auth
if auth == "" || d.Authorization == "" {
t.Fatal("authorization is empty after password login")
}
generatedAuthorization = auth
refreshedMailCookies = d.MailCookies
})
t.Run("authorization generated by password login", func(t *testing.T) {
if generatedAuthorization == "" {
t.Fatal("password login did not generate authorization")
}
d := Yun139{Addition: Addition{Authorization: generatedAuthorization}}
state, err := d.credentialState()
if err != nil {
t.Fatalf("credentialState() unexpected error: %v", err)
}
if state != credentialStateAuthorization {
t.Fatalf("credentialState() = %v, want authorization", state)
}
if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") {
t.Fatal("authorization should not include Basic prefix")
}
})
t.Run("mail cookies fast login from input", func(t *testing.T) {
sid, rmkey := extractFastLoginCookies(mailCookies)
if sid == "" || rmkey == "" {
t.Skip("input mail cookies are missing Os_SSo_Sid or RMKEY")
}
if err := runFastLogin(mailCookies); err != nil {
t.Skipf("input mail cookies are stale or unusable: %v", err)
}
})
t.Run("mail cookies fast login after password login", func(t *testing.T) {
if refreshedMailCookies == "" {
t.Fatal("password login did not refresh mail cookies")
}
if err := runFastLogin(refreshedMailCookies); err != nil {
t.Fatalf("refreshed mail cookies fast login failed: %v", err)
}
})
}
func TestInvalidAuthorizationDoesNotUseCookieFastLogin(t *testing.T) {
d := Yun139{Addition: Addition{
Authorization: "not-base64",
MailCookies: "Os_SSo_Sid=sid; RMKEY=rmkey",
}}
err := d.refreshToken()
if err == nil || !strings.Contains(err.Error(), "password login failed") {
t.Fatalf("refreshToken() error = %v, want password login fallback error", err)
}
if d.Authorization != "not-base64" {
t.Fatalf("Authorization = %q, want original invalid value retained", d.Authorization)
}
}
func TestSMSSceneForRisk(t *testing.T) {
tests := map[string]int{
"S025": 1,
"S035": 1,
"PML401010062": 2,
"MW0016": 4,
}
for riskCode, want := range tests {
got, ok := smsSceneForRisk(riskCode)
if !ok || got != want {
t.Fatalf("smsSceneForRisk(%q) = %d, %t; want %d, true", riskCode, got, ok, want)
}
}
if _, ok := smsSceneForRisk("PICTURE_ONLY"); ok {
t.Fatal("smsSceneForRisk() accepted unsupported risk code")
}
}
func TestSendSMSVerificationCode(t *testing.T) {
useRetryingTestClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if r.URL.Query().Get("func") != "login:sendSmsCodeByScene" {
t.Errorf("func = %q", r.URL.Query().Get("func"))
}
if !strings.Contains(string(body), `<string name="scene">1</string>`) {
t.Errorf("request body = %s", body)
}
if !strings.Contains(r.Header.Get("Cookie"), "device=fingerprint") {
t.Errorf("Cookie = %q", r.Header.Get("Cookie"))
}
http.SetCookie(w, &http.Cookie{Name: "challenge", Value: "sms"})
_, _ = io.WriteString(w, `{"code":"S_OK"}`)
}))
defer server.Close()
oldURL := mailSMSURL
mailSMSURL = server.URL
defer func() { mailSMSURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000", MailCookies: "device=fingerprint"}}
if err := d.sendSMSVerificationCode("S025"); err != nil {
t.Fatalf("sendSMSVerificationCode() error = %v", err)
}
if !strings.Contains(d.MailCookies, "challenge=sms") {
t.Fatalf("MailCookies = %q", d.MailCookies)
}
}
func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) {
useRetryingTestClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"code":"PML401010021"}`)
}))
defer server.Close()
oldURL := mailSMSURL
mailSMSURL = server.URL
defer func() { mailSMSURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000"}}
err := d.sendSMSVerificationCode("S025")
if err == nil || !strings.Contains(err.Error(), "requires picture verification") {
t.Fatalf("sendSMSVerificationCode() error = %v", err)
}
}
func TestVerifySMSCode(t *testing.T) {
useRetryingTestClient(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body: %v", err)
}
if r.URL.Query().Get("func") != "/login/inlogin.action" {
t.Errorf("func = %q", r.URL.Query().Get("func"))
}
wantHash := sha1Hash("fetion.com.cn:123456")
if !strings.Contains(string(body), `<string name="loginPassword">`+wantHash+`</string>`) {
t.Errorf("request body does not contain SMS code hash: %s", body)
}
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rmkey"})
_, _ = io.WriteString(w, `{"code":"S_OK","var":{"loginSuccessUrl":"https://mail.10086.cn/?sid=sms-sid"}}`)
}))
defer server.Close()
oldURL := mailSMSURL
mailSMSURL = server.URL
defer func() { mailSMSURL = oldURL }()
d := Yun139{Addition: Addition{
Username: "18800000000",
SmsCode: "123456",
MailCookies: "challenge=sms",
}}
sid, err := d.verifySMSCode("S025")
if err != nil {
t.Fatalf("verifySMSCode() error = %v", err)
}
if sid != "sms-sid" {
t.Fatalf("sid = %q", sid)
}
if d.SmsCode != "" {
t.Fatalf("SmsCode = %q, want cleared", d.SmsCode)
}
if !strings.Contains(d.MailCookies, "RMKEY=new-rmkey") {
t.Fatalf("MailCookies = %q", d.MailCookies)
}
}
func TestPasswordLoginWithoutInitialCookiesStopsAtRedirectAndPersistsCookies(t *testing.T) {
useRetryingTestClient(t)
var loginRequests, redirectRequests int
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Login/Login.ashx":
loginRequests++
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rm"})
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "new-sid"})
w.Header().Set("Location", server.URL+"/appmail?sid=single-sid")
w.WriteHeader(http.StatusFound)
case "/appmail":
redirectRequests++
w.WriteHeader(http.StatusOK)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldURL := mailPasswordURL
mailPasswordURL = server.URL + "/Login/Login.ashx"
defer func() { mailPasswordURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password"}}
sid, err := d.step1_password_login()
if err != nil || sid != "single-sid" {
t.Fatalf("sid=%q err=%v", sid, err)
}
if loginRequests != 1 || redirectRequests != 0 {
t.Fatalf("login/redirect requests=%d/%d, want 1/0", loginRequests, redirectRequests)
}
if !strings.Contains(d.MailCookies, "RMKEY=new-rm") || !strings.Contains(d.MailCookies, "Os_SSo_Sid=new-sid") {
t.Fatalf("response cookies were not persisted: %q", d.MailCookies)
}
}
func TestPasswordLoginReusesRawRefreshedCookies(t *testing.T) {
useRetryingTestClient(t)
var n int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n++
cookie := r.Header.Get("Cookie")
if !strings.Contains(cookie, "JSESSIONID=stale") || !strings.Contains(cookie, "behaviorid=device") {
t.Errorf("login %d lost raw cookie context: %q", n, cookie)
}
if n == 1 {
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-1"})
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-1"})
w.Header().Set("Location", "https://mail.10086.cn/?sid=first")
} else {
if !strings.Contains(cookie, "RMKEY=rm-1") || !strings.Contains(cookie, "Os_SSo_Sid=sid-1") {
t.Errorf("second login did not reuse first refreshed cookies: %q", cookie)
}
http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "rm-2"})
http.SetCookie(w, &http.Cookie{Name: "Os_SSo_Sid", Value: "sid-2"})
w.Header().Set("Location", "https://mail.10086.cn/?sid=second")
}
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
oldURL := mailPasswordURL
mailPasswordURL = server.URL
defer func() { mailPasswordURL = oldURL }()
d := Yun139{Addition: Addition{Username: "18800000000", Password: "password", MailCookies: "Os_SSo_Sid=old; RMKEY=old; JSESSIONID=stale; behaviorid=device"}}
if sid, err := d.step1_password_login(); err != nil || sid != "first" {
t.Fatalf("first login sid=%q err=%v", sid, err)
}
refreshed := d.MailCookies
if !strings.Contains(refreshed, "RMKEY=rm-1") || !strings.Contains(refreshed, "Os_SSo_Sid=sid-1") {
t.Fatalf("first login did not persist refreshed cookies: %q", refreshed)
}
d.Authorization = ""
d.MailCookies = refreshed
if sid, err := d.step1_password_login(); err != nil || sid != "second" {
t.Fatalf("second login sid=%q err=%v", sid, err)
}
if n != 2 {
t.Fatalf("login count=%d, want 2", n)
}
}
+3 -21
View File
@@ -5,15 +5,12 @@ import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
func clientSuffix() map[string]string {
@@ -60,13 +57,7 @@ func timestamp() int64 {
type Time time.Time
func (t *Time) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
return t.Unmarshal([]byte(s))
}
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
b, err := e.Token()
if err != nil {
@@ -81,19 +72,10 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
}
func (t *Time) Unmarshal(b []byte) error {
bs := strings.Trim(string(b), "\"")
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
bs = strings.ReplaceAll(bs, "\u202f", " ")
bs = strings.ReplaceAll(bs, "\u00a0", " ")
var v time.Time
var err error
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
for _, s := range []string{bs, bs + " +08"} {
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
v, err = time.ParseInLocation(f, s, utils.CNLoc)
if err == nil {
break
}
}
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
if err == nil {
break
}
-65
View File
@@ -1,65 +0,0 @@
package _189_tv
import (
"encoding/json"
"encoding/xml"
"testing"
"time"
)
func TestTimeUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
want time.Time
}{
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var tm Time
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
}
if !tt.want.Equal(time.Time(tm)) {
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
}
})
}
}
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
var tm Time
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
t.Fatal("Unmarshal accepted an invalid time")
}
}
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
t.Run(input, func(t *testing.T) {
var tm Time
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
}
})
}
}
func TestTimeUnmarshalXML(t *testing.T) {
var tm Time
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33&#x202f;PM +08</time>`), &tm); err != nil {
t.Fatal(err)
}
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
if !want.Equal(time.Time(tm)) {
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
}
}
+3 -13
View File
@@ -184,7 +184,6 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
}
fullUrl += "/listFiles.action"
pageSize := 130 // 每一页返回的文件数量
res := make([]model.Obj, 0, 130)
for pageNum := 1; ; pageNum++ {
var resp Cloud189FilesResp
@@ -196,7 +195,7 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
"mediaAttr": "0",
"iconOption": "5",
"pageNum": fmt.Sprint(pageNum),
"pageSize": fmt.Sprint(pageSize),
"pageSize": "130",
})
if isFamily {
r.SetQueryParams(map[string]string{
@@ -219,22 +218,13 @@ func (y *Cloud189TV) getFiles(ctx context.Context, fileId string, isFamily bool)
if resp.FileListAO.Count == 0 {
break
}
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
PageCount := FolderCount + FileCount // 当前页数总数
for i := 0; i < FolderCount; i++ {
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
res = append(res, &resp.FileListAO.FolderList[i])
}
for i := 0; i < FileCount; i++ {
for i := 0; i < len(resp.FileListAO.FileList); i++ {
res = append(res, &resp.FileListAO.FileList[i])
}
// 文件数量小于设定数量时跳出
if PageCount < pageSize {
break
}
}
return res, nil
}
+3 -20
View File
@@ -9,7 +9,6 @@ import (
"crypto/sha1"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"encoding/xml"
"fmt"
@@ -20,7 +19,6 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/utils/random"
)
@@ -103,13 +101,7 @@ func MustParseTime(str string) *time.Time {
type Time time.Time
func (t *Time) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
return t.Unmarshal([]byte(s))
}
func (t *Time) UnmarshalJSON(b []byte) error { return t.Unmarshal(b) }
func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
b, err := e.Token()
if err != nil {
@@ -124,19 +116,10 @@ func (t *Time) UnmarshalXML(e *xml.Decoder, ee xml.StartElement) error {
}
func (t *Time) Unmarshal(b []byte) error {
bs := strings.Trim(string(b), "\"")
// 189 时间串里 AM/PM 前可能使用 U+202F 窄不换行空格或 U+00A0 不换行空格,统一替换为普通空格
bs = strings.ReplaceAll(bs, "\u202f", " ")
bs = strings.ReplaceAll(bs, "\u00a0", " ")
var v time.Time
var err error
// 189 返回的时间可能自带时区(如 "Aug 11, 2026, 10:37:18 PM +08"),也可能不带,分别尝试
for _, s := range []string{bs, bs + " +08"} {
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 3:04:05 PM -07", "Jan 2, 2006, 3:04:05 PM -07"} {
v, err = time.ParseInLocation(f, s, utils.CNLoc)
if err == nil {
break
}
}
for _, f := range []string{"2006-01-02 15:04:05 -07", "Jan 2, 2006 15:04:05 PM -07"} {
v, err = time.ParseInLocation(f, bs+" +08", time.Local)
if err == nil {
break
}
-65
View File
@@ -1,65 +0,0 @@
package _189pc
import (
"encoding/json"
"encoding/xml"
"testing"
"time"
)
func TestTimeUnmarshal(t *testing.T) {
tests := []struct {
name string
input string
want time.Time
}{
{"numeric date", `"2026-08-11 10:37:18"`, time.Date(2026, 8, 11, 10, 37, 18, 0, time.FixedZone("", 8*3600))},
{"legacy month date", `"Aug 11, 2026 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format with tz", `"Aug 11, 2026, 10:37:18 PM +08"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"new format no tz", `"Aug 11, 2026, 10:37:18 PM"`, time.Date(2026, 8, 11, 22, 37, 18, 0, time.FixedZone("", 8*3600))},
{"narrow no-break space (U+202F)", "\"Aug 12, 2026, 12:35:41\u202fAM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"no-break space (U+00A0)", "\"Aug 12, 2026, 12:35:41\u00a0AM +08\"", time.Date(2026, 8, 12, 0, 35, 41, 0, time.FixedZone("", 8*3600))},
{"JSON escaped narrow space", `"Sep 4, 2026, 10:59:33\u202fPM +08"`, time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))},
{"JSON escaped space without timezone", `"Sep 4, 2026, 12:59:33\u00a0AM"`, time.Date(2026, 9, 4, 0, 59, 33, 0, time.FixedZone("", 8*3600))},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var tm Time
if err := json.Unmarshal([]byte(tt.input), &tm); err != nil {
t.Fatalf("Unmarshal(%s) error: %v", tt.input, err)
}
if !tt.want.Equal(time.Time(tm)) {
t.Fatalf("Unmarshal(%s) = %v, want %v", tt.input, time.Time(tm), tt.want)
}
})
}
}
func TestTimeUnmarshalRejectsInvalid(t *testing.T) {
var tm Time
if err := tm.Unmarshal([]byte("Aug 12, 2026, 25:32:44 AM")); err == nil {
t.Fatal("Unmarshal accepted an invalid time")
}
}
func TestTimeUnmarshalJSONRejectsInvalid(t *testing.T) {
for _, input := range []string{`"invalid"`, `123`, `null`, `"Sep 4, 2026, 10:59:33\uZZZZPM +08"`} {
t.Run(input, func(t *testing.T) {
var tm Time
if err := tm.UnmarshalJSON([]byte(input)); err == nil {
t.Fatalf("UnmarshalJSON(%s) accepted invalid input", input)
}
})
}
}
func TestTimeUnmarshalXML(t *testing.T) {
var tm Time
if err := xml.Unmarshal([]byte(`<time>Sep 4, 2026, 10:59:33&#x202f;PM +08</time>`), &tm); err != nil {
t.Fatal(err)
}
want := time.Date(2026, 9, 4, 22, 59, 33, 0, time.FixedZone("", 8*3600))
if !want.Equal(time.Time(tm)) {
t.Fatalf("UnmarshalXML = %v, want %v", time.Time(tm), want)
}
}
-8
View File
@@ -191,10 +191,6 @@ type Cloud189File struct {
// StarLabel int64 `json:"starLabel"`
}
func normalizeCloud189Name(name string) string {
return strings.ReplaceAll(name, "\\'", "'")
}
func (c *Cloud189File) CreateTime() time.Time {
return time.Time(c.CreateDate)
}
@@ -211,8 +207,6 @@ func (c *Cloud189File) GetID() string { return string(c.ID) }
func (c *Cloud189File) GetPath() string { return "" }
func (c *Cloud189File) Thumb() string { return c.Icon.SmallUrl }
func (c *Cloud189File) GetDisplayName() string { return normalizeCloud189Name(c.Name) }
// 文件夹
type Cloud189Folder struct {
ID String `json:"id"`
@@ -244,8 +238,6 @@ func (c *Cloud189Folder) IsDir() bool { return true }
func (c *Cloud189Folder) GetID() string { return string(c.ID) }
func (c *Cloud189Folder) GetPath() string { return "" }
func (c *Cloud189Folder) GetDisplayName() string { return normalizeCloud189Name(c.Name) }
type Cloud189FilesResp struct {
//ResCode int `json:"res_code"`
//ResMessage string `json:"res_message"`
+3 -13
View File
@@ -195,10 +195,9 @@ func (y *Cloud189PC) put(ctx context.Context, url string, headers map[string]str
}
func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool) ([]model.Obj, error) {
pageSize := 1000 // 每一页返回的文件数量
res := make([]model.Obj, 0, 100)
for pageNum := 1; ; pageNum++ {
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, pageSize, y.OrderBy, y.OrderDirection)
resp, err := y.getFilesWithPage(ctx, fileId, isFamily, pageNum, 1000, y.OrderBy, y.OrderDirection)
if err != nil {
return nil, err
}
@@ -206,23 +205,14 @@ func (y *Cloud189PC) getFiles(ctx context.Context, fileId string, isFamily bool)
if resp.FileListAO.Count == 0 {
break
}
FolderCount := len(resp.FileListAO.FolderList) // 当前文件夹总数
FileCount := len(resp.FileListAO.FileList) // 当前文件总数
PageCount := FolderCount + FileCount // 当前页数总数
for i := 0; i < FolderCount; i++ {
for i := 0; i < len(resp.FileListAO.FolderList); i++ {
res = append(res, &resp.FileListAO.FolderList[i])
}
for i := 0; i < FileCount; i++ {
for i := 0; i < len(resp.FileListAO.FileList); i++ {
resp.FileListAO.FileList[i].ParentID = fileId
res = append(res, &resp.FileListAO.FileList[i])
}
// 当前文件数量小于设定数量则跳出
if PageCount < pageSize {
break
}
}
return res, nil
}
+1
View File
@@ -328,6 +328,7 @@ func (d *Alias) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
return nil, err
}
resultLink := link.Clone() // 复制一份,避免修改到原始link
resultLink.Expiration = nil
if args.Redirect {
return resultLink, nil
}
-1
View File
@@ -95,7 +95,6 @@ func (d *AListV3) List(ctx context.Context, dir model.Obj, args model.ListArgs)
file := model.ObjThumb{
Object: model.Object{
Name: f.Name,
Path: path.Join(dir.GetPath(), f.Name),
Modified: f.Modified,
Ctime: f.Created,
Size: f.Size,
-65
View File
@@ -1,65 +0,0 @@
package alist_v3
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
// TestListSetsChildPaths descends two levels the way op.Get does, feeding an
// object from one listing back into List as dir. Without a Path on that object
// the driver asks upstream for "", which a real server answers with its own
// root -- hence the fake upstream's fallback, and the endless self-similar tree.
func TestListSetsChildPaths(t *testing.T) {
tree := map[string][]ObjResp{
"/": {{Name: "root-marker", IsDir: true}},
"/drive": {{Name: "concerts", IsDir: true}},
"/drive/concerts": {{Name: "show.mkv"}},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ListReq
_ = json.NewDecoder(r.Body).Decode(&req)
content, ok := tree[req.Path]
if !ok {
content = tree["/"]
}
w.Header().Set("Content-Type", "application/json") // resty only unmarshals JSON
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200, "message": "success",
"data": map[string]any{"content": content, "total": len(content)},
})
}))
t.Cleanup(srv.Close)
// conf.Conf is nil outside a booted server, so base.InitClient() is unusable.
prev := base.RestyClient
base.RestyClient = resty.New().SetTimeout(5 * time.Second)
t.Cleanup(func() { base.RestyClient = prev })
d := &AListV3{Addition: Addition{
RootPath: driver.RootPath{RootFolderPath: "/drive"},
Address: srv.URL,
}}
dir := model.Obj(&model.Object{Path: "/drive", IsFolder: true})
for _, want := range []string{"/drive/concerts", "/drive/concerts/show.mkv"} {
objs, err := d.List(context.Background(), dir, model.ListArgs{})
if err != nil {
t.Fatalf("List(%q): %v", dir.GetPath(), err)
}
if len(objs) != 1 {
t.Fatalf("List(%q) returned %d objects, want 1", dir.GetPath(), len(objs))
}
if got := objs[0].GetPath(); got != want {
t.Fatalf("child of %q has path %q, want %q", dir.GetPath(), got, want)
}
dir = objs[0]
}
}
-295
View File
@@ -1,295 +0,0 @@
package aliyundrive_open
import (
"context"
"fmt"
"io"
"math/rand/v2"
"net/http"
"strings"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
anet "github.com/OpenListTeam/OpenList/v4/internal/net"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
)
const (
defaultCallbackConcurrency = 1
callbackAcquireTimeout = time.Second
callbackRequestAttempts = 3
callbackRetryBaseDelay = 200 * time.Millisecond
callbackErrorBodyLimit = 64 << 10
)
var callbackLimiters = struct {
sync.Mutex
byUser map[string]*callbackLimiter
}{byUser: make(map[string]*callbackLimiter)}
type callbackLimiter struct {
userID string
mu sync.Mutex
active int
nextID uint64
registrations map[uint64]int
changed chan struct{}
}
type callbackRegistration struct {
limiter *callbackLimiter
id uint64
once sync.Once
}
type callbackPermit struct {
limiter *callbackLimiter
once sync.Once
}
func normalizeCallbackConcurrency(limit int) int {
if limit <= 0 {
return defaultCallbackConcurrency
}
return limit
}
func registerCallbackLimiter(userID string, limit int) *callbackRegistration {
callbackLimiters.Lock()
defer callbackLimiters.Unlock()
limiter := callbackLimiters.byUser[userID]
if limiter == nil {
limiter = &callbackLimiter{
userID: userID,
registrations: make(map[uint64]int),
changed: make(chan struct{}),
}
callbackLimiters.byUser[userID] = limiter
}
limiter.mu.Lock()
limiter.nextID++
id := limiter.nextID
limiter.registrations[id] = normalizeCallbackConcurrency(limit)
limiter.signalLocked()
limiter.mu.Unlock()
return &callbackRegistration{limiter: limiter, id: id}
}
func (r *callbackRegistration) unregister() {
if r == nil || r.limiter == nil {
return
}
r.once.Do(func() {
callbackLimiters.Lock()
defer callbackLimiters.Unlock()
r.limiter.mu.Lock()
delete(r.limiter.registrations, r.id)
r.limiter.signalLocked()
if len(r.limiter.registrations) == 0 && r.limiter.active == 0 {
delete(callbackLimiters.byUser, r.limiter.userID)
}
r.limiter.mu.Unlock()
})
}
func (r *callbackRegistration) acquire(ctx context.Context) (*callbackPermit, error) {
if r == nil || r.limiter == nil {
return nil, errs.NewErr(errs.TemporaryCapacity, "callback limiter is unavailable")
}
if err := ctx.Err(); err != nil {
return nil, err
}
waitCtx, cancel := context.WithTimeout(ctx, callbackAcquireTimeout)
defer cancel()
for {
r.limiter.mu.Lock()
if r.limiter.active < r.limiter.limitLocked() {
r.limiter.active++
r.limiter.mu.Unlock()
return &callbackPermit{limiter: r.limiter}, nil
}
changed := r.limiter.changed
r.limiter.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-waitCtx.Done():
if err := ctx.Err(); err != nil {
return nil, err
}
return nil, errs.NewErr(errs.TemporaryCapacity, "timed out waiting for callback admission")
case <-changed:
}
}
}
func (l *callbackLimiter) limitLocked() int {
limit := 0
for _, registered := range l.registrations {
if limit == 0 || registered < limit {
limit = registered
}
}
return limit
}
func (l *callbackLimiter) signalLocked() {
close(l.changed)
l.changed = make(chan struct{})
}
func (p *callbackPermit) release() {
if p == nil || p.limiter == nil {
return
}
p.once.Do(func() {
callbackLimiters.Lock()
defer callbackLimiters.Unlock()
p.limiter.mu.Lock()
p.limiter.active--
p.limiter.signalLocked()
if len(p.limiter.registrations) == 0 && p.limiter.active == 0 {
delete(callbackLimiters.byUser, p.limiter.userID)
}
p.limiter.mu.Unlock()
})
}
func (d *AliyundriveOpen) callbackRegistration() *callbackRegistration {
if d.callback != nil {
return d.callback
}
if d.ref != nil {
return d.ref.callbackRegistration()
}
return nil
}
func (d *AliyundriveOpen) callbackRangeReader(url string, size int64) stream.RangeReaderFunc {
return func(ctx context.Context, requested http_range.Range) (io.ReadCloser, error) {
if requested.Length < 0 || requested.Start+requested.Length > size {
requested.Length = size - requested.Start
}
for attempt := 0; attempt < callbackRequestAttempts; attempt++ {
permit, err := d.callbackRegistration().acquire(ctx)
if err != nil {
return nil, err
}
body, retry, err := openCallbackRange(ctx, url, size, requested)
if !retry && err == nil {
return newCallbackBody(ctx, body, permit.release), nil
}
permit.release()
if !retry {
return nil, err
}
if attempt+1 == callbackRequestAttempts {
return nil, errs.NewErr(errs.TemporaryCapacity, "Aliyun callback concurrency limit rejected %d attempts", callbackRequestAttempts)
}
delay := callbackRetryBaseDelay << attempt
delay += time.Duration(rand.Int64N(int64(delay / 2)))
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, errs.NewErr(errs.TemporaryCapacity, "callback attempts exhausted")
}
}
func openCallbackRange(ctx context.Context, url string, size int64, requested http_range.Range) (io.ReadCloser, bool, error) {
requestHeader, _ := ctx.Value(conf.RequestHeaderKey).(http.Header)
header := anet.ProcessHeader(requestHeader, nil)
header = http_range.ApplyRangeToHttpHeader(requested, header)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, false, fmt.Errorf("create Aliyun callback request: %w", err)
}
req.Header = header
response, err := anet.HttpClient().Do(req)
if err != nil {
return nil, false, fmt.Errorf("Aliyun callback request failed: %w", err)
}
if response.StatusCode >= http.StatusBadRequest {
defer response.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(response.Body, callbackErrorBodyLimit))
if readErr != nil {
return nil, false, fmt.Errorf("read Aliyun callback error response: %w", readErr)
}
if isCallbackCapacityRejection(response.StatusCode, body) {
return nil, true, nil
}
message := strings.ReplaceAll(strings.TrimSpace(string(body)), url, "<redacted>")
return nil, false, fmt.Errorf("Aliyun callback request failed: %w; response: %s", anet.HttpStatusCodeError(response.StatusCode), message)
}
if requested.Start == 0 && requested.Length == size || response.StatusCode == http.StatusPartialContent || callbackContentRangeStartsAt(response.Header, requested.Start) {
return response.Body, false, nil
}
if response.StatusCode == http.StatusOK {
body, rangeErr := anet.GetRangedHttpReader(response.Body, requested.Start, requested.Length)
if rangeErr != nil {
response.Body.Close()
return nil, false, rangeErr
}
return body, false, nil
}
return response.Body, false, nil
}
func isCallbackCapacityRejection(status int, body []byte) bool {
return status == http.StatusForbidden &&
strings.Contains(string(body), "RequestDeniedByCallback") &&
strings.Contains(string(body), "ExceedMaxConcurrency")
}
func callbackContentRangeStartsAt(header http.Header, offset int64) bool {
start, _, err := http_range.ParseContentRange(header.Get("Content-Range"))
return err == nil && start == offset
}
type callbackBody struct {
body io.ReadCloser
release func()
once sync.Once
mu sync.Mutex
stop func() bool
}
func newCallbackBody(ctx context.Context, body io.ReadCloser, release func()) *callbackBody {
b := &callbackBody{body: body, release: release}
stop := context.AfterFunc(ctx, func() { _ = b.Close() })
b.mu.Lock()
b.stop = stop
b.mu.Unlock()
return b
}
func (b *callbackBody) Read(p []byte) (int, error) {
n, err := b.body.Read(p)
if err != nil {
_ = b.Close()
}
return n, err
}
func (b *callbackBody) Close() error {
var err error
b.once.Do(func() {
b.mu.Lock()
stop := b.stop
b.mu.Unlock()
if stop != nil {
stop()
}
err = b.body.Close()
b.release()
})
return err
}
-365
View File
@@ -1,365 +0,0 @@
package aliyundrive_open
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"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/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
)
func TestLinkSeparatesRedirectAndProxyRepresentations(t *testing.T) {
oldConf := conf.Conf
conf.Conf = &conf.Config{}
t.Cleanup(func() { conf.Conf = oldConf })
base.InitClient()
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/adrive/v1.0/user/getDriveInfo":
_, _ = fmt.Fprint(w, `{"user_id":"user-1","resource_drive_id":"drive-1"}`)
case "/adrive/v1.0/openFile/getDownloadUrl":
_, _ = fmt.Fprintf(w, `{"url":%q}`, server.URL+"/callback")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
oldAPIURL := API_URL
API_URL = server.URL
defer func() { API_URL = oldAPIURL }()
d := &AliyundriveOpen{Addition: Addition{AccessToken: "token"}}
if err := d.Init(t.Context()); err != nil {
t.Fatal(err)
}
defer d.Drop(context.Background())
if d.CallbackConcurrency != defaultCallbackConcurrency {
t.Fatalf("normalized callback concurrency = %d, want %d", d.CallbackConcurrency, defaultCallbackConcurrency)
}
link, err := d.Link(t.Context(), &model.Object{ID: "file-1", Name: "file", Size: 1}, model.LinkArgs{})
if err != nil {
t.Fatal(err)
}
if link.RangeReader == nil {
t.Fatal("proxy link must own callback acquisition through a range reader")
}
if _, ok := link.RangeReader.(stream.RateLimitRangeReaderFunc); !ok {
t.Fatalf("proxy range reader type = %T, want server-rate-limited reader", link.RangeReader)
}
direct, err := d.Link(t.Context(), &model.Object{ID: "file-1", Name: "file", Size: 1}, model.LinkArgs{Redirect: true})
if err != nil {
t.Fatal(err)
}
if direct.URL == "" || direct.RangeReader != nil {
t.Fatal("redirect link must remain URL-only")
}
}
func TestCallbackRangeHoldsPermitUntilBodyClose(t *testing.T) {
oldConf := conf.Conf
conf.Conf = &conf.Config{}
t.Cleanup(func() { conf.Conf = oldConf })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "1")
w.Header().Set("Content-Range", "bytes 0-0/1")
w.WriteHeader(http.StatusPartialContent)
_, _ = io.WriteString(w, "x")
}))
defer server.Close()
registration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(registration.unregister)
d := &AliyundriveOpen{callback: registration}
body, err := d.callbackRangeReader(server.URL, 1).RangeRead(t.Context(), http_range.Range{Length: 1})
if err != nil {
t.Fatal(err)
}
registration.limiter.mu.Lock()
active := registration.limiter.active
registration.limiter.mu.Unlock()
if active != 1 {
t.Fatalf("active callback bodies = %d, want 1", active)
}
if err := body.Close(); err != nil {
t.Fatal(err)
}
registration.limiter.mu.Lock()
active = registration.limiter.active
registration.limiter.mu.Unlock()
if active != 0 {
t.Fatalf("active callback bodies after Close = %d, want 0", active)
}
}
func TestCallbackLimiterUsesMinimumRegisteredLimit(t *testing.T) {
firstRegistration := registerCallbackLimiter(t.Name(), 2)
t.Cleanup(firstRegistration.unregister)
first, err := firstRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
second, err := firstRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
defer first.release()
defer second.release()
lowerRegistration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(lowerRegistration.unregister)
acquired := make(chan *callbackPermit, 1)
go func() {
permit, acquireErr := lowerRegistration.acquire(t.Context())
if acquireErr == nil {
acquired <- permit
}
}()
first.release()
select {
case permit := <-acquired:
permit.release()
t.Fatal("lowering the shared limit must wait for all excess bodies to drain")
case <-time.After(100 * time.Millisecond):
}
second.release()
select {
case permit := <-acquired:
permit.release()
case <-time.After(time.Second):
t.Fatal("admission did not resume after active bodies drained below the new limit")
}
}
func TestCallbackLimiterSeparatesUsers(t *testing.T) {
firstUser := registerCallbackLimiter(t.Name()+"-first", 1)
secondUser := registerCallbackLimiter(t.Name()+"-second", 1)
t.Cleanup(firstUser.unregister)
t.Cleanup(secondUser.unregister)
first, err := firstUser.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
defer first.release()
second, err := secondUser.acquire(t.Context())
if err != nil {
t.Fatalf("independent user was blocked: %v", err)
}
second.release()
}
func TestCallbackLimiterReconfigureWaitsForOldBodies(t *testing.T) {
userID := t.Name()
oldRegistration := registerCallbackLimiter(userID, 2)
first, err := oldRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
second, err := oldRegistration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
oldRegistration.unregister()
newRegistration := registerCallbackLimiter(userID, 1)
t.Cleanup(newRegistration.unregister)
acquired := make(chan *callbackPermit, 1)
go func() {
permit, acquireErr := newRegistration.acquire(t.Context())
if acquireErr == nil {
acquired <- permit
}
}()
first.release()
select {
case permit := <-acquired:
permit.release()
t.Fatal("reconfigured limiter admitted while an old body still occupied the new limit")
case <-time.After(100 * time.Millisecond):
}
second.release()
select {
case permit := <-acquired:
permit.release()
case <-time.After(time.Second):
t.Fatal("reconfigured limiter did not admit after old bodies drained")
}
}
func TestCallbackLimiterDistinguishesTimeoutAndCancellation(t *testing.T) {
registration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(registration.unregister)
permit, err := registration.acquire(t.Context())
if err != nil {
t.Fatal(err)
}
defer permit.release()
started := time.Now()
_, err = registration.acquire(t.Context())
if !errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("admission timeout error = %v, want TemporaryCapacity", err)
}
if time.Since(started) < callbackAcquireTimeout {
t.Fatal("admission timed out before the configured wait elapsed")
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err = registration.acquire(ctx)
if !errors.Is(err, context.Canceled) || errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("canceled admission error = %v, want only context.Canceled", err)
}
}
func TestCallbackCapacityRejectionRequiresBothExactMarkers(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{name: "both", body: `{"code":"RequestDeniedByCallback","message":"ExceedMaxConcurrency"}`, want: true},
{name: "code only", body: `{"code":"RequestDeniedByCallback"}`},
{name: "message only", body: `{"message":"ExceedMaxConcurrency"}`},
{name: "case differs", body: `{"code":"requestdeniedbycallback","message":"ExceedMaxConcurrency"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := isCallbackCapacityRejection(http.StatusForbidden, []byte(test.body)); got != test.want {
t.Fatalf("classification = %v, want %v", got, test.want)
}
})
}
if isCallbackCapacityRejection(http.StatusTooManyRequests, []byte(`RequestDeniedByCallback ExceedMaxConcurrency`)) {
t.Fatal("non-403 response must not be classified as callback capacity")
}
}
func TestCallbackRangeRetriesOnlyVerifiedCapacityRejections(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"code":"RequestDeniedByCallback","message":"ExceedMaxConcurrency"}`)
}))
defer server.Close()
registration := registerCallbackLimiter(t.Name(), 1)
t.Cleanup(registration.unregister)
d := &AliyundriveOpen{callback: registration}
_, err := d.callbackRangeReader(server.URL+"?token=secret", 1).RangeRead(t.Context(), http_range.Range{Length: 1})
if !errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("verified rejection error = %v, want TemporaryCapacity", err)
}
if requests.Load() != callbackRequestAttempts {
t.Fatalf("requests = %d, want %d", requests.Load(), callbackRequestAttempts)
}
if strings.Contains(err.Error(), "secret") {
t.Fatal("capacity error leaked the signed callback URL")
}
permit, acquireErr := registration.acquire(t.Context())
if acquireErr != nil {
t.Fatalf("capacity retries leaked admission: %v", acquireErr)
}
permit.release()
requests.Store(0)
permanent := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"code":"RequestDeniedByCallback","message":"denied"}`)
}))
defer permanent.Close()
_, err = d.callbackRangeReader(permanent.URL+"?token=secret", 1).RangeRead(t.Context(), http_range.Range{Length: 1})
if errors.Is(err, errs.TemporaryCapacity) {
t.Fatalf("permanent 403 error = %v, must not be TemporaryCapacity", err)
}
if requests.Load() != 1 {
t.Fatalf("permanent 403 requests = %d, want 1", requests.Load())
}
if strings.Contains(err.Error(), "secret") {
t.Fatal("permanent error leaked the signed callback URL")
}
}
type countingReadCloser struct {
reader io.Reader
closed atomic.Int32
}
func (r *countingReadCloser) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *countingReadCloser) Close() error {
r.closed.Add(1)
return nil
}
func TestCallbackBodyReleasesExactlyOnce(t *testing.T) {
underlying := &countingReadCloser{reader: strings.NewReader("x")}
var released atomic.Int32
body := newCallbackBody(t.Context(), underlying, func() { released.Add(1) })
_, _ = io.ReadAll(body)
if err := body.Close(); err != nil {
t.Fatal(err)
}
if err := body.Close(); err != nil {
t.Fatal(err)
}
if underlying.closed.Load() != 1 || released.Load() != 1 {
t.Fatalf("close count = %d, release count = %d; want 1, 1", underlying.closed.Load(), released.Load())
}
}
type failingReadCloser struct {
closed atomic.Int32
}
func (*failingReadCloser) Read([]byte) (int, error) { return 0, errors.New("read failed") }
func (r *failingReadCloser) Close() error {
r.closed.Add(1)
return nil
}
func TestCallbackBodyReadFailureReleasesPermit(t *testing.T) {
underlying := &failingReadCloser{}
var released atomic.Int32
body := newCallbackBody(t.Context(), underlying, func() { released.Add(1) })
if _, err := body.Read(make([]byte, 1)); err == nil {
t.Fatal("read unexpectedly succeeded")
}
if underlying.closed.Load() != 1 || released.Load() != 1 {
t.Fatalf("close count = %d, release count = %d; want 1, 1", underlying.closed.Load(), released.Load())
}
}
func TestCallbackBodyCancellationReleasesPermit(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
underlying := &countingReadCloser{reader: strings.NewReader("x")}
released := make(chan struct{}, 1)
_ = newCallbackBody(ctx, underlying, func() { released <- struct{}{} })
cancel()
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("context cancellation did not release callback admission")
}
if underlying.closed.Load() != 1 {
t.Fatalf("underlying close count = %d, want 1", underlying.closed.Load())
}
}
+4 -18
View File
@@ -11,7 +11,6 @@ import (
"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/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
@@ -23,9 +22,8 @@ type AliyundriveOpen struct {
DriveId string
limiter *limiter
ref *AliyundriveOpen
callback *callbackRegistration
limiter *limiter
ref *AliyundriveOpen
}
func (d *AliyundriveOpen) Config() driver.Config {
@@ -37,7 +35,6 @@ func (d *AliyundriveOpen) GetAddition() driver.Additional {
}
func (d *AliyundriveOpen) Init(ctx context.Context) error {
d.CallbackConcurrency = normalizeCallbackConcurrency(d.CallbackConcurrency)
d.limiter = getLimiterForUser(globalLimiterUserID) // First create a globally shared limiter to limit the initial requests.
if d.LIVPDownloadFormat == "" {
d.LIVPDownloadFormat = "jpeg"
@@ -55,7 +52,6 @@ func (d *AliyundriveOpen) Init(ctx context.Context) error {
userid := utils.Json.Get(res, "user_id").ToString()
d.limiter.free()
d.limiter = getLimiterForUser(userid) // Allocate a corresponding limiter for each user.
d.callback = registerCallbackLimiter(userid, d.CallbackConcurrency)
return nil
}
@@ -69,10 +65,6 @@ func (d *AliyundriveOpen) InitReference(storage driver.Driver) error {
}
func (d *AliyundriveOpen) Drop(ctx context.Context) error {
if d.callback != nil {
d.callback.unregister()
d.callback = nil
}
d.limiter.free()
d.limiter = nil
d.ref = nil
@@ -127,16 +119,10 @@ func (d *AliyundriveOpen) Link(ctx context.Context, file model.Obj, args model.L
url = utils.Json.Get(res, "streamsUrl", d.LIVPDownloadFormat).ToString()
}
exp := time.Minute
link := &model.Link{
return &model.Link{
URL: url,
Expiration: &exp,
}
if args.Redirect {
return link, nil
}
link.URL = ""
link.RangeReader = stream.RateLimitRangeReaderFunc(d.callbackRangeReader(url, file.GetSize()))
return link, nil
}, nil
}
func (d *AliyundriveOpen) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
+13 -14
View File
@@ -8,20 +8,19 @@ import (
type Addition struct {
DriveType string `json:"drive_type" type:"select" options:"default,resource,backup" default:"resource"`
driver.RootID
RefreshToken string `json:"refresh_token" required:"true"`
OrderBy string `json:"order_by" type:"select" options:"name,size,updated_at,created_at"`
OrderDirection string `json:"order_direction" type:"select" options:"ASC,DESC"`
UseOnlineAPI bool `json:"use_online_api" default:"true"`
AlipanType string `json:"alipan_type" required:"true" type:"select" default:"default" options:"default,alipanTV"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/alicloud/renewapi"`
ClientID string `json:"client_id" help:"Keep it empty if you don't have one"`
ClientSecret string `json:"client_secret" help:"Keep it empty if you don't have one"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
RapidUpload bool `json:"rapid_upload" help:"If you enable this option, the file will be uploaded to the server first, so the progress will be incorrect"`
InternalUpload bool `json:"internal_upload" help:"If you are using Aliyun ECS is located in Beijing, you can turn it on to boost the upload speed"`
LIVPDownloadFormat string `json:"livp_download_format" type:"select" options:"jpeg,mov" default:"jpeg"`
CallbackConcurrency int `json:"callback_concurrency" type:"number" default:"1" help:"Maximum active proxied downloads shared by Aliyun user"`
AccessToken string
RefreshToken string `json:"refresh_token" required:"true"`
OrderBy string `json:"order_by" type:"select" options:"name,size,updated_at,created_at"`
OrderDirection string `json:"order_direction" type:"select" options:"ASC,DESC"`
UseOnlineAPI bool `json:"use_online_api" default:"true"`
AlipanType string `json:"alipan_type" required:"true" type:"select" default:"default" options:"default,alipanTV"`
APIAddress string `json:"api_url_address" default:"https://api.oplist.org/alicloud/renewapi"`
ClientID string `json:"client_id" help:"Keep it empty if you don't have one"`
ClientSecret string `json:"client_secret" help:"Keep it empty if you don't have one"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
RapidUpload bool `json:"rapid_upload" help:"If you enable this option, the file will be uploaded to the server first, so the progress will be incorrect"`
InternalUpload bool `json:"internal_upload" help:"If you are using Aliyun ECS is located in Beijing, you can turn it on to boost the upload speed"`
LIVPDownloadFormat string `json:"livp_download_format" type:"select" options:"jpeg,mov" default:"jpeg"`
AccessToken string
}
var config = driver.Config{
-2
View File
@@ -22,7 +22,6 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/azure_blob"
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_netdisk"
_ "github.com/OpenListTeam/OpenList/v4/drivers/baidu_photo"
_ "github.com/OpenListTeam/OpenList/v4/drivers/bunny_storage"
_ "github.com/OpenListTeam/OpenList/v4/drivers/chaoxing"
_ "github.com/OpenListTeam/OpenList/v4/drivers/chunk"
_ "github.com/OpenListTeam/OpenList/v4/drivers/cloudflare_imgbed"
@@ -42,7 +41,6 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/github_releases"
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_drive"
_ "github.com/OpenListTeam/OpenList/v4/drivers/google_photo"
_ "github.com/OpenListTeam/OpenList/v4/drivers/guangyapan"
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud"
_ "github.com/OpenListTeam/OpenList/v4/drivers/halalcloud_open"
_ "github.com/OpenListTeam/OpenList/v4/drivers/ilanzou"
-226
View File
@@ -1,226 +0,0 @@
package bunny_storage
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
stdpath "path"
"strings"
"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/go-resty/resty/v2"
)
type BunnyStorage struct {
model.Storage
Addition
client *resty.Client
endpoint *url.URL
cdnBase *url.URL
}
func (d *BunnyStorage) Config() driver.Config {
cfg := config
if d.StorageZoneName != "" && d.CDNBaseURL == "" {
cfg.OnlyProxy = true
cfg.PreferProxy = true
}
if d.CDNTokenKey != "" && d.CDNTokenIncludeIP {
cfg.LinkCacheMode = driver.LinkCacheIP
}
return cfg
}
func (d *BunnyStorage) GetAddition() driver.Additional {
return &d.Addition
}
func (d *BunnyStorage) Init(ctx context.Context) error {
if d.RootFolderPath == "" {
d.RootFolderPath = "/"
}
if d.Endpoint == "" {
d.Endpoint = defaultEndpoint
}
if d.SignURLExpire <= 0 {
d.SignURLExpire = 4
}
if d.CDNTokenMethod == "" {
d.CDNTokenMethod = cdnTokenMethodSHA256
}
endpoint, err := normalizeBaseURL(d.Endpoint, defaultEndpoint)
if err != nil {
return fmt.Errorf("invalid endpoint: %w", err)
}
d.endpoint = endpoint
if d.CDNBaseURL != "" {
cdnBase, err := normalizeBaseURL(d.CDNBaseURL, "")
if err != nil {
return fmt.Errorf("invalid cdn_base_url: %w", err)
}
d.cdnBase = cdnBase
}
d.client = base.RestyClient
if d.client == nil {
d.client = base.NewRestyClient()
}
return nil
}
func (d *BunnyStorage) Drop(ctx context.Context) error {
return nil
}
func (d *BunnyStorage) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
var items []bunnyObject
resp, err := d.authRequest().
SetContext(ctx).
SetResult(&items).
Get(d.storageURL(dir.GetPath(), true))
if err != nil {
return nil, err
}
if err := d.handleResponseError(resp); err != nil {
return nil, err
}
result := make([]model.Obj, 0, len(items))
placeholder := d.placeholderName()
for _, item := range items {
if item.ObjectName == "" {
continue
}
if !args.S3ShowPlaceholder && !item.IsDirectory && item.ObjectName == placeholder {
continue
}
result = append(result, d.toObj(dir.GetPath(), item))
}
return result, nil
}
func (d *BunnyStorage) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, errs.NotFile
}
cacheTTL := time.Duration(0)
if d.cdnBase != nil {
linkURL := d.cdnURL(d.cdnObjectPath(file.GetPath()))
link := &model.Link{
URL: linkURL,
ContentLength: file.GetSize(),
Expiration: &cacheTTL,
}
if d.CDNTokenKey != "" {
signedURL, _, err := d.signCDNURL(linkURL, args.IP)
if err != nil {
return nil, err
}
link.URL = signedURL
}
return link, nil
}
return &model.Link{
URL: d.storageURL(file.GetPath(), false),
Header: http.Header{"AccessKey": []string{d.AccessKey}},
ContentLength: file.GetSize(),
Expiration: &cacheTTL,
}, nil
}
func (d *BunnyStorage) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
dirPath := stdpath.Join(parentDir.GetPath(), dirName)
placeholderPath := stdpath.Join(dirPath, d.placeholderName())
if err := d.putReader(ctx, placeholderPath, bytes.NewReader(nil), 0, "application/octet-stream", nil); err != nil {
return nil, err
}
now := time.Now()
return &model.Object{
Path: dirPath,
Name: dirName,
Modified: now,
Ctime: now,
IsFolder: true,
}, nil
}
func (d *BunnyStorage) Remove(ctx context.Context, obj model.Obj) error {
resp, err := d.authRequest().
SetContext(ctx).
Delete(d.storageURL(obj.GetPath(), obj.IsDir()))
if err != nil {
return err
}
return d.handleResponseError(resp)
}
func (d *BunnyStorage) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
if up == nil {
up = func(float64) {}
}
dstPath := stdpath.Join(dstDir.GetPath(), file.GetName())
err := d.putReader(ctx, dstPath, driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
Reader: file,
UpdateProgress: up,
}), file.GetSize(), file.GetMimetype(), nil)
if err != nil {
return nil, err
}
now := time.Now()
return &model.Object{
Path: dstPath,
Name: file.GetName(),
Size: file.GetSize(),
Modified: now,
Ctime: now,
}, nil
}
func (d *BunnyStorage) putReader(ctx context.Context, path string, body any, size int64, contentType string, extraHeaders http.Header) error {
if contentType == "" {
contentType = "application/octet-stream"
}
req := d.authRequest().
SetContext(ctx).
SetBody(body).
SetHeader("Content-Type", contentType)
if size >= 0 {
req.SetHeader("Content-Length", fmt.Sprint(size))
}
for key, values := range extraHeaders {
for _, value := range values {
req.SetHeader(key, value)
}
}
resp, err := req.Put(d.storageURL(path, false))
if err != nil {
return err
}
return d.handleResponseError(resp)
}
func (d *BunnyStorage) Get(ctx context.Context, path string) (model.Obj, error) {
fullPath := stdpath.Join(d.GetRootPath(), path)
parentPath, name := stdpath.Split(fullPath)
parentPath = strings.TrimSuffix(parentPath, "/")
if parentPath == "" {
parentPath = "/"
}
objs, err := d.List(ctx, &model.Object{Path: parentPath, IsFolder: true}, model.ListArgs{S3ShowPlaceholder: true})
if err != nil {
return nil, err
}
for _, obj := range objs {
if obj.GetName() == name {
return obj, nil
}
}
return nil, errs.ObjectNotFound
}
var _ driver.Driver = (*BunnyStorage)(nil)
var _ driver.Getter = (*BunnyStorage)(nil)
-32
View File
@@ -1,32 +0,0 @@
package bunny_storage
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
driver.RootPath
StorageZoneName string `json:"storage_zone_name" required:"true"`
AccessKey string `json:"access_key" required:"true"`
Endpoint string `json:"endpoint" required:"true" default:"storage.bunnycdn.com"`
CDNBaseURL string `json:"cdn_base_url"`
CDNTokenKey string `json:"cdn_token_key"`
CDNTokenMethod string `json:"cdn_token_method" type:"select" options:"sha256,hmac_sha256" default:"sha256"`
CDNTokenIncludeIP bool `json:"cdn_token_include_ip" default:"false"`
SignURLExpire int `json:"sign_url_expire" type:"number" default:"4"`
Placeholder string `json:"placeholder" default:".openlist"`
}
var config = driver.Config{
Name: "Bunny Storage",
LocalSort: true,
DefaultRoot: "/",
CheckStatus: true,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &BunnyStorage{}
})
}
-27
View File
@@ -1,27 +0,0 @@
package bunny_storage
import "time"
type bunnyObject struct {
Guid string `json:"Guid"`
StorageZoneName string `json:"StorageZoneName"`
Path string `json:"Path"`
ObjectName string `json:"ObjectName"`
Length int64 `json:"Length"`
LastChanged string `json:"LastChanged"`
IsDirectory bool `json:"IsDirectory"`
ServerID int `json:"ServerId"`
UserID string `json:"UserId"`
DateCreated string `json:"DateCreated"`
StorageZoneID int64 `json:"StorageZoneId"`
}
type apiError struct {
HttpCode int `json:"HttpCode"`
Message string `json:"Message"`
}
type parsedTimes struct {
modified time.Time
created time.Time
}
-275
View File
@@ -1,275 +0,0 @@
package bunny_storage
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
stdpath "path"
"sort"
"strconv"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/errs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/go-resty/resty/v2"
)
const (
defaultEndpoint = "storage.bunnycdn.com"
defaultPlaceholder = ".openlist"
cdnTokenMethodSHA256 = "sha256"
cdnTokenMethodHMACSHA256 = "hmac_sha256"
)
func normalizeBaseURL(raw string, fallback string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = fallback
}
if raw == "" {
return nil, fmt.Errorf("empty url")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if u.Host == "" {
return nil, fmt.Errorf("invalid url: %s", raw)
}
u.Path = strings.TrimRight(u.Path, "/")
return u, nil
}
func cleanObjectPath(path string) string {
if path == "" {
return "/"
}
return stdpath.Clean("/" + strings.TrimPrefix(path, "/"))
}
func stripObjectPathPrefix(path string, prefix string) (string, bool) {
path = cleanObjectPath(path)
prefix = cleanObjectPath(prefix)
if prefix == "/" {
return path, false
}
if path == prefix {
return "/", true
}
if strings.HasPrefix(path, prefix+"/") {
return cleanObjectPath(strings.TrimPrefix(path, prefix)), true
}
return path, false
}
func isObjectPathOrChild(path string, parent string) bool {
path = cleanObjectPath(path)
parent = cleanObjectPath(parent)
return path == parent || strings.HasPrefix(path, parent+"/")
}
func trimCDNBasePath(path string, mountPath string) string {
path = cleanObjectPath(path)
if path == "/" {
return ""
}
if stripped, ok := stripObjectPathPrefix(path, mountPath); ok {
path = stripped
}
if path == "/" {
return ""
}
return strings.TrimRight(path, "/")
}
func (d *BunnyStorage) cdnObjectPath(path string) string {
objectPath := cleanObjectPath(path)
if stripped, ok := stripObjectPathPrefix(objectPath, d.GetStorage().MountPath); ok {
objectPath = stripped
}
rootPath := cleanObjectPath(d.GetRootPath())
if rootPath != "/" && !isObjectPathOrChild(objectPath, rootPath) {
objectPath = cleanObjectPath(stdpath.Join(rootPath, objectPath))
}
return objectPath
}
func (d *BunnyStorage) placeholderName() string {
if d.Placeholder == "" {
return defaultPlaceholder
}
return d.Placeholder
}
func (d *BunnyStorage) storageURL(path string, dir bool) string {
u := *d.endpoint
cleanPath := cleanObjectPath(path)
zone := strings.Trim(d.StorageZoneName, "/")
if cleanPath == "/" {
u.Path = "/" + zone + "/"
return u.String()
}
u.Path = "/" + zone + "/" + strings.TrimPrefix(cleanPath, "/")
if dir && !strings.HasSuffix(u.Path, "/") {
u.Path += "/"
}
return u.String()
}
func (d *BunnyStorage) cdnURL(path string) string {
u := *d.cdnBase
cleanPath := cleanObjectPath(path)
basePath := trimCDNBasePath(u.Path, d.GetStorage().MountPath)
if cleanPath == "/" {
if basePath == "" {
u.Path = "/"
} else {
u.Path = basePath + "/"
}
return u.String()
}
u.Path = basePath + "/" + strings.TrimPrefix(cleanPath, "/")
return u.String()
}
func (d *BunnyStorage) authRequest() *resty.Request {
return d.client.R().SetHeader("AccessKey", d.AccessKey)
}
func (d *BunnyStorage) handleResponseError(resp *resty.Response) error {
if resp == nil {
return fmt.Errorf("empty response")
}
if resp.StatusCode() >= http.StatusOK && resp.StatusCode() < http.StatusMultipleChoices {
return nil
}
message := strings.TrimSpace(resp.String())
var apiErrors []apiError
if err := json.Unmarshal(resp.Body(), &apiErrors); err == nil && len(apiErrors) > 0 && apiErrors[0].Message != "" {
message = apiErrors[0].Message
}
switch resp.StatusCode() {
case http.StatusUnauthorized, http.StatusForbidden:
return errs.NewErr(errs.PermissionDenied, "bunny storage request failed: %s", message)
case http.StatusNotFound:
return errs.NewErr(errs.ObjectNotFound, "bunny storage request failed: %s", message)
default:
return fmt.Errorf("bunny storage request failed: %s: %s", resp.Status(), message)
}
}
func (d *BunnyStorage) parseTimes(item bunnyObject) parsedTimes {
return parsedTimes{
modified: parseBunnyTime(item.LastChanged, d.Modified),
created: parseBunnyTime(item.DateCreated, time.Time{}),
}
}
func parseBunnyTime(value string, fallback time.Time) time.Time {
if value == "" {
return fallback
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05.999999999", value); err == nil {
return t
}
if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil {
return t
}
return fallback
}
func (d *BunnyStorage) toObj(parentPath string, item bunnyObject) model.Obj {
times := d.parseTimes(item)
return &model.Object{
ID: item.Guid,
Path: stdpath.Join(parentPath, item.ObjectName),
Name: item.ObjectName,
Size: item.Length,
Modified: times.modified,
Ctime: times.created,
IsFolder: item.IsDirectory,
}
}
func canonicalQuery(values url.Values) (string, error) {
keys := make([]string, 0, len(values))
for key := range values {
if key == "token" || key == "expires" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
vals := values[key]
if len(vals) > 1 {
return "", fmt.Errorf("duplicate query parameter %q is not supported", key)
}
value := ""
if len(vals) == 1 {
value = vals[0]
}
parts = append(parts, key+"="+value)
}
return strings.Join(parts, "&"), nil
}
func (d *BunnyStorage) signCDNURL(rawURL string, clientIP string) (string, time.Duration, error) {
return d.signCDNURLAt(rawURL, clientIP, time.Now())
}
func (d *BunnyStorage) signCDNURLAt(rawURL string, clientIP string, now time.Time) (string, time.Duration, error) {
expire := time.Hour * time.Duration(d.SignURLExpire)
if expire <= 0 {
expire = 4 * time.Hour
}
expires := now.Add(expire).Unix()
u, err := url.Parse(rawURL)
if err != nil {
return "", 0, err
}
query := u.Query()
parameterData, err := canonicalQuery(query)
if err != nil {
return "", 0, err
}
signaturePath, err := url.PathUnescape(u.EscapedPath())
if err != nil {
signaturePath = u.Path
}
if !d.CDNTokenIncludeIP {
clientIP = ""
}
token := d.signCDNToken(signaturePath, strconv.FormatInt(expires, 10), parameterData, clientIP)
query.Set("token", token)
query.Set("expires", strconv.FormatInt(expires, 10))
u.RawQuery = query.Encode()
return u.String(), expire, nil
}
func (d *BunnyStorage) signCDNToken(signaturePath string, expires string, parameterData string, clientIP string) string {
switch strings.ToLower(strings.TrimSpace(d.CDNTokenMethod)) {
case cdnTokenMethodHMACSHA256:
message := signaturePath + expires + parameterData + clientIP
mac := hmac.New(sha256.New, []byte(d.CDNTokenKey))
_, _ = mac.Write([]byte(message))
return "HS256-" + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
default:
hashableBase := d.CDNTokenKey + signaturePath + expires + parameterData + clientIP
sum := sha256.Sum256([]byte(hashableBase))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
}
-255
View File
@@ -1,255 +0,0 @@
package bunny_storage
import (
"context"
"net/url"
"testing"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func TestStorageURL(t *testing.T) {
endpoint, err := normalizeBaseURL("ny.storage.bunnycdn.com", defaultEndpoint)
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{
Addition: Addition{
StorageZoneName: "my-zone",
},
endpoint: endpoint,
}
if got, want := driver.storageURL("/", true), "https://ny.storage.bunnycdn.com/my-zone/"; got != want {
t.Fatalf("root list url = %q, want %q", got, want)
}
if got, want := driver.storageURL("/dir/a file.txt", false), "https://ny.storage.bunnycdn.com/my-zone/dir/a%20file.txt"; got != want {
t.Fatalf("file url = %q, want %q", got, want)
}
if got, want := driver.storageURL("/dir", true), "https://ny.storage.bunnycdn.com/my-zone/dir/"; got != want {
t.Fatalf("dir url = %q, want %q", got, want)
}
}
func TestCDNURLWithBasePath(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.example.com/prefix/", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{cdnBase: cdnBase}
if got, want := driver.cdnURL("/dir/a file.txt"), "https://cdn.example.com/prefix/dir/a%20file.txt"; got != want {
t.Fatalf("cdn url = %q, want %q", got, want)
}
}
func TestCDNURLUsesObjectPathWithoutMountPath(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.firmant.me", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{
Storage: model.Storage{MountPath: "/BS"},
cdnBase: cdnBase,
}
if got, want := driver.cdnURL(driver.cdnObjectPath("/BS/Video")), "https://cdn.firmant.me/Video"; got != want {
t.Fatalf("cdn url = %q, want %q", got, want)
}
}
func TestCDNURLDropsMountPathFromBaseURL(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.firmant.me/BS/", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{
Storage: model.Storage{MountPath: "/BS"},
cdnBase: cdnBase,
}
if got, want := driver.cdnURL(driver.cdnObjectPath("/Video")), "https://cdn.firmant.me/Video"; got != want {
t.Fatalf("cdn url = %q, want %q", got, want)
}
}
func TestCDNObjectPathKeepsRootFolderPath(t *testing.T) {
driver := &BunnyStorage{}
driver.MountPath = "/BS"
driver.RootFolderPath = "/library"
if got, want := driver.cdnObjectPath("/BS/Video"), "/library/Video"; got != want {
t.Fatalf("cdn object path = %q, want %q", got, want)
}
if got, want := driver.cdnObjectPath("/library/Video"), "/library/Video"; got != want {
t.Fatalf("cdn object path = %q, want %q", got, want)
}
}
func TestLinkDisablesLongLivedCache(t *testing.T) {
cdnBase, err := normalizeBaseURL("https://cdn.example.com", "")
if err != nil {
t.Fatal(err)
}
driver := &BunnyStorage{cdnBase: cdnBase}
link, err := driver.Link(context.Background(), &model.Object{
Path: "/video.mp4",
Name: "video.mp4",
Size: 123,
}, model.LinkArgs{})
if err != nil {
t.Fatal(err)
}
if link.Expiration == nil || *link.Expiration != 0 {
t.Fatalf("link expiration = %v, want immediate cache expiration", link.Expiration)
}
}
func TestSignCDNURL(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
CDNTokenIncludeIP: true,
SignURLExpire: 1,
},
}
signed, expire, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0))
if err != nil {
t.Fatal(err)
}
if expire <= 0 {
t.Fatal("expected positive expiration")
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("token")
if want := "FxSpFem88zFo6uHFziwTuoMQTgDaD2PEn5n1zTMBUBI"; token != want {
t.Fatalf("token = %q, want %q", token, want)
}
if parsed.Query().Get("expires") != "1700003600" {
t.Fatalf("expires = %q, want 1700003600", parsed.Query().Get("expires"))
}
if parsed.Query().Get("quality") != "high" {
t.Fatal("expected existing query parameters to be preserved")
}
}
func TestSignCDNURLSupportsHMACSHA256(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
CDNTokenMethod: cdnTokenMethodHMACSHA256,
CDNTokenIncludeIP: true,
SignURLExpire: 1,
},
}
signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high", "192.0.2.1", time.Unix(1700000000, 0))
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
if got, want := parsed.Query().Get("token"), "HS256-sdrSSJE2JVwhSk2AoDUrmTV1muH6R5UHpZVcVfHeNxg"; got != want {
t.Fatalf("token = %q, want %q", got, want)
}
}
func TestSignCDNURLUsesDecodedPathForSHA256(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
SignURLExpire: 1,
},
}
signed, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/%E8%A7%86%E9%A2%91/%5Ba%20b%5D.mp4", "", time.Unix(1700000000, 0))
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
if got, want := parsed.Query().Get("token"), "yq1evD7klw0e3DjCbv8dJptbW4S4JwVW3GKLnxfeKGM"; got != want {
t.Fatalf("token = %q, want %q", got, want)
}
}
func TestSignCDNURLTreatsPlusAsLiteralPathCharacter(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
SignURLExpire: 1,
},
}
now := time.Unix(1700000000, 0)
literal, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a+b.mp4", "", now)
if err != nil {
t.Fatal(err)
}
encoded, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a%2Bb.mp4", "", now)
if err != nil {
t.Fatal(err)
}
space, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/a%20b.mp4", "", now)
if err != nil {
t.Fatal(err)
}
literalURL, err := url.Parse(literal)
if err != nil {
t.Fatal(err)
}
encodedURL, err := url.Parse(encoded)
if err != nil {
t.Fatal(err)
}
spaceURL, err := url.Parse(space)
if err != nil {
t.Fatal(err)
}
if got, want := literalURL.Query().Get("token"), encodedURL.Query().Get("token"); got != want {
t.Fatalf("literal plus token = %q, encoded plus token = %q", got, want)
}
if got, notWant := literalURL.Query().Get("token"), spaceURL.Query().Get("token"); got == notWant {
t.Fatalf("literal plus token = %q, space token should differ", got)
}
}
func TestSignCDNURLRejectsDuplicateQueryParameters(t *testing.T) {
driver := &BunnyStorage{
Addition: Addition{
CDNTokenKey: "secret",
SignURLExpire: 1,
},
}
_, _, err := driver.signCDNURLAt("https://zone.b-cdn.net/video.mp4?quality=high&quality=low", "", time.Unix(1700000000, 0))
if err == nil {
t.Fatal("expected duplicate query parameters to be rejected")
}
if got, want := err.Error(), `duplicate query parameter "quality" is not supported`; got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
func TestParseBunnyTimeSupportsFractionalSecondsWithoutTimezone(t *testing.T) {
fallback := time.Unix(1, 0)
got := parseBunnyTime("2023-03-21T13:38:31.693", fallback)
want := time.Date(2023, 3, 21, 13, 38, 31, 693000000, time.UTC)
if !got.Equal(want) {
t.Fatalf("parsed time = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano))
}
}
func TestConfigProxyMode(t *testing.T) {
registeredConfig := (&BunnyStorage{}).Config()
if registeredConfig.OnlyProxy {
t.Fatal("driver registration config should allow users to choose proxy policy")
}
withoutCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone"}}).Config()
if !withoutCDN.OnlyProxy {
t.Fatal("storage API links require AccessKey headers and should be proxied without CDN")
}
withCDN := (&BunnyStorage{Addition: Addition{StorageZoneName: "my-zone", CDNBaseURL: "https://zone.b-cdn.net"}}).Config()
if withCDN.OnlyProxy {
t.Fatal("CDN links should be allowed to redirect directly")
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ func (d *Crypt) Link(ctx context.Context, file model.Obj, _ model.LinkArgs) (*mo
rrf, err := stream.GetRangeReaderFromLink(remoteSize, remoteLink)
if err != nil {
_ = remoteLink.Close()
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encryption")
return nil, fmt.Errorf("the remote storage driver need to be enhanced to support encrytion")
}
mu := &sync.Mutex{}
+2 -2
View File
@@ -56,8 +56,8 @@ func (d *Emby) Init(ctx context.Context) error {
return nil
}
if strings.TrimSpace(d.Username) == "" {
return fmt.Errorf("please provide api_key+user_id or username(+password)")
if strings.TrimSpace(d.Username) == "" || strings.TrimSpace(d.Password) == "" {
return fmt.Errorf("please provide api_key+user_id or username+password")
}
if err := d.login(ctx); err != nil {
+7 -9
View File
@@ -154,17 +154,15 @@ func (d *Github) List(ctx context.Context, dir model.Obj, args model.ListArgs) (
}
}
return ret, nil
}
ret := make([]model.Obj, 0, len(obj.Entries))
for _, entry := range obj.Entries {
if entry.Name == ".gitkeep" {
continue
} else {
ret := make([]model.Obj, 0, len(obj.Entries))
for _, entry := range obj.Entries {
if entry.Name != ".gitkeep" {
ret = append(ret, entry.toModelObj())
}
}
ret = append(ret, entry.toModelObj())
return ret, nil
}
d.fetchAccurateModifiedTimes(ctx, dir.GetPath(), ret)
return ret, nil
}
func (d *Github) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
+17 -18
View File
@@ -7,24 +7,23 @@ import (
type Addition struct {
driver.RootPath
Token string `json:"token" type:"string" required:"true"`
Owner string `json:"owner" type:"string" required:"true"`
Repo string `json:"repo" type:"string" required:"true"`
Ref string `json:"ref" type:"string" help:"A branch, a tag or a commit SHA, main branch by default."`
AccurateModifiedTime bool `json:"accurate_modified_time" type:"bool" default:"false" help:"Best-effort accurate modified time for small directory listings. Default disabled. Adds one GitHub GraphQL request and falls back to legacy zero-time values on failure."`
GitHubProxy string `json:"gh_proxy" type:"string" help:"GitHub proxy, e.g. https://ghproxy.net/raw.githubusercontent.com or https://gh-proxy.com/raw.githubusercontent.com"`
GPGPrivateKey string `json:"gpg_private_key" type:"text"`
GPGKeyPassphrase string `json:"gpg_key_passphrase" type:"string"`
CommitterName string `json:"committer_name" type:"string"`
CommitterEmail string `json:"committer_email" type:"string"`
AuthorName string `json:"author_name" type:"string"`
AuthorEmail string `json:"author_email" type:"string"`
MkdirCommitMsg string `json:"mkdir_commit_message" type:"text" default:"{{.UserName}} mkdir {{.ObjPath}}"`
DeleteCommitMsg string `json:"delete_commit_message" type:"text" default:"{{.UserName}} remove {{.ObjPath}}"`
PutCommitMsg string `json:"put_commit_message" type:"text" default:"{{.UserName}} upload {{.ObjPath}}"`
RenameCommitMsg string `json:"rename_commit_message" type:"text" default:"{{.UserName}} rename {{.ObjPath}} to {{.TargetName}}"`
CopyCommitMsg string `json:"copy_commit_message" type:"text" default:"{{.UserName}} copy {{.ObjPath}} to {{.TargetPath}}"`
MoveCommitMsg string `json:"move_commit_message" type:"text" default:"{{.UserName}} move {{.ObjPath}} to {{.TargetPath}}"`
Token string `json:"token" type:"string" required:"true"`
Owner string `json:"owner" type:"string" required:"true"`
Repo string `json:"repo" type:"string" required:"true"`
Ref string `json:"ref" type:"string" help:"A branch, a tag or a commit SHA, main branch by default."`
GitHubProxy string `json:"gh_proxy" type:"string" help:"GitHub proxy, e.g. https://ghproxy.net/raw.githubusercontent.com or https://gh-proxy.com/raw.githubusercontent.com"`
GPGPrivateKey string `json:"gpg_private_key" type:"text"`
GPGKeyPassphrase string `json:"gpg_key_passphrase" type:"string"`
CommitterName string `json:"committer_name" type:"string"`
CommitterEmail string `json:"committer_email" type:"string"`
AuthorName string `json:"author_name" type:"string"`
AuthorEmail string `json:"author_email" type:"string"`
MkdirCommitMsg string `json:"mkdir_commit_message" type:"text" default:"{{.UserName}} mkdir {{.ObjPath}}"`
DeleteCommitMsg string `json:"delete_commit_message" type:"text" default:"{{.UserName}} remove {{.ObjPath}}"`
PutCommitMsg string `json:"put_commit_message" type:"text" default:"{{.UserName}} upload {{.ObjPath}}"`
RenameCommitMsg string `json:"rename_commit_message" type:"text" default:"{{.UserName}} rename {{.ObjPath}} to {{.TargetName}}"`
CopyCommitMsg string `json:"copy_commit_message" type:"text" default:"{{.UserName}} copy {{.ObjPath}} to {{.TargetPath}}"`
MoveCommitMsg string `json:"move_commit_message" type:"text" default:"{{.UserName}} move {{.ObjPath}} to {{.TargetPath}}"`
}
var config = driver.Config{
-106
View File
@@ -1,106 +0,0 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
log "github.com/sirupsen/logrus"
)
const (
mtimeMaxEntries = 200
githubGraphQLEndpoint = "https://api.github.com/graphql"
)
var githubZeroTime = time.Unix(0, 0)
type graphQLHistory struct {
Nodes []struct {
CommittedDate time.Time `json:"committedDate"`
} `json:"nodes"`
}
type graphQLMtimeResponse struct {
Data struct {
Repository struct {
Commit map[string]graphQLHistory `json:"commit"`
} `json:"repository"`
} `json:"data"`
Errors []struct{} `json:"errors"`
}
func quoteGraphQLString(value string) string {
quoted, _ := json.Marshal(value)
return string(quoted)
}
func buildMtimeQuery(owner, repo, ref string, objs []model.Obj) string {
histories := make([]string, 0, len(objs))
for i, obj := range objs {
path := strings.TrimPrefix(obj.GetPath(), "/")
histories = append(histories, fmt.Sprintf(`p%d: history(first: 1, path: %s) { nodes { committedDate } }`, i, quoteGraphQLString(path)))
}
return fmt.Sprintf(`query {
repository(owner: %s, name: %s) {
commit: object(expression: %s) {
... on Commit {
%s
}
}
}
}`,
quoteGraphQLString(owner),
quoteGraphQLString(repo),
quoteGraphQLString(ref+"^{commit}"),
strings.Join(histories, "\n\t\t\t\t"),
)
}
func (d *Github) fetchAccurateModifiedTimes(ctx context.Context, dirPath string, objs []model.Obj) {
token := strings.TrimSpace(d.Token)
if !d.AccurateModifiedTime || token == "" || len(objs) == 0 || len(objs) > mtimeMaxEntries {
return
}
res, err := d.client.R().
SetContext(ctx).
SetHeader("Accept", "application/vnd.github+json").
SetHeader("Authorization", "Bearer "+token).
SetBody(map[string]string{"query": buildMtimeQuery(d.Owner, d.Repo, d.Ref, objs)}).
Post(githubGraphQLEndpoint)
if err != nil {
log.WithError(err).Warnf("github accurate mtime failed for %s: transport", dirPath)
return
}
if res.StatusCode() != http.StatusOK {
log.Warnf("github accurate mtime failed for %s: http_%d", dirPath, res.StatusCode())
return
}
var response graphQLMtimeResponse
if err := utils.Json.Unmarshal(res.Body(), &response); err != nil {
log.WithError(err).Warnf("github accurate mtime failed for %s: graphql", dirPath)
return
}
if len(response.Errors) > 0 || response.Data.Repository.Commit == nil {
log.Warnf("github accurate mtime failed for %s: graphql", dirPath)
return
}
for i, obj := range objs {
history := response.Data.Repository.Commit[fmt.Sprintf("p%d", i)]
if len(history.Nodes) == 0 {
continue
}
if raw, ok := obj.(*model.Object); ok {
raw.Modified = history.Nodes[0].CommittedDate
}
}
}
-378
View File
@@ -1,378 +0,0 @@
package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
odriver "github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/go-resty/resty/v2"
)
func TestDriverInfoIncludesAccurateModifiedTimeDefault(t *testing.T) {
info := op.GetDriverInfoMap()["GitHub API"]
for _, item := range info.Additional {
if item.Name != "accurate_modified_time" {
continue
}
if item.Default != "false" {
t.Fatalf("unexpected default: %q", item.Default)
}
return
}
t.Fatal("accurate_modified_time item not registered")
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func newGithubTestDriver(rt roundTripFunc, token string, enabled bool) *Github {
return &Github{
Storage: model.Storage{MountPath: "/github-test", CacheExpiration: 10},
Addition: Addition{
RootPath: odriver.RootPath{RootFolderPath: "/"},
Token: token,
Owner: "owner",
Repo: "repo",
Ref: "main",
AccurateModifiedTime: enabled,
},
client: resty.New().SetTransport(rt),
}
}
func newJSONResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json: %v", err)
}
return string(data)
}
func graphQLQueryFromRequest(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read graphql request body: %v", err)
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode graphql request body: %v", err)
}
query := payload["query"]
if query == "" {
t.Fatalf("graphql request missing query: %s", string(body))
}
return query
}
func newContentsPayload(t *testing.T, entries []Object) string {
t.Helper()
return mustJSON(t, map[string]any{
"type": "dir",
"sha": "tree-sha",
"entries": entries,
})
}
func newTreePayload(t *testing.T, sha string, trees []TreeObjResp) string {
t.Helper()
return mustJSON(t, map[string]any{
"sha": sha,
"truncated": false,
"tree": trees,
})
}
func newCommitGraphQLPayload(t *testing.T, histories map[string][]string) string {
t.Helper()
commit := make(map[string]any, len(histories))
for alias, dates := range histories {
nodes := make([]map[string]string, 0, len(dates))
for _, date := range dates {
nodes = append(nodes, map[string]string{"committedDate": date})
}
commit[alias] = map[string]any{"nodes": nodes}
}
return mustJSON(t, map[string]any{
"data": map[string]any{
"repository": map[string]any{"commit": commit},
},
})
}
func newSequentialEntries(count int) []Object {
entries := make([]Object, 0, count)
for i := range count {
name := fmt.Sprintf("%03d.md", i)
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "file", Size: 1})
}
return entries
}
func mustObject(t *testing.T, obj model.Obj) *model.Object {
t.Helper()
raw, ok := model.UnwrapObjName(obj).(*model.Object)
if !ok {
t.Fatalf("unexpected obj type %T", obj)
}
return raw
}
func TestListAppliesAccurateModifiedTimeInOneRequest(t *testing.T) {
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
entries := []Object{
{Name: "a.md", Path: "docs/a.md", Type: "file", Size: 1},
{Name: ".gitkeep", Path: "docs/.gitkeep", Type: "file"},
{Name: `quote " 文.md`, Path: `docs/quote " 文.md`, Type: "file", Size: 1},
{Name: "control.md", Path: "docs/control\x01.md", Type: "file", Size: 1},
}
graphqlCalls := 0
var query string
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
query = graphQLQueryFromRequest(t, r)
if got := r.Header.Get("Authorization"); got != "Bearer token" {
t.Fatalf("unexpected authorization header: %q", got)
}
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
"p0": {stamp.Format(time.RFC3339)},
"p1": {},
"p2": {},
})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
}
if strings.Count(query, "history(first: 1") != 3 ||
!strings.Contains(query, `object(expression: "main^{commit}")`) ||
!strings.Contains(query, `p0: history(first: 1, path: "docs/a.md")`) ||
!strings.Contains(query, `p1: history(first: 1, path: "docs/quote \" 文.md")`) ||
!strings.Contains(query, `p2: history(first: 1, path: "docs/control\u0001.md")`) {
t.Fatalf("query should peel the ref and contain all listed paths once:\n%s", query)
}
if len(objs) != 3 {
t.Fatalf("expected three objects after .gitkeep filtering, got %d", len(objs))
}
if first := mustObject(t, objs[0]); !first.ModTime().Equal(stamp) || !first.CreateTime().Equal(githubZeroTime) {
t.Fatalf("unexpected first timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
}
if second := mustObject(t, objs[1]); !second.ModTime().Equal(githubZeroTime) || !second.CreateTime().Equal(githubZeroTime) {
t.Fatalf("unmatched entry should retain legacy timestamps: mod=%v create=%v", second.ModTime(), second.CreateTime())
}
}
func TestListSkipsAccurateModifiedTime(t *testing.T) {
tests := []struct {
name string
enabled bool
token string
entries int
}{
{name: "disabled", enabled: false, token: "token", entries: 1},
{name: "missing token", enabled: true, token: "", entries: 1},
{name: "over entry limit", enabled: true, token: "token", entries: 201},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
graphqlCalls := 0
payload := newContentsPayload(t, newSequentialEntries(tc.entries))
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, payload), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), tc.token, tc.enabled)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 0 {
t.Fatalf("expected zero GraphQL requests, got %d", graphqlCalls)
}
for _, obj := range objs {
raw := mustObject(t, obj)
if !raw.ModTime().Equal(githubZeroTime) || !raw.CreateTime().Equal(githubZeroTime) {
t.Fatalf("legacy timestamps should be preserved: mod=%v create=%v", raw.ModTime(), raw.CreateTime())
}
}
})
}
}
func TestListFallsBackWhenGraphQLFails(t *testing.T) {
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(1))), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, `{"errors":[{"message":"rate limited"}]}`), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("GraphQL failure should be best-effort: %v", err)
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL request, got %d", graphqlCalls)
}
obj := mustObject(t, objs[0])
if !obj.ModTime().Equal(githubZeroTime) || !obj.CreateTime().Equal(githubZeroTime) {
t.Fatalf("failed GraphQL request should retain legacy timestamps: mod=%v create=%v", obj.ModTime(), obj.CreateTime())
}
}
func TestListUsesOneGraphQLRequestAtEntryLimit(t *testing.T) {
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
graphqlCalls := 0
var query string
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, newSequentialEntries(200))), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
query = graphQLQueryFromRequest(t, r)
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{
"p199": {stamp.Format(time.RFC3339)},
})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if graphqlCalls != 1 || strings.Count(query, "history(first: 1") != 200 {
t.Fatalf("200 entries should share one request: calls=%d histories=%d", graphqlCalls, strings.Count(query, "history(first: 1"))
}
if !strings.Contains(query, `p199: history(first: 1, path: "docs/199.md")`) {
t.Fatalf("query missing final entry:\n%s", query)
}
if len(objs) != 200 || !mustObject(t, objs[199]).ModTime().Equal(stamp) {
t.Fatalf("unexpected final object timestamp")
}
}
func TestListKeepsTreeFallbackOnLegacyPath(t *testing.T) {
entries := make([]Object, 0, 1000)
for i := range 1000 {
name := fmt.Sprintf("dir-%d", i)
entries = append(entries, Object{Name: name, Path: "docs/" + name, Type: "dir"})
}
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, entries)), nil
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/git/trees/"):
return newJSONResponse(http.StatusOK, newTreePayload(t, "tree-sha", []TreeObjResp{{TreeObjReq: TreeObjReq{Path: "child.md", Mode: "100644", Type: "blob", Sha: "blob-sha"}, Size: 1, URL: "https://example.invalid/blob"}})), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, nil)), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
objs, err := drv.List(context.Background(), &model.Object{Path: "/docs", Name: "docs", IsFolder: true}, model.ListArgs{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("unexpected tree fallback result length: %d", len(objs))
}
first := mustObject(t, objs[0])
if first.GetPath() != "/child.md" {
t.Fatalf("unexpected tree fallback path: %s", first.GetPath())
}
if !first.ModTime().Equal(githubZeroTime) || !first.CreateTime().Equal(githubZeroTime) {
t.Fatalf("tree fallback should preserve legacy timestamps: mod=%v create=%v", first.ModTime(), first.CreateTime())
}
if graphqlCalls != 0 {
t.Fatalf("tree fallback should skip GraphQL, got %d calls", graphqlCalls)
}
}
func TestOpListCacheHitDoesNotRepeatGraphQL(t *testing.T) {
op.Cache.ClearAll()
defer op.Cache.ClearAll()
stamp := time.Date(2025, 12, 22, 4, 52, 41, 0, time.UTC)
graphqlCalls := 0
drv := newGithubTestDriver(roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/contents/"):
return newJSONResponse(http.StatusOK, newContentsPayload(t, []Object{{Name: "a.md", Path: "a.md", Type: "file", Size: 1}})), nil
case r.Method == http.MethodPost && r.URL.String() == githubGraphQLEndpoint:
graphqlCalls++
return newJSONResponse(http.StatusOK, newCommitGraphQLPayload(t, map[string][]string{"p0": {stamp.Format(time.RFC3339)}})), nil
default:
return nil, fmt.Errorf("unexpected request %s %s", r.Method, r.URL.String())
}
}), "token", true)
first, err := op.List(context.Background(), drv, "/", model.ListArgs{})
if err != nil {
t.Fatalf("unexpected first list error: %v", err)
}
second, err := op.List(context.Background(), drv, "/", model.ListArgs{})
if err != nil {
t.Fatalf("unexpected second list error: %v", err)
}
if len(first) != 1 || len(second) != 1 {
t.Fatalf("unexpected cached results: first=%d second=%d", len(first), len(second))
}
if graphqlCalls != 1 {
t.Fatalf("expected one GraphQL call across cached lists, got %d", graphqlCalls)
}
if !mustObject(t, first[0]).ModTime().Equal(stamp) {
t.Fatalf("expected first list to include backfilled modified time, got %v", mustObject(t, first[0]).ModTime())
}
if !mustObject(t, second[0]).ModTime().Equal(stamp) {
t.Fatalf("expected cached list to retain modified time, got %v", mustObject(t, second[0]).ModTime())
}
}
+4 -4
View File
@@ -1,6 +1,8 @@
package github
import (
"time"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
@@ -33,8 +35,7 @@ func (o *Object) toModelObj() *model.Object {
return &model.Object{
Name: o.Name,
Size: o.Size,
Modified: githubZeroTime,
Ctime: githubZeroTime,
Modified: time.Unix(0, 0),
IsFolder: o.Type == "dir",
Path: utils.FixAndCleanPath(o.Path),
}
@@ -68,8 +69,7 @@ func (o *TreeObjResp) toModelObj() *model.Object {
return &model.Object{
Name: o.Path,
Size: o.Size,
Modified: githubZeroTime,
Ctime: githubZeroTime,
Modified: time.Unix(0, 0),
IsFolder: o.Type == "tree",
Path: utils.FixAndCleanPath(o.Path),
}
-792
View File
@@ -1,792 +0,0 @@
package guangyapan
import (
"context"
"errors"
"fmt"
"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/aliyun/aliyun-oss-go-sdk/oss"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
)
const (
accountBaseURL = "https://account.guangyapan.com"
apiBaseURL = "https://api.guangyapan.com"
)
type GuangYaPan struct {
model.Storage
Addition
accountClient *resty.Client
apiClient *resty.Client
resolvedRootFolderID string
rootFolderResolved bool
// refreshMu protects concurrent access to AccessToken/RefreshToken during refresh.
refreshMu sync.Mutex
// statusTimer tracks delayed status updates for cancellation on Drop.
statusTimer *time.Timer
// apiRateLimit throttles requests per API endpoint so that batch operations
// (e.g. copying many files cross-storage) don't flood the upstream API.
apiRateLimit sync.Map
}
// apiRateInterval is the minimum gap between two requests to the same endpoint.
const apiRateInterval = 500 * time.Millisecond
func (d *GuangYaPan) Config() driver.Config {
return config
}
func (d *GuangYaPan) GetAddition() driver.Additional {
return &d.Addition
}
func (d *GuangYaPan) Init(ctx context.Context) error {
d.ClientID = strings.TrimSpace(d.ClientID)
if d.ClientID == "" {
return errors.New("client_id is required, please provide a valid client_id")
}
d.DeviceID = normalizeDeviceID(d.DeviceID)
if d.DeviceID == "" {
d.DeviceID = randomDeviceID()
}
deviceSign := strings.TrimSpace(d.DeviceSign)
if deviceSign == "" {
deviceSign = "wdi10." + d.DeviceID
}
if d.PageSize <= 0 {
d.PageSize = 100
}
if d.OrderBy < 0 {
d.OrderBy = 3
}
if d.SortType != 0 && d.SortType != 1 {
d.SortType = 1
}
d.RootPath = strings.TrimSpace(d.RootPath)
d.AccessToken = strings.TrimSpace(d.AccessToken)
d.RefreshToken = strings.TrimSpace(d.RefreshToken)
d.PhoneNumber = strings.TrimSpace(d.PhoneNumber)
d.VerifyCode = strings.TrimSpace(d.VerifyCode)
d.CaptchaToken = strings.TrimSpace(d.CaptchaToken)
d.VerificationID = strings.TrimSpace(d.VerificationID)
d.resolvedRootFolderID = ""
d.rootFolderResolved = false
d.accountClient = base.NewRestyClient().
SetBaseURL(accountBaseURL).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("X-Device-Model", "chrome%2F147.0.0.0").
SetHeader("X-Device-Name", "PC-Chrome").
SetHeader("X-Device-Sign", deviceSign).
SetHeader("X-Net-Work-Type", "NONE").
SetHeader("X-OS-Version", "MacIntel").
SetHeader("X-Platform-Version", "1").
SetHeader("X-Protocol-Version", "301").
SetHeader("X-Provider-Name", "NONE").
SetHeader("X-SDK-Version", "9.0.2").
SetHeader("X-Client-Id", d.ClientID).
SetHeader("X-Client-Version", "0.0.1").
SetHeader("X-Device-Id", d.DeviceID)
d.apiClient = base.NewRestyClient().
SetBaseURL(apiBaseURL).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("Did", d.DeviceID).
SetHeader("Dt", "4")
// Priority: access_token -> refresh_token -> sms login.
if d.AccessToken != "" {
if err := d.validateToken(ctx); err == nil {
return d.prepareRootFolder(ctx)
}
d.AccessToken = ""
}
if d.RefreshToken != "" {
if err := d.refreshToken(ctx); err == nil {
if err2 := d.validateToken(ctx); err2 == nil {
return d.prepareRootFolder(ctx)
}
}
}
// Two-stage SMS flow:
// 1) phone only + send_code=true: send code and cache verification_id (do not fail init).
// 2) phone + verify_code: complete login and save tokens.
if d.PhoneNumber != "" {
if d.canSMSLogin() {
if err := d.loginBySMSCode(ctx); err != nil {
return err
}
if err := d.validateToken(ctx); err != nil {
return err
}
return d.prepareRootFolder(ctx)
}
if d.SendCode {
d.setTempStatus("SMS sending in progress...")
if err := d.prepareSMSCode(ctx); err != nil {
d.setTempStatus(fmt.Sprintf("SMS send failed: %v. Please check captcha/meta and set send_code=true to retry.", err))
log.Warnf("guangyapan: prepare sms code failed: %v", err)
} else {
d.setTempStatus("SMS sent successfully. Please fill verify_code and save to complete login.")
}
}
return nil
}
return errors.New("login failed: provide a valid access_token, or refresh_token, or phone_number + verify_code + captcha_token")
}
func (d *GuangYaPan) Drop(ctx context.Context) error {
if d.statusTimer != nil {
d.statusTimer.Stop()
}
return nil
}
func (d *GuangYaPan) GetRoot(ctx context.Context) (model.Obj, error) {
rootID, err := d.getRootFolderID(ctx)
if err != nil {
return nil, err
}
return &model.Object{
ID: rootID,
Path: "/",
Name: "root",
Size: 0,
Modified: d.Modified,
IsFolder: true,
}, nil
}
func (d *GuangYaPan) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
parentID := dir.GetID()
const maxPage = 10000
res := make([]model.Obj, 0, d.PageSize)
for page := 0; page < maxPage; page++ {
var resp listResp
body := map[string]any{
"parentId": parentID,
"page": page,
"pageSize": d.PageSize,
"orderBy": d.OrderBy,
"sortType": d.SortType,
}
if err := d.postAPI(ctx, "/userres/v1/file/get_file_list", body, &resp); err != nil {
return nil, err
}
for _, item := range resp.Data.List {
res = append(res, &model.Object{
ID: item.FileID,
Path: parentID,
Name: item.FileName,
Size: item.FileSize,
Modified: unixOrZero(item.UTime),
Ctime: unixOrZero(item.CTime),
IsFolder: item.ResType == 2,
})
}
if len(resp.Data.List) < d.PageSize {
break
}
if resp.Data.Total > 0 && len(res) >= resp.Data.Total {
break
}
}
return res, nil
}
func (d *GuangYaPan) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
if file.IsDir() {
return nil, errs.NotFile
}
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
var resp downloadResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_download_url", map[string]any{
"fileId": file.GetID(),
}, &resp); err != nil {
return nil, err
}
url := strings.TrimSpace(resp.Data.SignedURL)
if url == "" {
url = strings.TrimSpace(resp.Data.DownloadURL)
}
if url == "" {
return nil, errors.New("empty download url")
}
return &model.Link{URL: url}, nil
}
func (d *GuangYaPan) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
name := strings.TrimSpace(dirName)
if name == "" {
return errors.New("dir name is empty")
}
parentID := parentDir.GetID()
var out createDirResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/create_dir", map[string]any{
"parentId": parentID,
"dirName": name,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("make dir failed: %s", strings.TrimSpace(out.Msg))
}
return nil
}
func (d *GuangYaPan) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(srcObj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
name := strings.TrimSpace(newName)
if name == "" {
return errors.New("new name is empty")
}
var out commonResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/rename", map[string]any{
"fileId": fileID,
"newName": name,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("rename failed: %s", strings.TrimSpace(out.Msg))
}
return nil
}
func (d *GuangYaPan) Remove(ctx context.Context, obj model.Obj) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(obj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
var del taskResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/delete_file", map[string]any{
"fileIds": []string{fileID},
}, &del); err != nil {
return err
}
if !isSuccessMsg(del.Msg) {
return fmt.Errorf("delete failed: %s", strings.TrimSpace(del.Msg))
}
taskID := strings.TrimSpace(del.Data.TaskID)
if taskID == "" {
// Some backends may apply deletion synchronously.
return nil
}
return d.waitTaskDone(ctx, taskID)
}
func (d *GuangYaPan) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(srcObj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
parentID := dstDir.GetID()
var out taskResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/move_file", map[string]any{
"fileIds": []string{fileID},
"parentId": parentID,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("move failed: %s", strings.TrimSpace(out.Msg))
}
taskID := strings.TrimSpace(out.Data.TaskID)
if taskID == "" {
return nil
}
return d.waitTaskDone(ctx, taskID)
}
func (d *GuangYaPan) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
fileID := strings.TrimSpace(srcObj.GetID())
if fileID == "" {
return errors.New("file id is empty")
}
parentID := dstDir.GetID()
var out taskResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/copy_file", map[string]any{
"fileIds": []string{fileID},
"parentId": parentID,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("copy failed: %s", strings.TrimSpace(out.Msg))
}
taskID := strings.TrimSpace(out.Data.TaskID)
if taskID == "" {
return nil
}
return d.waitTaskDone(ctx, taskID)
}
func (d *GuangYaPan) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
if file == nil {
return nil, errors.New("file is nil")
}
if file.GetSize() < 0 {
return nil, errors.New("invalid file size")
}
name := strings.TrimSpace(file.GetName())
if name == "" {
return nil, errors.New("file name is empty")
}
parentID := dstDir.GetID()
// 优先秒传:先计算文件 MD5,后端命中相同文件时直接秒传完成,无需真实上传。
md5sum, err := d.fileMD5(file, up)
if err != nil {
return nil, err
}
token, code, err := d.getUploadToken(ctx, parentID, name, file.GetSize(), md5sum)
if err != nil {
return nil, err
}
taskID := strings.TrimSpace(token.TaskID)
// code == 156 (instant upload) or AlreadyDone mean the backend has already
// finished/imported the file; there is no OSS upload to perform.
if code == 156 || token.AlreadyDone {
if taskID == "" {
return nil, errors.New("instant upload returns empty task id")
}
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
return nil, err
}
return nil, nil
}
if token.ObjectPath == "" || token.BucketName == "" || token.EndPoint == "" || token.AccessKeyID == "" || token.SecretAccessKey == "" {
return nil, errors.New("upload token is incomplete")
}
ossEndpoint := normalizeOSSEndpoint(token.EndPoint, token.BucketName)
client, err := oss.New(ossEndpoint, token.AccessKeyID, token.SecretAccessKey, oss.SecurityToken(token.SessionToken))
if err != nil {
return nil, fmt.Errorf("create oss client failed: %w", err)
}
bucket, err := client.Bucket(token.BucketName)
if err != nil {
return nil, fmt.Errorf("create oss bucket failed: %w", err)
}
if file.GetSize() == 0 {
if err := bucket.PutObject(token.ObjectPath, strings.NewReader("")); err != nil {
return nil, err
}
} else {
if err := d.multipartUploadToOSS(ctx, bucket, token.ObjectPath, file, up); err != nil {
return nil, err
}
}
if taskID == "" {
return nil, nil
}
if err := d.waitUploadTaskInfo(ctx, taskID); err != nil {
return nil, err
}
return nil, nil
}
func (d *GuangYaPan) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
var resp assetsInfoResp
if err := d.postAPI(ctx, "/nd.bizassets.s/v1/get_assets", nil, &resp); err != nil {
return nil, err
}
if resp.IsSuccess() && resp.Data.TotalSpaceSize > 0 {
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: resp.Data.TotalSpaceSize,
UsedSpace: resp.Data.UsedSpaceSize,
},
}, nil
}
return nil, errors.New("failed to get storage details")
}
func (d *GuangYaPan) getRootFolderID(ctx context.Context) (string, error) {
if d.rootFolderResolved {
return d.resolvedRootFolderID, nil
}
if err := d.ensureAccessToken(ctx); err != nil {
return "", err
}
if err := d.prepareRootFolder(ctx); err != nil {
return "", err
}
return d.resolvedRootFolderID, nil
}
func (d *GuangYaPan) prepareRootFolder(ctx context.Context) error {
rootID, err := d.resolveConfiguredRootFolderID(ctx)
if err != nil {
return err
}
d.resolvedRootFolderID = rootID
d.rootFolderResolved = true
return nil
}
func (d *GuangYaPan) resolveConfiguredRootFolderID(ctx context.Context) (string, error) {
root := strings.TrimSpace(d.RootPath)
if root == "" {
return "", nil
}
return d.resolveFolderPath(ctx, root)
}
func (d *GuangYaPan) resolveFolderPath(ctx context.Context, rootPath string) (string, error) {
cleanPath := strings.Trim(strings.ReplaceAll(strings.TrimSpace(rootPath), "\\", "/"), "/")
if cleanPath == "" {
return "", nil
}
parentID := ""
for _, name := range strings.Split(cleanPath, "/") {
if name == "" {
continue
}
childID, err := d.findChildFolderID(ctx, parentID, name)
if err != nil {
return "", err
}
parentID = childID
}
return parentID, nil
}
func (d *GuangYaPan) findChildFolderID(ctx context.Context, parentID, name string) (string, error) {
pageSize := d.PageSize
if pageSize <= 0 {
pageSize = 100
}
const maxPage = 10000
seen := 0
for page := 0; page < maxPage; page++ {
var resp listResp
body := map[string]any{
"parentId": parentID,
"page": page,
"pageSize": pageSize,
"orderBy": d.OrderBy,
"sortType": d.SortType,
}
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_file_list", body, &resp); err != nil {
return "", err
}
for _, item := range resp.Data.List {
seen++
if item.ResType == 2 && item.FileName == name {
return item.FileID, nil
}
}
if len(resp.Data.List) < pageSize {
break
}
if resp.Data.Total > 0 && seen >= resp.Data.Total {
break
}
}
if parentID == "" {
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under /", name)
}
return "", fmt.Errorf("resolve root folder path failed: folder %q not found under parent %s", name, parentID)
}
func (d *GuangYaPan) ensureAccessToken(ctx context.Context) error {
if strings.TrimSpace(d.AccessToken) != "" {
return nil
}
if strings.TrimSpace(d.RefreshToken) == "" {
return errors.New("not logged in, please re-init storage")
}
return d.refreshToken(ctx)
}
func (d *GuangYaPan) validateToken(ctx context.Context) error {
var me userMeResp
resp, err := d.accountClient.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.AccessToken).
SetResult(&me).
Get("/v1/user/me")
if err != nil {
return err
}
if resp.IsError() {
return fmt.Errorf("validate token failed: status=%d body=%s", resp.StatusCode(), resp.String())
}
if strings.TrimSpace(me.Sub) == "" {
return errors.New("validate token failed: empty user sub")
}
return nil
}
func (d *GuangYaPan) refreshToken(ctx context.Context) error {
if strings.TrimSpace(d.RefreshToken) == "" {
return errors.New("refresh_token is empty")
}
d.refreshMu.Lock()
defer d.refreshMu.Unlock()
// Double-check after acquiring lock (may have been refreshed by another goroutine)
if strings.TrimSpace(d.AccessToken) != "" {
if err := d.validateToken(ctx); err == nil {
return nil
}
}
var out tokenResp
resp, err := d.accountClient.R().
SetContext(ctx).
SetBody(map[string]any{
"client_id": d.ClientID,
"grant_type": "refresh_token",
"refresh_token": d.RefreshToken,
}).
SetResult(&out).
Post("/v1/auth/token")
if err != nil {
return err
}
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
errMsg := strings.TrimSpace(out.ErrorDesc)
if errMsg == "" {
errMsg = strings.TrimSpace(out.Error)
}
if errMsg == "" {
errMsg = strings.TrimSpace(resp.String())
}
if errMsg == "" {
errMsg = fmt.Sprintf("status=%d", resp.StatusCode())
}
return fmt.Errorf("refresh token failed: %s", errMsg)
}
d.AccessToken = strings.TrimSpace(out.AccessToken)
if strings.TrimSpace(out.RefreshToken) != "" {
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
}
op.MustSaveDriverStorage(d)
return nil
}
func (d *GuangYaPan) canSMSLogin() bool {
return d.PhoneNumber != "" && d.VerifyCode != ""
}
func (d *GuangYaPan) loginBySMSCode(ctx context.Context) error {
verificationID := strings.TrimSpace(d.VerificationID)
if verificationID == "" {
var err error
verificationID, err = d.requestVerificationID(ctx)
if err != nil {
return err
}
}
var step2 verifyResp
resp, err := d.accountClient.R().
SetContext(ctx).
SetBody(map[string]any{
"verification_id": verificationID,
"verification_code": d.VerifyCode,
"client_id": d.ClientID,
}).
SetResult(&step2).
Post("/v1/auth/verification/verify")
if err != nil {
return err
}
if resp.IsError() || step2.Error != "" || strings.TrimSpace(step2.VerificationToken) == "" {
return fmt.Errorf("verify code failed: %s", d.accountErr(step2.ErrorDesc, step2.Error, resp))
}
var out tokenResp
resp, err = d.accountClient.R().
SetContext(ctx).
SetBody(map[string]any{
"verification_code": d.VerifyCode,
"verification_token": step2.VerificationToken,
"username": normalizePhoneE164(d.PhoneNumber),
"client_id": d.ClientID,
}).
SetResult(&out).
Post("/v1/auth/signin")
if err != nil {
return err
}
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.AccessToken) == "" {
return fmt.Errorf("signin failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
}
d.AccessToken = strings.TrimSpace(out.AccessToken)
d.RefreshToken = strings.TrimSpace(out.RefreshToken)
d.VerificationID = ""
// One-time SMS code should not be reused after successful login.
d.VerifyCode = ""
op.MustSaveDriverStorage(d)
return nil
}
func (d *GuangYaPan) prepareSMSCode(ctx context.Context) error {
// Explicit send action should always refresh verification_id.
d.VerificationID = ""
if err := d.ensureCaptchaToken(ctx, false); err != nil {
return err
}
verificationID, err := d.requestVerificationID(ctx)
if err != nil {
return err
}
d.VerificationID = verificationID
d.SendCode = false
op.MustSaveDriverStorage(d)
return nil
}
func (d *GuangYaPan) requestVerificationID(ctx context.Context) (string, error) {
req := d.accountClient.R().SetContext(ctx)
if d.CaptchaToken != "" {
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
}
var step1 verificationResp
resp, err := req.
SetBody(map[string]any{
"phone_number": normalizePhoneE164(d.PhoneNumber),
"target": "ANY",
"client_id": d.ClientID,
}).
SetResult(&step1).
Post("/v1/auth/verification")
if err != nil {
return "", err
}
if resp.IsError() || step1.Error != "" || strings.TrimSpace(step1.VerificationID) == "" {
// If captcha token is expired/invalid, refresh it once and retry.
if strings.Contains(step1.Error, "captcha_invalid") || strings.Contains(step1.ErrorDesc, "captcha_token expired") {
if err := d.ensureCaptchaToken(ctx, true); err == nil {
return d.requestVerificationID(ctx)
}
}
return "", fmt.Errorf("request verification failed: %s", d.accountErr(step1.ErrorDesc, step1.Error, resp))
}
return strings.TrimSpace(step1.VerificationID), nil
}
func (d *GuangYaPan) ensureCaptchaToken(ctx context.Context, force bool) error {
if !force && d.CaptchaToken != "" {
return nil
}
var out captchaInitResp
req := d.accountClient.R().SetContext(ctx)
if d.CaptchaToken != "" {
req.SetHeader("X-Captcha-Token", d.CaptchaToken)
}
resp, err := req.
SetBody(map[string]any{
"client_id": d.ClientID,
"action": "POST:/v1/auth/verification",
"device_id": d.DeviceID,
"meta": map[string]any{
"username": normalizePhoneE164(d.PhoneNumber),
"phone_number": normalizePhoneE164(d.PhoneNumber),
"VERIFICATION_PHONE": normalizePhoneE164(d.PhoneNumber),
},
}).
SetResult(&out).
Post("/v1/shield/captcha/init")
if err != nil {
return err
}
if resp.IsError() || out.Error != "" || strings.TrimSpace(out.CaptchaToken) == "" {
return fmt.Errorf("init captcha token failed: %s", d.accountErr(out.ErrorDesc, out.Error, resp))
}
d.CaptchaToken = strings.TrimSpace(out.CaptchaToken)
op.MustSaveDriverStorage(d)
return nil
}
// Interface compliance checks
var (
_ driver.Driver = (*GuangYaPan)(nil)
_ driver.GetRooter = (*GuangYaPan)(nil)
_ driver.Mkdir = (*GuangYaPan)(nil)
_ driver.Move = (*GuangYaPan)(nil)
_ driver.Copy = (*GuangYaPan)(nil)
_ driver.Rename = (*GuangYaPan)(nil)
_ driver.Remove = (*GuangYaPan)(nil)
_ driver.PutResult = (*GuangYaPan)(nil)
_ driver.WithDetails = (*GuangYaPan)(nil)
)
-37
View File
@@ -1,37 +0,0 @@
package guangyapan
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/op"
)
type Addition struct {
RootPath string `json:"root_path" help:"Full path in GuangYaPan cloud drive"`
PhoneNumber string `json:"phone_number" type:"text" help:"Phone number for SMS login, e.g. +86 13800000000"`
CaptchaToken string `json:"captcha_token" help:"Captcha token required by /v1/auth/verification"`
SendCode bool `json:"send_code" type:"bool" help:"Set true and save to send SMS code, it auto-resets to false after sending"`
VerifyCode string `json:"verify_code" type:"text" help:"SMS verification code used with phone_number; fill then save to finish login"`
VerificationID string `json:"verification_id" type:"text" help:"Auto-generated after sending SMS code; do not edit manually"`
AccessToken string `json:"access_token" help:"Bearer access token (optional if refresh_token is provided)"`
RefreshToken string `json:"refresh_token" help:"Refresh token for auto-login/auto-refresh"`
ClientID string `json:"client_id" required:"true" help:"Client ID for GuangYaPan API, must be provided"`
DeviceID string `json:"device_id" help:"Optional custom device id (32 hex chars), auto-generated when empty"`
DeviceSign string `json:"device_sign" help:"Optional custom X-Device-Sign header (generated from device_id when empty)"`
PageSize int `json:"page_size" type:"number" default:"100"`
OrderBy int `json:"order_by" type:"number" options:"0,1,2,3,4" default:"3" help:"Sort field used by the file list"`
SortType int `json:"sort_type" type:"number" options:"0,1" default:"1" help:"Sort direction used by the file list"`
}
var config = driver.Config{
Name: "GuangYaPan",
DefaultRoot: "",
CheckStatus: true,
Alert: "info|Two-stage SMS login: (1) fill phone_number (+ captcha_token if needed), set send_code=true and save; (2) fill verify_code and save to finish login and auto-save access_token/refresh_token.",
NoOverwriteUpload: true,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &GuangYaPan{}
})
}
-169
View File
@@ -1,169 +0,0 @@
package guangyapan
import (
"context"
"errors"
"fmt"
"net/url"
stdpath "path"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/model"
)
func (d *GuangYaPan) ResolveOfflineResource(ctx context.Context, fileURL string) (*OfflineResolveData, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
fileURL = strings.TrimSpace(fileURL)
if fileURL == "" {
return nil, errors.New("offline url is empty")
}
var resp offlineResolveResp
if err := d.postAPI(ctx, "/cloudcollection/v1/resolve_res", map[string]any{
"url": fileURL,
}, &resp); err != nil {
return nil, err
}
if !isSuccessMsg(resp.Msg) {
return nil, fmt.Errorf("resolve offline resource failed: %s", strings.TrimSpace(resp.Msg))
}
return &resp.Data, nil
}
func (d *GuangYaPan) OfflineDownload(ctx context.Context, fileURL string, parentDir model.Obj, fileName string) (*OfflineTask, error) {
resolved, err := d.ResolveOfflineResource(ctx, fileURL)
if err != nil {
return nil, err
}
parentID := parentDir.GetID()
rootID, err := d.getRootFolderID(ctx)
if err != nil {
return nil, err
}
if parentID == rootID {
parentID = ""
}
taskURL := strings.TrimSpace(resolved.URL)
if taskURL == "" {
taskURL = strings.TrimSpace(fileURL)
}
name := strings.TrimSpace(fileName)
if name == "" {
name = resolved.defaultName(taskURL)
}
body := map[string]any{
"url": taskURL,
"parentId": parentID,
"newName": name,
}
if indexes := resolved.fileIndexes(); len(indexes) > 0 {
body["fileIndexes"] = indexes
}
var resp offlineCreateResp
if err := d.postAPI(ctx, "/cloudcollection/v1/create_task", body, &resp); err != nil {
return nil, err
}
if !isSuccessMsg(resp.Msg) {
return nil, fmt.Errorf("create offline task failed: %s", strings.TrimSpace(resp.Msg))
}
taskID := strings.TrimSpace(resp.Data.TaskID)
if taskID == "" {
return nil, errors.New("create offline task failed: empty task id")
}
return &OfflineTask{
TaskID: taskID,
FileName: name,
Res: taskURL,
}, nil
}
func (d *GuangYaPan) OfflineList(ctx context.Context, taskIDs []string, statuses []int, cursor string, pageSize int) ([]OfflineTask, error) {
if err := d.ensureAccessToken(ctx); err != nil {
return nil, err
}
body := map[string]any{}
if len(taskIDs) > 0 {
body["taskIds"] = taskIDs
}
if len(statuses) > 0 {
body["status"] = statuses
}
if cursor = strings.TrimSpace(cursor); cursor != "" {
body["cursor"] = cursor
}
if pageSize > 0 {
body["pageSize"] = pageSize
}
var resp offlineListResp
if err := d.postAPI(ctx, "/cloudcollection/v1/list_task", body, &resp); err != nil {
return nil, err
}
if !isSuccessMsg(resp.Msg) {
return nil, fmt.Errorf("list offline tasks failed: %s", strings.TrimSpace(resp.Msg))
}
return resp.Data.List, nil
}
func (d *GuangYaPan) DeleteOfflineTasks(ctx context.Context, taskIDs []string) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
if len(taskIDs) == 0 {
return nil
}
var resp offlineDeleteResp
if err := d.postAPI(ctx, "/cloudcollection/v2/delete_task", map[string]any{
"taskIds": taskIDs,
}, &resp); err != nil {
return err
}
if !isSuccessMsg(resp.Msg) {
return fmt.Errorf("delete offline tasks failed: %s", strings.TrimSpace(resp.Msg))
}
return nil
}
func (d OfflineResolveData) defaultName(fileURL string) string {
if d.BTResInfo != nil && strings.TrimSpace(d.BTResInfo.FileName) != "" {
return strings.TrimSpace(d.BTResInfo.FileName)
}
u, err := url.Parse(fileURL)
if err == nil {
name := strings.TrimSpace(stdpath.Base(u.Path))
if name != "" && name != "." && name != "/" {
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
return name
}
}
return "offline_download"
}
func (d OfflineResolveData) fileIndexes() []int {
if d.BTResInfo == nil || len(d.BTResInfo.Subfiles) == 0 {
return nil
}
indexes := make([]int, 0, len(d.BTResInfo.Subfiles))
for i, file := range d.BTResInfo.Subfiles {
if file.FileIndex != nil {
indexes = append(indexes, *file.FileIndex)
continue
}
indexes = append(indexes, i)
}
return indexes
}
func isSuccessMsg(msg string) bool {
msg = strings.TrimSpace(msg)
return msg == "" || strings.EqualFold(msg, "success")
}
-232
View File
@@ -1,232 +0,0 @@
package guangyapan
import "time"
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Sub string `json:"sub"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type verificationResp struct {
VerificationID string `json:"verification_id"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type captchaInitResp struct {
CaptchaToken string `json:"captcha_token"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type verifyResp struct {
VerificationToken string `json:"verification_token"`
Error string `json:"error"`
ErrorCode int `json:"error_code"`
ErrorDesc string `json:"error_description"`
}
type userMeResp struct {
Sub string `json:"sub"`
}
type listResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Total int `json:"total"`
List []fileItem `json:"list"`
} `json:"data"`
}
type fileItem struct {
FileID string `json:"fileId"`
ParentID string `json:"parentId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
ResType int `json:"resType"`
CTime int64 `json:"ctime"`
UTime int64 `json:"utime"`
}
type downloadResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
SignedURL string `json:"signedURL"`
DownloadURL string `json:"downloadUrl"`
} `json:"data"`
}
type createDirResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
FileID string `json:"fileId"`
FileName string `json:"fileName"`
ResType int `json:"resType"`
CTime int64 `json:"ctime"`
UTime int64 `json:"utime"`
} `json:"data"`
}
type commonResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
// taskResp is used for async operations (delete, move, copy) that return a task ID.
type taskResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TaskID string `json:"taskId"`
} `json:"data"`
}
type taskStatusResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Status int `json:"status"`
} `json:"data"`
}
type uploadTokenResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data uploadTokenData `json:"data"`
}
type uploadTokenData struct {
TaskID string `json:"taskId"`
AlreadyDone bool `json:"-"`
ObjectPath string `json:"objectPath"`
Provider any `json:"provider"`
Region string `json:"region"`
BucketName string `json:"bucketName"`
EndPoint string `json:"endPoint"`
FullEndPoint string `json:"fullEndPoint"`
CallbackVar string `json:"callbackVar"`
AccessKeyID string `json:"accessKeyID"`
SecretAccessKey string `json:"secretAccessKey"`
SessionToken string `json:"sessionToken"`
Creds struct {
AccessKeyID string `json:"accessKeyID"`
SecretAccessKey string `json:"secretAccessKey"`
SessionToken string `json:"sessionToken"`
} `json:"creds"`
}
type taskInfoResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
FileID string `json:"fileId"`
} `json:"data"`
}
// assetsInfoResp is the response for storage details query.
type assetsInfoResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TotalSpaceSize int64 `json:"totalSpaceSize"`
UsedSpaceSize int64 `json:"usedSpaceSize"`
} `json:"data"`
}
func (r assetsInfoResp) IsSuccess() bool {
return isSuccessMsg(r.Msg)
}
// Offline download types
type offlineResolveResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data OfflineResolveData `json:"data"`
}
type OfflineResolveData struct {
ResType int `json:"resType"`
BTResInfo *OfflineBTResInfo `json:"btResInfo"`
URL string `json:"url"`
}
type OfflineBTResInfo struct {
InfoHash string `json:"infoHash"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
SubfilesNum int `json:"subfilesNum"`
Subfiles []OfflineSubfile `json:"subfiles"`
CreateTime int64 `json:"createTime"`
ExcludeIndices []int `json:"excludeIndices"`
}
type OfflineSubfile struct {
FileName string `json:"fileName"`
FileIndex *int `json:"fileIndex"`
FileSize int64 `json:"fileSize"`
}
type offlineCreateResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TaskID string `json:"taskId"`
URL string `json:"url"`
} `json:"data"`
}
type offlineDeleteResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
TaskIDs []string `json:"taskIds"`
} `json:"data"`
}
type offlineListResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
StatusCounts []struct {
Status int `json:"status"`
Count int `json:"count"`
} `json:"statusCounts"`
Cursor string `json:"cursor"`
List []OfflineTask `json:"list"`
Total int `json:"total"`
} `json:"data"`
}
type OfflineTask struct {
TaskID string `json:"taskId"`
FileName string `json:"fileName"`
TotalSize int64 `json:"totalSize"`
Status int `json:"status"`
CreateTime int64 `json:"createTime"`
Res string `json:"res"`
ResType int `json:"resType"`
Progress int `json:"progress"`
FileID string `json:"fileId"`
IsDir bool `json:"isDir"`
Exist bool `json:"exist"`
}
func unixOrZero(v int64) time.Time {
if v <= 0 {
return time.Time{}
}
return time.Unix(v, 0)
}
-436
View File
@@ -1,436 +0,0 @@
package guangyapan
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/avast/retry-go"
"github.com/go-resty/resty/v2"
"golang.org/x/time/rate"
)
// --- HTTP request helpers ---
func (d *GuangYaPan) accountErr(desc, short string, resp *resty.Response) string {
msg := strings.TrimSpace(desc)
if msg == "" {
msg = strings.TrimSpace(short)
}
if msg == "" && resp != nil {
msg = strings.TrimSpace(resp.String())
}
if msg == "" && resp != nil {
msg = fmt.Sprintf("status=%d", resp.StatusCode())
}
if msg == "" {
msg = "unknown error"
}
return msg
}
func (d *GuangYaPan) apiRateLimitWait(ctx context.Context, path string) error {
value, _ := d.apiRateLimit.LoadOrStore(path, rate.NewLimiter(rate.Every(apiRateInterval), 1))
return value.(*rate.Limiter).Wait(ctx)
}
func (d *GuangYaPan) postAPI(ctx context.Context, path string, body any, out any) error {
if err := d.ensureAccessToken(ctx); err != nil {
return err
}
if err := d.apiRateLimitWait(ctx, path); err != nil {
return err
}
resp, err := d.apiClient.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.AccessToken).
SetBody(body).
SetResult(out).
Post(path)
if err != nil {
return err
}
if resp.StatusCode() == 401 || resp.StatusCode() == 403 {
if strings.TrimSpace(d.RefreshToken) == "" {
return fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode(), resp.String())
}
if err := d.refreshToken(ctx); err != nil {
return err
}
resp, err = d.apiClient.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+d.AccessToken).
SetBody(body).
SetResult(out).
Post(path)
if err != nil {
return err
}
}
if resp.IsError() {
return fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode(), resp.String())
}
return nil
}
func (d *GuangYaPan) setTempStatus(status string) {
if d.statusTimer != nil {
d.statusTimer.Stop()
}
// initStorage sets status to WORK after Init returns, so we update it shortly after.
d.statusTimer = time.AfterFunc(200*time.Millisecond, func() {
d.GetStorage().SetStatus(status)
op.MustSaveDriverStorage(d)
})
}
// --- Task polling helpers ---
func (d *GuangYaPan) waitTaskDone(ctx context.Context, taskID string) error {
const (
maxTry = 30
interval = 300 * time.Millisecond
)
for i := 0; i < maxTry; i++ {
var out taskStatusResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_task_status", map[string]any{
"taskId": taskID,
}, &out); err != nil {
return err
}
if !isSuccessMsg(out.Msg) {
return fmt.Errorf("get task status failed: %s", strings.TrimSpace(out.Msg))
}
switch out.Data.Status {
case 2:
return nil
case -1, 3:
return fmt.Errorf("task %s failed with status=%d", taskID, out.Data.Status)
}
if i == maxTry-1 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
return fmt.Errorf("task %s timeout", taskID)
}
// --- Upload helpers ---
// fileMD5 returns the MD5 of the file content, computing it from the stream when
// the caller did not provide it (e.g. local uploads). The computed hash is used
// to attempt instant upload (秒传) before falling back to the real OSS upload.
// For force-stream uploads the whole file would have to be buffered just to hash
// it, which defeats the purpose of streaming, so we skip 秒传 in that case.
func (d *GuangYaPan) fileMD5(file model.FileStreamer, up driver.UpdateProgress) (string, error) {
if md5sum := file.GetHash().GetHash(utils.MD5); len(md5sum) == utils.MD5.Width {
return md5sum, nil
}
if file.IsForceStreamUpload() {
return "", nil
}
_, md5sum, err := streamPkg.CacheFullAndHash(file, &up, utils.MD5)
if err != nil {
return "", err
}
return md5sum, nil
}
func (d *GuangYaPan) getUploadToken(ctx context.Context, parentID, name string, size int64, md5sum string) (*uploadTokenData, int, error) {
var out uploadTokenResp
res := map[string]any{
"fileSize": size,
}
if md5sum != "" {
// 秒传:后端根据 md5 判断文件是否已存在,命中则返回 code 156 秒传完成。
res["md5"] = md5sum
}
err := d.postAPI(ctx, "/nd.bizuserres.s/v1/get_res_center_token", map[string]any{
"capacity": 2,
"name": name,
"parentId": parentID,
"res": res,
}, &out)
if err != nil {
return nil, 0, err
}
msg := strings.TrimSpace(out.Msg)
if !isSuccessMsg(msg) && !isUploadAlreadyDone(msg) {
return nil, out.Code, fmt.Errorf("get upload token failed: %s", msg)
}
if out.Data.TaskID == "" {
return nil, out.Code, errors.New("get upload token failed: empty task id")
}
// When the backend reports the file is already uploaded/instant-uploaded,
// it returns a valid TaskID without OSS credentials.
// Mark it so the caller can skip the real upload and just wait for the task.
if out.Code == 156 || isUploadAlreadyDone(msg) {
out.Data.AlreadyDone = true
}
if out.Data.AccessKeyID == "" {
out.Data.AccessKeyID = out.Data.Creds.AccessKeyID
}
if out.Data.SecretAccessKey == "" {
out.Data.SecretAccessKey = out.Data.Creds.SecretAccessKey
}
if out.Data.SessionToken == "" {
out.Data.SessionToken = out.Data.Creds.SessionToken
}
if strings.TrimSpace(out.Data.EndPoint) == "" {
out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint)
}
if strings.TrimSpace(out.Data.EndPoint) != "" && !strings.HasPrefix(out.Data.EndPoint, "http://") && !strings.HasPrefix(out.Data.EndPoint, "https://") {
if strings.TrimSpace(out.Data.FullEndPoint) != "" {
out.Data.EndPoint = strings.TrimSpace(out.Data.FullEndPoint)
} else if strings.TrimSpace(out.Data.BucketName) != "" {
host := strings.TrimSpace(out.Data.EndPoint)
prefix := strings.TrimSpace(out.Data.BucketName) + "."
if strings.HasPrefix(host, prefix) {
out.Data.EndPoint = "https://" + host
} else {
out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.BucketName) + "." + host
}
} else {
out.Data.EndPoint = "https://" + strings.TrimSpace(out.Data.EndPoint)
}
}
return &out.Data, out.Code, nil
}
func (d *GuangYaPan) waitUploadTaskInfo(ctx context.Context, taskID string) error {
const (
maxTry = 300
interval = 1 * time.Second
)
for i := 0; i < maxTry; i++ {
var out taskInfoResp
if err := d.postAPI(ctx, "/nd.bizuserres.s/v1/file/get_info_by_task_id", map[string]any{
"taskId": taskID,
}, &out); err != nil {
return err
}
if out.Data.FileID != "" {
return nil
}
switch out.Code {
case 145, 146, 147, 155, 163, 0:
// uploading/verifying/processing
default:
if strings.TrimSpace(out.Msg) != "" {
return fmt.Errorf("upload task failed: code=%d msg=%s", out.Code, strings.TrimSpace(out.Msg))
}
}
if i == maxTry-1 {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
return fmt.Errorf("upload task %s timeout", taskID)
}
func (d *GuangYaPan) multipartUploadToOSS(ctx context.Context, bucket *oss.Bucket, objectPath string, file model.FileStreamer, up driver.UpdateProgress) error {
partSize := calcUploadPartSize(file.GetSize())
imur, err := bucket.InitiateMultipartUpload(objectPath, oss.Sequential())
if err != nil {
return err
}
total := file.GetSize()
partCount := int((total + partSize - 1) / partSize)
// Use StreamSectionReader for seekable, retryable chunk reads (hybrid cache).
ss, err := streamPkg.NewStreamSectionReader(file, int(partSize), &up)
if err != nil {
return err
}
parts := make([]oss.UploadPart, 0, partCount)
var uploaded int64
for i := 0; i < partCount; i++ {
if utils.IsCanceled(ctx) {
return ctx.Err()
}
offset := int64(i) * partSize
length := partSize
if remain := total - offset; length > remain {
length = remain
}
rd, err := ss.GetSectionReader(offset, length)
if err != nil {
return err
}
var part oss.UploadPart
err = retry.Do(func() error {
rd.Seek(0, io.SeekStart)
var uploadErr error
part, uploadErr = bucket.UploadPart(imur, driver.NewLimitedUploadStream(ctx, rd), length, i+1)
return uploadErr
},
retry.Context(ctx),
retry.Attempts(3),
retry.DelayType(retry.BackOffDelay),
retry.Delay(time.Second))
ss.FreeSectionReader(rd)
if err != nil {
return fmt.Errorf("failed to upload part %d: %w", i+1, err)
}
parts = append(parts, part)
uploaded += length
if total > 0 {
up(100 * float64(uploaded) / float64(total))
}
}
_, err = bucket.CompleteMultipartUpload(imur, parts)
return err
}
// --- Normalization helpers ---
func normalizeOSSEndpoint(endpoint, bucket string) string {
ep := strings.TrimSpace(endpoint)
if ep == "" {
return ep
}
if !strings.HasPrefix(ep, "http://") && !strings.HasPrefix(ep, "https://") {
ep = "https://" + ep
}
u, err := url.Parse(ep)
if err != nil || u.Host == "" {
return ep
}
host := u.Host
prefix := strings.TrimSpace(bucket)
if prefix != "" && strings.HasPrefix(host, prefix+".") {
host = strings.TrimPrefix(host, prefix+".")
}
u.Host = host
return u.String()
}
func normalizeDeviceID(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.ReplaceAll(v, "-", "")
if len(v) != 32 {
return ""
}
for _, ch := range v {
if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') {
return ""
}
}
return v
}
func randomDeviceID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "0123456789abcdef0123456789abcdef"
}
return hex.EncodeToString(b)
}
func normalizeCaptchaUsername(phone string) string {
p := strings.TrimSpace(phone)
p = strings.ReplaceAll(p, " ", "")
p = strings.TrimPrefix(p, "+")
// Keep only digits.
b := make([]rune, 0, len(p))
for _, ch := range p {
if ch >= '0' && ch <= '9' {
b = append(b, ch)
}
}
digits := string(b)
// Mainland number normalization: +86xxxxxxxxxxx -> xxxxxxxxxxx
if strings.HasPrefix(digits, "86") && len(digits) > 11 {
digits = digits[2:]
}
return digits
}
func normalizePhoneE164(phone string) string {
p := strings.TrimSpace(phone)
if p == "" {
return ""
}
p = strings.ReplaceAll(p, " ", "")
if strings.HasPrefix(p, "+") {
// Format as "+86 1xxxxxxxxxx" to match browser payload expectations.
if strings.HasPrefix(p, "+86") && len(p) > 3 {
rest := strings.TrimPrefix(p, "+86")
return "+86 " + rest
}
return p
}
// If raw mainland number is provided, normalize with +86 prefix.
digits := normalizeCaptchaUsername(p)
if len(digits) == 11 {
return "+86 " + digits
}
return p
}
func calcUploadPartSize(size int64) int64 {
const (
mb = int64(1024 * 1024)
gb = int64(1024 * 1024 * 1024)
)
switch {
case size <= 100*mb:
return 1 * mb
case size <= 16*gb:
return 2 * mb
case size <= 160*gb:
return 4 * mb
default:
return 8 * mb
}
}
// isUploadAlreadyDone reports whether the upload-token response indicates the
// file was already uploaded (instant upload). In that case the backend returns
// a valid TaskID but no OSS credentials, and we should just wait for the task
// instead of starting a real upload.
func isUploadAlreadyDone(msg string) bool {
msg = strings.TrimSpace(msg)
if msg == "" {
return false
}
if strings.EqualFold(msg, "上传已完成") {
return true
}
if strings.EqualFold(msg, "upload completed") {
return true
}
if strings.EqualFold(msg, "already uploaded") {
return true
}
if strings.EqualFold(msg, "秒传成功") {
return true
}
return false
}
+18 -18
View File
@@ -15,11 +15,12 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/city404/v6-public-rpc-proto/go/v6/common"
pbPublicUser "github.com/city404/v6-public-rpc-proto/go/v6/user"
pubUserFile "github.com/city404/v6-public-rpc-proto/go/v6/userfile"
@@ -373,24 +374,23 @@ func (d *HalalCloud) put(ctx context.Context, dstDir model.Obj, fileStream model
u, _ := url.Parse(result.Endpoint)
u.Host = "s3." + u.Host
result.Endpoint = u.String()
s, err := session.NewSession(&aws.Config{
s3Client := s3.NewFromConfig(aws.Config{
HTTPClient: base.HttpClient,
Credentials: credentials.NewStaticCredentials(result.AccessKey, result.SecretKey, result.Token),
Region: aws.String(result.Region),
Endpoint: aws.String(result.Endpoint),
S3ForcePathStyle: aws.Bool(true),
Credentials: credentials.NewStaticCredentialsProvider(result.AccessKey, result.SecretKey, result.Token),
Region: result.Region,
}, func(o *s3.Options) {
o.BaseEndpoint = aws.String(result.Endpoint)
o.UsePathStyle = true
})
if err != nil {
return nil, err
}
uploader := s3manager.NewUploader(s, func(u *s3manager.Uploader) {
u.Concurrency = d.uploadThread
tmClient := transfermanager.New(s3Client, func(o *transfermanager.Options) {
if fileStream.GetSize() > int64(8*utils.MB)*10000 {
o.PartSizeBytes = fileStream.GetSize() / 9999
}
o.Concurrency = d.uploadThread
})
if fileStream.GetSize() > s3manager.MaxUploadParts*s3manager.DefaultUploadPartSize {
uploader.PartSize = fileStream.GetSize() / (s3manager.MaxUploadParts - 1)
}
reader := driver.NewLimitedUploadStream(ctx, fileStream)
_, err = uploader.UploadWithContext(ctx, &s3manager.UploadInput{
_, err = tmClient.UploadObject(ctx, &transfermanager.UploadObjectInput{
Bucket: aws.String(result.Bucket),
Key: aws.String(result.Key),
Body: io.TeeReader(reader, driver.NewProgress(fileStream.GetSize(), up)),
+151 -97
View File
@@ -1,4 +1,4 @@
package ilanzou
package template
import (
"context"
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
@@ -28,13 +27,11 @@ type ILanZou struct {
model.Storage
Addition
userID string
account string
apiClient *resty.Client
linkClient *resty.Client
upClient *resty.Client
conf Conf
config driver.Config
userID string
account string
upClient *resty.Client
conf Conf
config driver.Config
}
func (d *ILanZou) Config() driver.Config {
@@ -46,18 +43,6 @@ func (d *ILanZou) GetAddition() driver.Additional {
}
func (d *ILanZou) Init(ctx context.Context) error {
jar, err := cookiejar.New(nil)
if err != nil {
return err
}
// Keep state isolated per storage. The console and CDN issue cookies that
// must survive a retry but must never leak to another configured account.
d.apiClient = base.NewRestyClient().SetCookieJar(jar)
d.linkClient = base.NewRestyClient().SetCookieJar(jar).SetRedirectPolicy(
resty.RedirectPolicyFunc(func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}),
)
d.upClient = base.NewRestyClient().SetTimeout(time.Minute * 10)
if d.UUID == "" {
res, err := d.unproved("/getUuid", http.MethodGet, nil)
@@ -92,7 +77,8 @@ func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs)
"folderId=" + dir.GetID(),
"type=0",
}
req.SetQueryString(strings.Join(params, "&")).SetResult(&resp)
queryString := strings.Join(params, "&")
req.SetQueryString(queryString).SetResult(&resp)
})
if err != nil {
return nil, err
@@ -110,12 +96,14 @@ func (d *ILanZou) List(ctx context.Context, dir model.Obj, args model.ListArgs)
return nil, err
}
obj := model.Object{
ID: strconv.FormatInt(f.FileId, 10),
ID: strconv.FormatInt(f.FileId, 10),
// Path: "",
Name: f.FileName,
Size: f.FileSize * 1024,
Modified: updTime,
Ctime: updTime,
IsFolder: false,
// HashInfo: utils.HashInfo{},
}
if f.FileType == 2 {
obj.IsFolder = true
@@ -145,23 +133,26 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
"devVersion=" + url.QueryEscape(d.conf.devVersion),
"appVersion=",
"timestamp=" + tsStr,
"appToken=" + appTokenQueryValue(d.Token),
"appToken=" + url.QueryEscape(d.Token),
"enable=1",
}
downloadID, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%s", file.GetID(), d.userID)), d.conf.secret)
downloadId, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%s", file.GetID(), d.userID)), d.conf.secret)
if err != nil {
return nil, err
}
params = append(params, "downloadId="+url.QueryEscape(hex.EncodeToString(downloadID)))
params = append(params, "downloadId="+url.QueryEscape(hex.EncodeToString(downloadId)))
auth, err := mopan.AesEncrypt([]byte(fmt.Sprintf("%s|%d", file.GetID(), ts)), d.conf.secret)
if err != nil {
return nil, err
}
params = append(params, "auth="+url.QueryEscape(hex.EncodeToString(auth)))
u.RawQuery = strings.Join(params, "&")
realURL := u.String()
req := d.linkClient.R().SetContext(ctx)
// get the url after redirect
req := base.NoRedirectClient.R().SetContext(ctx)
req.SetHeaders(map[string]string{
"Origin": d.conf.site,
"Referer": d.conf.site + "/",
@@ -171,6 +162,7 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
if d.Addition.Ip != "" {
req.SetHeader("X-Forwarded-For", d.Addition.Ip)
}
res, err := req.Get(realURL)
if err != nil {
return nil, err
@@ -180,50 +172,49 @@ func (d *ILanZou) Link(ctx context.Context, file model.Obj, args model.LinkArgs)
realURL = location
} else if res.StatusCode() == http.StatusOK && location != "" {
realURL = location
} else if res.StatusCode() == http.StatusOK {
// Some file types return a 200 JSON resolver response instead of a 3xx.
realURL = utils.Json.Get(res.Body(), "url").ToString()
if realURL == "" {
realURL = utils.Json.Get(res.Body(), "data", "url").ToString()
}
if realURL == "" {
return nil, fmt.Errorf("download resolver returned no URL: %s", utils.Json.Get(res.Body(), "msg").ToString())
}
} else {
return nil, fmt.Errorf("redirect failed, status: %d, location: %s, msg: %s", res.StatusCode(), location, utils.Json.Get(res.Body(), "msg").ToString())
}
link := &model.Link{URL: realURL}
// Probe the CDN for the actual object size; API metadata can differ from
// the bytes served by the final URL. The timeout bounds Link latency.
headCtx, cancel := context.WithTimeout(ctx, linkHeadTimeout)
defer cancel()
if response, err := d.apiClient.R().SetContext(headCtx).Head(realURL); err == nil && response.StatusCode() >= http.StatusOK && response.StatusCode() < http.StatusMultipleChoices {
if size, parseErr := strconv.ParseInt(response.Header().Get("Content-Length"), 10, 64); parseErr == nil && size > 0 {
link.ContentLength = size
}
}
return link, nil
link := model.Link{URL: realURL}
return &link, nil
}
func (d *ILanZou) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
res, err := d.proved("/file/folder/save", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"folderDesc": "", "folderId": parentDir.GetID(), "folderName": dirName})
req.SetBody(base.Json{
"folderDesc": "",
"folderId": parentDir.GetID(),
"folderName": dirName,
})
})
if err != nil {
return nil, err
}
return &model.Object{ID: utils.Json.Get(res, "list", 0, "id").ToString(), Name: dirName, Modified: time.Now(), Ctime: time.Now(), IsFolder: true}, nil
return &model.Object{
ID: utils.Json.Get(res, "list", 0, "id").ToString(),
// Path: "",
Name: dirName,
Size: 0,
Modified: time.Now(),
Ctime: time.Now(),
IsFolder: true,
// HashInfo: utils.HashInfo{},
}, nil
}
func (d *ILanZou) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
var fileIDs, folderIDs []string
var fileIds, folderIds []string
if srcObj.IsDir() {
folderIDs = []string{srcObj.GetID()}
folderIds = []string{srcObj.GetID()}
} else {
fileIDs = []string{srcObj.GetID()}
fileIds = []string{srcObj.GetID()}
}
_, err := d.proved("/file/folder/move", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"folderIds": strings.Join(folderIDs, ","), "fileIds": strings.Join(fileIDs, ","), "targetId": dstDir.GetID()})
req.SetBody(base.Json{
"folderIds": strings.Join(folderIds, ","),
"fileIds": strings.Join(fileIds, ","),
"targetId": dstDir.GetID(),
})
})
if err != nil {
return nil, err
@@ -235,47 +226,58 @@ func (d *ILanZou) Rename(ctx context.Context, srcObj model.Obj, newName string)
var err error
if srcObj.IsDir() {
_, err = d.proved("/file/folder/edit", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"folderDesc": "", "folderId": srcObj.GetID(), "folderName": newName})
req.SetBody(base.Json{
"folderDesc": "",
"folderId": srcObj.GetID(),
"folderName": newName,
})
})
} else {
_, err = d.proved("/file/edit", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"fileDesc": "", "fileId": srcObj.GetID(), "fileName": newName})
req.SetBody(base.Json{
"fileDesc": "",
"fileId": srcObj.GetID(),
"fileName": newName,
})
})
}
if err != nil {
return nil, err
}
return &model.Object{ID: srcObj.GetID(), Name: newName, Size: srcObj.GetSize(), Modified: time.Now(), Ctime: srcObj.CreateTime(), IsFolder: srcObj.IsDir()}, nil
return &model.Object{
ID: srcObj.GetID(),
// Path: "",
Name: newName,
Size: srcObj.GetSize(),
Modified: time.Now(),
Ctime: srcObj.CreateTime(),
IsFolder: srcObj.IsDir(),
}, nil
}
// iLanzou has no server-side copy primitive. Returning NotImplement delegates
// to OpenList's persistent CopyTaskManager instead of blocking the HTTP request.
func (d *ILanZou) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
// TODO copy obj, optional
return nil, errs.NotImplement
}
func (d *ILanZou) Remove(ctx context.Context, obj model.Obj) error {
var fileIDs, folderIDs []string
var fileIds, folderIds []string
if obj.IsDir() {
folderIDs = []string{obj.GetID()}
folderIds = []string{obj.GetID()}
} else {
fileIDs = []string{obj.GetID()}
fileIds = []string{obj.GetID()}
}
_, err := d.proved("/file/delete", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"folderIds": strings.Join(folderIDs, ","), "fileIds": strings.Join(fileIDs, ","), "status": 0})
req.SetBody(base.Json{
"folderIds": strings.Join(folderIds, ","),
"fileIds": strings.Join(fileIds, ","),
"status": 0,
})
})
return err
}
const (
DefaultPartSize = 1024 * 1024 * 8
// The results endpoint is eventually consistent after the upload commit.
maxUploadCommitRetries = 10
uploadCommitRetryDelay = time.Second
linkHeadTimeout = 5 * time.Second
)
const DefaultPartSize = 1024 * 1024 * 8
func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
etag := s.GetHash().GetHash(utils.MD5)
@@ -286,31 +288,55 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
return nil, err
}
}
fileSizeKiB := (s.GetSize() + 1023) / 1024
if fileSizeKiB < 1 {
fileSizeKiB = 1
}
// get upToken
res, err := d.proved("/7n/getUpToken", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"fileId": "", "fileName": s.GetName(), "fileSize": fileSizeKiB, "folderId": dstDir.GetID(), "md5": etag, "type": 1})
req.SetBody(base.Json{
"fileId": "",
"fileName": s.GetName(),
"fileSize": s.GetSize()/1024 + 1,
"folderId": dstDir.GetID(),
"md5": etag,
"type": 1,
})
})
if err != nil {
return nil, err
}
upToken := utils.Json.Get(res, "upToken").ToString()
if upToken == "-1" {
// 支持秒传
var resp UploadTokenRapidResp
if err := utils.Json.Unmarshal(res, &resp); err != nil {
err := utils.Json.Unmarshal(res, &resp)
if err != nil {
return nil, err
}
return &model.Object{ID: strconv.FormatInt(resp.Map.FileID, 10), Name: resp.Map.FileName, Size: s.GetSize(), Modified: s.ModTime(), Ctime: s.CreateTime(), IsFolder: false, HashInfo: utils.NewHashInfo(utils.MD5, etag)}, nil
return &model.Object{
ID: strconv.FormatInt(resp.Map.FileID, 10),
Name: resp.Map.FileName,
Size: s.GetSize(),
Modified: s.ModTime(),
Ctime: s.CreateTime(),
IsFolder: false,
HashInfo: utils.NewHashInfo(utils.MD5, etag),
}, nil
}
now := time.Now()
// Match the current console's generated Qiniu object key.
key := fmt.Sprintf("disk/%04d/%02d/%02d/%s/%d.rar", now.Year(), now.Month(), now.Day(), d.account, now.UnixMilli())
reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{Reader: &driver.SimpleReaderWithSize{Reader: s, Size: s.GetSize()}, UpdateProgress: up})
key := fmt.Sprintf("disk/%d/%d/%d/%s/%016d", now.Year(), now.Month(), now.Day(), d.account, now.UnixMilli())
reader := driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
Reader: &driver.SimpleReaderWithSize{
Reader: s,
Size: s.GetSize(),
},
UpdateProgress: up,
})
var token string
if s.GetSize() <= DefaultPartSize {
res, err := d.upClient.R().SetContext(ctx).SetMultipartFormData(map[string]string{"token": upToken, "key": key, "fname": s.GetName()}).SetMultipartField("file", s.GetName(), s.GetMimetype(), reader).Post("https://upload.qiniup.com/")
res, err := d.upClient.R().SetContext(ctx).SetMultipartFormData(map[string]string{
"token": upToken,
"key": key,
"fname": s.GetName(),
}).SetMultipartField("file", s.GetName(), s.GetMimetype(), reader).
Post("https://upload.qiniup.com/")
if err != nil {
return nil, err
}
@@ -321,27 +347,40 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
if err != nil {
return nil, err
}
uploadID := utils.Json.Get(res.Body(), "uploadId").ToString()
uploadId := utils.Json.Get(res.Body(), "uploadId").ToString()
parts := make([]Part, 0)
partNum := (s.GetSize() + DefaultPartSize - 1) / DefaultPartSize
for i := 1; i <= int(partNum); i++ {
u := fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s/%d", d.conf.bucket, keyBase64, uploadID, i)
u := fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s/%d", d.conf.bucket, keyBase64, uploadId, i)
res, err = d.upClient.R().SetContext(ctx).SetHeader("Authorization", "UpToken "+upToken).SetBody(io.LimitReader(reader, DefaultPartSize)).Put(u)
if err != nil {
return nil, err
}
parts = append(parts, Part{PartNumber: i, ETag: utils.Json.Get(res.Body(), "etag").ToString()})
etag := utils.Json.Get(res.Body(), "etag").ToString()
parts = append(parts, Part{
PartNumber: i,
ETag: etag,
})
}
res, err = d.upClient.R().SetHeader("Authorization", "UpToken "+upToken).SetBody(base.Json{"fnmae": s.GetName(), "parts": parts}).Post(fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s", d.conf.bucket, keyBase64, uploadID))
res, err = d.upClient.R().SetHeader("Authorization", "UpToken "+upToken).SetBody(base.Json{
"fnmae": s.GetName(),
"parts": parts,
}).Post(fmt.Sprintf("https://upload.qiniup.com/buckets/%s/objects/%s/uploads/%s", d.conf.bucket, keyBase64, uploadId))
if err != nil {
return nil, err
}
token = utils.Json.Get(res.Body(), "token").ToString()
}
// commit upload
var resp UploadResultResp
for i := 0; i < maxUploadCommitRetries; i++ {
for i := 0; i < 10; i++ {
_, err = d.unproved("/7n/results", http.MethodPost, func(req *resty.Request) {
req.SetQueryString("tokenList=" + token + "&tokenTime=" + time.Now().Format("Mon Jan 02 2006 15:04:05 GMT-0700 (MST)")).SetResult(&resp)
params := []string{
"tokenList=" + token,
"tokenTime=" + time.Now().Format("Mon Jan 02 2006 15:04:05 GMT-0700 (MST)"),
}
queryString := strings.Join(params, "&")
req.SetQueryString(queryString).SetResult(&resp)
})
if err != nil {
return nil, err
@@ -352,17 +391,22 @@ func (d *ILanZou) Put(ctx context.Context, dstDir model.Obj, s model.FileStreame
if resp.List[0].Status == 1 {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(uploadCommitRetryDelay):
}
time.Sleep(time.Second * 1)
}
file := resp.List[0]
if file.Status != 1 {
return nil, fmt.Errorf("upload failed, status: %d", file.Status)
return nil, fmt.Errorf("upload failed, status: %d", resp.List[0].Status)
}
return &model.Object{ID: strconv.FormatInt(file.FileId, 10), Name: file.FileName, Size: s.GetSize(), Modified: s.ModTime(), Ctime: s.CreateTime(), IsFolder: false, HashInfo: utils.NewHashInfo(utils.MD5, etag)}, nil
return &model.Object{
ID: strconv.FormatInt(file.FileId, 10),
// Path: ,
Name: file.FileName,
Size: s.GetSize(),
Modified: s.ModTime(),
Ctime: s.CreateTime(),
IsFolder: false,
HashInfo: utils.NewHashInfo(utils.MD5, etag),
}, nil
}
func (d *ILanZou) GetDetails(ctx context.Context) (*model.StorageDetails, error) {
@@ -375,8 +419,18 @@ func (d *ILanZou) GetDetails(ctx context.Context) (*model.StorageDetails, error)
vipSize := utils.Json.Get(res, "map", "vipSize").ToInt64() * 1024
totalSize := utils.Json.Get(res, "map", "totalSize").ToInt64() * 1024
rewardSize := utils.Json.Get(res, "map", "rewardSize").ToInt64() * 1024
total := totalSize + rewardSize + vipSize
used := utils.Json.Get(res, "map", "usedSize").ToInt64() * 1024
return &model.StorageDetails{DiskUsage: model.DiskUsage{TotalSpace: totalSize + rewardSize + vipSize, UsedSpace: used}}, nil
return &model.StorageDetails{
DiskUsage: model.DiskUsage{
TotalSpace: total,
UsedSpace: used,
},
}, nil
}
//func (d *ILanZou) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
// return nil, errs.NotSupport
//}
var _ driver.Driver = (*ILanZou)(nil)
+1 -1
View File
@@ -1,4 +1,4 @@
package ilanzou
package template
import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
+1 -1
View File
@@ -1,4 +1,4 @@
package ilanzou
package template
type ListResp struct {
Msg string `json:"msg"`
+23 -36
View File
@@ -1,4 +1,4 @@
package ilanzou
package template
import (
"encoding/hex"
@@ -18,7 +18,10 @@ import (
func (d *ILanZou) login() error {
res, err := d.unproved("/login", http.MethodPost, func(req *resty.Request) {
req.SetBody(base.Json{"loginName": d.Username, "loginPwd": d.Password})
req.SetBody(base.Json{
"loginName": d.Username,
"loginPwd": d.Password,
})
})
if err != nil {
return err
@@ -32,33 +35,20 @@ func (d *ILanZou) login() error {
func getTimestamp(secret []byte) (int64, string, error) {
ts := time.Now().UnixMilli()
res, err := mopan.AesEncrypt([]byte(strconv.FormatInt(ts, 10)), secret)
tsStr := strconv.FormatInt(ts, 10)
res, err := mopan.AesEncrypt([]byte(tsStr), secret)
if err != nil {
return 0, "", err
}
return ts, hex.EncodeToString(res), nil
}
// isCDNChallenge detects a transient CDN challenge (409 Conflict + HTML 403).
// A retry with the session cookie jar can resolve transient challenges.
func isCDNChallenge(res *resty.Response) bool {
if res == nil || res.StatusCode() != http.StatusConflict {
return false
}
return strings.Contains(res.Header().Get("Content-Type"), "text/html") && strings.Contains(string(res.Body()), "403")
}
func appTokenQueryValue(token string) string {
// iLanzou requires a literal colon, while other reserved token characters
// must remain query-escaped.
return strings.ReplaceAll(url.QueryEscape(token), "%3A", ":")
}
func (d *ILanZou) request(pathname, method string, callback base.ReqCallback, proved bool, retry ...bool) ([]byte, error) {
_, timestamp, err := getTimestamp(d.conf.secret)
_, ts_str, err := getTimestamp(d.conf.secret)
if err != nil {
return nil, err
}
params := []string{
"uuid=" + url.QueryEscape(d.UUID),
"devType=6",
@@ -66,51 +56,48 @@ func (d *ILanZou) request(pathname, method string, callback base.ReqCallback, pr
"devModel=chrome",
"devVersion=" + url.QueryEscape(d.conf.devVersion),
"appVersion=",
"timestamp=" + timestamp,
"timestamp=" + ts_str,
}
if proved {
params = append(params, "appToken="+appTokenQueryValue(d.Token))
params = append(params, "appToken="+url.QueryEscape(d.Token))
}
params = append(params, "extra=2")
if d.apiClient == nil {
return nil, fmt.Errorf("iLanzou driver is not initialized")
}
req := d.apiClient.R()
queryString := strings.Join(params, "&")
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"Origin": d.conf.site,
"Referer": d.conf.site + "/",
"Accept-Encoding": "gzip",
"Accept-Language": "zh-CN,zh;q=0.9,en-US,en;q=0.8",
})
if d.Addition.Ip != "" {
req.SetHeader("X-Forwarded-For", d.Addition.Ip)
}
if callback != nil {
callback(req)
}
res, err := req.Execute(method, d.conf.base+pathname+"?"+strings.Join(params, "&"))
res, err := req.Execute(method, d.conf.base+pathname+"?"+queryString)
if err != nil {
if res != nil {
log.Errorf("[ilanzou] request error: %s", res.String())
log.Errorf("[iLanZou] request error: %s", res.String())
}
return nil, err
}
isRetry := len(retry) > 0 && retry[0]
if isCDNChallenge(res) {
// Resty's cookie jar records the challenge cookie from the first response.
// A second request proves whether it is a transient challenge or hard block.
if !isRetry {
return d.request(pathname, method, callback, proved, true)
}
return nil, fmt.Errorf("iLanzou CDN rejected %s with HTML 403; retry later or delete from the console", pathname)
}
body := res.Body()
code := utils.Json.Get(body, "code").ToInt()
msg := utils.Json.Get(body, "msg").ToString()
if code != 200 {
if !isRetry && proved && (utils.SliceContains([]int{-1, -2}, code) || d.Token == "") {
if err := d.login(); err != nil {
err = d.login()
if err != nil {
return nil, err
}
return d.request(pathname, method, callback, proved, true)
-11
View File
@@ -1,11 +0,0 @@
package ilanzou
import "testing"
func TestAppTokenQueryValue(t *testing.T) {
got := appTokenQueryValue("alpha:beta+gamma&delta#percent%")
want := "alpha:beta%2Bgamma%26delta%23percent%25"
if got != want {
t.Fatalf("appTokenQueryValue() = %q, want %q", got, want)
}
}
+16 -10
View File
@@ -170,17 +170,23 @@ var findKVReg = regexp.MustCompile(`'(.+?)':('?([^' },]*)'?)`) // 拆分kv
// 根据key查询js变量
func findJSVarFunc(key, data string) string {
re := regexp.MustCompile(`var\s+` + regexp.QuoteMeta(key) + `\s*=\s*['"]?([^'"]*)['"]?\s*;`)
matches := re.FindAllStringSubmatch(data, -1)
if len(matches) == 0 {
return ""
}
for i := len(matches) - 1; i >= 0; i-- {
if matches[i][1] != "" {
return matches[i][1]
var values []string
if key != "sasign" {
values = regexp.MustCompile(`var ` + key + `\s*=\s*['"]?(.+?)['"]?;`).FindStringSubmatch(data)
} else {
matches := regexp.MustCompile(`var `+key+`\s*=\s*['"]?(.+?)['"]?;`).FindAllStringSubmatch(data, -1)
if len(matches) == 3 {
values = matches[1]
} else {
if len(matches) > 0 {
values = matches[0]
}
}
}
return matches[len(matches)-1][1]
if len(values) == 0 {
return ""
}
return values[1]
}
var findFunction = regexp.MustCompile(`(?ims)^function[^{]+`)
@@ -298,7 +304,7 @@ var findFromReg = regexp.MustCompile(`data : '(.+?)'`) // 查找from字符串
func htmlFormToMap(html string) (map[string]string, error) {
forms := findFromReg.FindStringSubmatch(html)
if len(forms) != 2 {
return nil, fmt.Errorf("not find file sign")
return nil, fmt.Errorf("not find file sgin")
}
return formToMap(forms[1]), nil
}
+2 -2
View File
@@ -132,7 +132,7 @@ type FileOrFolderByShareUrl struct {
//T int `json:"t"`
// 文件夹特有
IsFolder bool `json:"-"`
IsFloder bool `json:"-"`
//
Url string `json:"-"`
@@ -162,7 +162,7 @@ func (f *FileOrFolderByShareUrl) GetSize() int64 {
}
return *f.size
}
func (f *FileOrFolderByShareUrl) IsDir() bool { return f.IsFolder }
func (f *FileOrFolderByShareUrl) IsDir() bool { return f.IsFloder }
func (f *FileOrFolderByShareUrl) ModTime() time.Time {
if f.time == nil {
time := MustParseTime(f.Time)
+37 -51
View File
@@ -157,43 +157,25 @@ func (d *LanZou) request(url string, method string, callback base.ReqCallback, u
}
func (d *LanZou) Login() ([]*http.Cookie, error) {
var vs string
for retry := 0; retry < 3; retry++ {
req := base.NewRestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()).R()
// 如果已计算出 acw_sc__v2,通过 cookie 携带
if vs != "" {
req.SetHeader("cookie", "acw_sc__v2="+vs)
}
resp, err := req.SetFormData(map[string]string{
"task": "3",
"uid": d.Account,
"pwd": d.Password,
"setSessionId": "",
"setSig": "",
"setScene": "",
"setTocen": "",
"formhash": "",
}).Post("https://up.woozooo.com/mlogin.php")
if err != nil {
return nil, err
}
bodyStr := resp.String()
if strings.Contains(bodyStr, "acw_sc__v2") {
vs, err = CalcAcwScV2(bodyStr)
if err != nil {
return nil, err
}
continue
}
if utils.Json.Get(resp.Body(), "zt").ToInt() != 1 {
return nil, fmt.Errorf("login err: %s", resp.Body())
}
d.Cookie = CookieToString(resp.Cookies())
return resp.Cookies(), nil
resp, err := base.NewRestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()).
R().SetFormData(map[string]string{
"task": "3",
"uid": d.Account,
"pwd": d.Password,
"setSessionId": "",
"setSig": "",
"setScene": "",
"setTocen": "",
"formhash": "",
}).Post("https://up.woozooo.com/mlogin.php")
if err != nil {
return nil, err
}
return nil, errors.New("acw_sc__v2 validation error")
if utils.Json.Get(resp.Body(), "zt").ToInt() != 1 {
return nil, fmt.Errorf("login err: %s", resp.Body())
}
d.Cookie = CookieToString(resp.Cookies())
return resp.Cookies(), nil
}
/*
@@ -313,7 +295,7 @@ var findSubFolderReg = regexp.MustCompile(`(?i)(?:folderlink|mbxfolder).+href="/
var findDownPageParamReg = regexp.MustCompile(`<iframe.*?src="(.+?)"`)
// 获取文件ID
var findFileIDReg = regexp.MustCompile(`'/ajax(?:file|m)\.php\?file=(\d+)'`)
var findFileIDReg = regexp.MustCompile(`'/ajaxm\.php\?file=(\d+)'`)
// 获取分享链接主界面
func (d *LanZou) getShareUrlHtml(shareID string) (string, error) {
@@ -412,13 +394,15 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) (
}
param["p"] = pwd
matches := findFileIDReg.FindStringSubmatch(sharePageData)
if len(matches) < 2 {
fileIDs := findFileIDReg.FindStringSubmatch(sharePageData)
var fileID string
if len(fileIDs) > 1 {
fileID = fileIDs[1]
} else {
return nil, fmt.Errorf("not find file id")
}
ajaxUrl := d.ShareUrl + matches[0][1:len(matches[0])-1]
var resp FileShareInfoAndUrlResp[string]
_, err = d.post(ajaxUrl, func(req *resty.Request) { req.SetFormData(param) }, &resp)
_, err = d.post(d.ShareUrl+"/ajaxm.php?file="+fileID, func(req *resty.Request) { req.SetFormData(param) }, &resp)
if err != nil {
return nil, err
}
@@ -442,13 +426,15 @@ func (d *LanZou) getFilesByShareUrl(shareID, pwd string, sharePageData string) (
return nil, err
}
matches := findFileIDReg.FindStringSubmatch(nextPageData)
if len(matches) < 2 {
fileIDs := findFileIDReg.FindStringSubmatch(nextPageData)
var fileID string
if len(fileIDs) > 1 {
fileID = fileIDs[1]
} else {
return nil, fmt.Errorf("not find file id")
}
ajaxUrl := d.ShareUrl + matches[0][1:len(matches[0])-1]
var resp FileShareInfoAndUrlResp[int]
_, err = d.post(ajaxUrl, func(req *resty.Request) { req.SetFormData(param) }, &resp)
_, err = d.post(d.ShareUrl+"/ajaxm.php?file="+fileID, func(req *resty.Request) { req.SetFormData(param) }, &resp)
if err != nil {
return nil, err
}
@@ -586,14 +572,14 @@ func (d *LanZou) getFolderByShareUrl(pwd string, sharePageData string) ([]FileOr
files := make([]FileOrFolderByShareUrl, 0)
// vip获取文件夹
folders := findSubFolderReg.FindAllStringSubmatch(sharePageData, -1)
for _, folder := range folders {
if len(folder) == 3 {
floders := findSubFolderReg.FindAllStringSubmatch(sharePageData, -1)
for _, floder := range floders {
if len(floder) == 3 {
files = append(files, FileOrFolderByShareUrl{
// Pwd: pwd, // 子文件夹不加密
ID: folder[1],
NameAll: folder[2],
IsFolder: true,
ID: floder[1],
NameAll: floder[2],
IsFloder: true,
})
}
}
+4 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"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"
@@ -152,7 +153,8 @@ func (d *Local) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([
func (d *Local) FileInfoToObj(ctx context.Context, f fs.FileInfo, reqPath string, fullPath string) model.Obj {
thumb := ""
if d.Thumbnail {
if d.supportsThumbnail(f.Name()) {
typeName := utils.GetFileType(f.Name())
if typeName == conf.IMAGE || typeName == conf.VIDEO {
thumb = common.GetApiUrl(ctx) + stdpath.Join("/d", reqPath, f.Name())
thumb = utils.EncodePath(thumb, true)
thumb += "?type=thumb&sign=" + sign.Sign(stdpath.Join(reqPath, f.Name()))
@@ -238,7 +240,7 @@ func (d *Local) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (
var thumbPath *string
err := d.thumbTokenBucket.Do(ctx, func() error {
var err error
buf, thumbPath, err = d.getThumb(ctx, file)
buf, thumbPath, err = d.getThumb(file)
return err
})
if err != nil {
-1
View File
@@ -9,7 +9,6 @@ type Addition struct {
driver.RootPath
DirectorySize bool `json:"directory_size" default:"false" help:"This might impact host performance"`
Thumbnail bool `json:"thumbnail" required:"true" help:"enable thumbnail"`
PDFThumbnail bool `json:"pdf_thumbnail" default:"false" required:"false" help:"Generate PDF first-page thumbnails with Quick Look on macOS"`
ThumbCacheFolder string `json:"thumb_cache_folder"`
ThumbConcurrency string `json:"thumb_concurrency" default:"16" required:"false" help:"Number of concurrent thumbnail generation goroutines. This controls how many thumbnails can be generated in parallel."`
VideoThumbPos string `json:"video_thumb_pos" default:"20%" required:"false" help:"The position of the video thumbnail. If the value is a number (integer ot floating point), it represents the time in seconds. If the value ends with '%', it represents the percentage of the video duration."`
-45
View File
@@ -1,45 +0,0 @@
//go:build darwin
package local
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
)
func pdfThumbnailSupported() bool {
return true
}
func renderPDFThumbnail(ctx context.Context, fullPath string) (*bytes.Buffer, error) {
tempDir, err := os.MkdirTemp("", "openlist-pdf-thumb-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(tempDir)
renderCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(renderCtx, "/usr/bin/qlmanage", "-t", "-s", "512", "-o", tempDir, fullPath)
if output, err := cmd.CombinedOutput(); err != nil {
if renderCtx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("render PDF thumbnail timed out: %w", renderCtx.Err())
}
if renderCtx.Err() != nil {
return nil, fmt.Errorf("render PDF thumbnail canceled: %w", renderCtx.Err())
}
return nil, fmt.Errorf("render PDF thumbnail: %w: %s", err, bytes.TrimSpace(output))
}
thumbPath := filepath.Join(tempDir, filepath.Base(fullPath)+".png")
data, err := os.ReadFile(thumbPath)
if err != nil {
return nil, fmt.Errorf("read rendered PDF thumbnail: %w", err)
}
return bytes.NewBuffer(data), nil
}
-53
View File
@@ -1,53 +0,0 @@
//go:build darwin
package local
import (
"bytes"
"context"
"errors"
"image/png"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestRenderPDFThumbnailDarwin(t *testing.T) {
tempDir := t.TempDir()
textPath := filepath.Join(tempDir, "source.txt")
pdfPath := filepath.Join(tempDir, "source 文件.pdf")
if err := os.WriteFile(textPath, []byte("OpenList PDF thumbnail integration test\n"), 0o600); err != nil {
t.Fatal(err)
}
cmd := exec.Command("/usr/sbin/cupsfilter", textPath)
pdfData, err := cmd.Output()
if err != nil {
t.Fatalf("create fixture PDF: %v", err)
}
if err := os.WriteFile(pdfPath, pdfData, 0o600); err != nil {
t.Fatal(err)
}
thumb, err := renderPDFThumbnail(context.Background(), pdfPath)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(thumb.Bytes(), []byte("\x89PNG\r\n\x1a\n")) {
t.Fatal("rendered thumbnail is not PNG")
}
cfg, err := png.DecodeConfig(bytes.NewReader(thumb.Bytes()))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
t.Fatalf("invalid thumbnail dimensions: %dx%d", cfg.Width, cfg.Height)
}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := renderPDFThumbnail(canceledCtx, pdfPath); !errors.Is(err, context.Canceled) {
t.Fatalf("renderPDFThumbnail with canceled context returned %v, want context.Canceled", err)
}
}
-40
View File
@@ -1,40 +0,0 @@
package local
import (
"testing"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
)
func TestSupportsThumbnail(t *testing.T) {
oldImages := conf.SlicesMap[conf.ImageTypes]
oldVideos := conf.SlicesMap[conf.VideoTypes]
conf.SlicesMap[conf.ImageTypes] = []string{"jpg"}
conf.SlicesMap[conf.VideoTypes] = []string{"mp4"}
t.Cleanup(func() {
conf.SlicesMap[conf.ImageTypes] = oldImages
conf.SlicesMap[conf.VideoTypes] = oldVideos
})
tests := []struct {
name string
fileName string
pdfThumbnail bool
want bool
}{
{name: "image", fileName: "cover.jpg", want: true},
{name: "video", fileName: "movie.mp4", want: true},
{name: "PDF disabled by default", fileName: "document.pdf", want: false},
{name: "unrelated document", fileName: "document.txt", pdfThumbnail: true, want: false},
{name: "PDF enabled when renderer is available", fileName: "document.PDF", pdfThumbnail: true, want: pdfThumbnailSupported()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &Local{Addition: Addition{PDFThumbnail: tt.pdfThumbnail}}
if got := d.supportsThumbnail(tt.fileName); got != tt.want {
t.Fatalf("supportsThumbnail(%q) = %v, want %v", tt.fileName, got, tt.want)
}
})
}
}
-17
View File
@@ -1,17 +0,0 @@
//go:build !darwin
package local
import (
"bytes"
"context"
"errors"
)
func pdfThumbnailSupported() bool {
return false
}
func renderPDFThumbnail(context.Context, string) (*bytes.Buffer, error) {
return nil, errors.New("PDF thumbnails are not supported on this platform")
}
+2 -22
View File
@@ -2,7 +2,6 @@ package local
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -128,19 +127,7 @@ func (d *Local) removeThumbCache(fullPath string) {
_ = os.Remove(thumbPath)
}
func (d *Local) supportsThumbnail(name string) bool {
typeName := utils.GetFileType(name)
if typeName == conf.IMAGE || typeName == conf.VIDEO {
return true
}
return d.supportsPDFThumbnail(name)
}
func (d *Local) supportsPDFThumbnail(name string) bool {
return d.PDFThumbnail && pdfThumbnailSupported() && strings.EqualFold(filepath.Ext(name), ".pdf")
}
func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *string, error) {
func (d *Local) getThumb(file model.Obj) (*bytes.Buffer, *string, error) {
fullPath := file.GetPath()
if d.ThumbCacheFolder != "" {
// skip if the file is a thumbnail
@@ -153,19 +140,12 @@ func (d *Local) getThumb(ctx context.Context, file model.Obj) (*bytes.Buffer, *s
}
}
var srcBuf *bytes.Buffer
typeName := utils.GetFileType(file.GetName())
if typeName == conf.VIDEO {
if utils.GetFileType(file.GetName()) == conf.VIDEO {
videoBuf, err := d.GetSnapshot(fullPath)
if err != nil {
return nil, nil, err
}
srcBuf = videoBuf
} else if d.supportsPDFThumbnail(file.GetName()) {
pdfBuf, err := renderPDFThumbnail(ctx, fullPath)
if err != nil {
return nil, nil, err
}
srcBuf = pdfBuf
} else {
imgData, err := os.ReadFile(fullPath)
if err != nil {
+18 -17
View File
@@ -14,10 +14,10 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/driver"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/go-resty/resty/v2"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@@ -171,12 +171,12 @@ func (d *MediaTrack) Put(ctx context.Context, dstDir model.Obj, file model.FileS
return err
}
credential := resp.Data.Credentials
cfg := &aws.Config{
Credentials: credentials.NewStaticCredentials(credential.TmpSecretID, credential.TmpSecretKey, credential.Token),
Region: &resp.Data.Region,
Endpoint: aws.String("cos.accelerate.myqcloud.com"),
}
s, err := session.NewSession(cfg)
s3Client := s3.NewFromConfig(aws.Config{
Credentials: credentials.NewStaticCredentialsProvider(credential.TmpSecretID, credential.TmpSecretKey, credential.Token),
Region: resp.Data.Region,
}, func(o *s3.Options) {
o.BaseEndpoint = aws.String("cos.accelerate.myqcloud.com")
})
if err != nil {
return err
}
@@ -184,11 +184,13 @@ func (d *MediaTrack) Put(ctx context.Context, dstDir model.Obj, file model.FileS
if err != nil {
return err
}
uploader := s3manager.NewUploader(s)
if file.GetSize() > s3manager.MaxUploadParts*s3manager.DefaultUploadPartSize {
uploader.PartSize = file.GetSize() / (s3manager.MaxUploadParts - 1)
}
input := &s3manager.UploadInput{
tmClient := transfermanager.New(s3Client, func(o *transfermanager.Options) {
if file.GetSize() > int64(8*utils.MB)*10000 {
o.PartSizeBytes = file.GetSize() / 9999
}
})
_, err = tmClient.UploadObject(ctx, &transfermanager.UploadObjectInput{
Bucket: &resp.Data.Bucket,
Key: &resp.Data.Object,
Body: driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
@@ -198,8 +200,7 @@ func (d *MediaTrack) Put(ctx context.Context, dstDir model.Obj, file model.FileS
},
UpdateProgress: up,
}),
}
_, err = uploader.UploadWithContext(ctx, input)
})
if err != nil {
return err
}
+24 -29
View File
@@ -2,6 +2,7 @@ package onedrive_sharelink
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
@@ -12,28 +13,30 @@ import (
"time"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
internalNet "github.com/OpenListTeam/OpenList/v4/internal/net"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
log "github.com/sirupsen/logrus"
"golang.org/x/net/html"
)
// NewNoRedirectClient creates an HTTP client that doesn't follow redirects
func NewNoRedirectCLient() *http.Client {
client := internalNet.NewHttpClient()
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
return &http.Client{
Timeout: time.Hour * 48,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.Conf.TlsInsecureSkipVerify},
},
// Prevent following redirects
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
return client
}
// getCookiesWithPassword fetches cookies required for authenticated access using the provided password
func getCookiesWithPassword(ctx context.Context, link, password string) (string, error) {
func getCookiesWithPassword(link, password string) (string, error) {
// Send GET request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return "", err
}
resp, err := base.HttpClient.Do(req)
resp, err := http.Get(link)
if err != nil {
return "", err
}
@@ -92,18 +95,16 @@ func getCookiesWithPassword(ctx context.Context, link, password string) (string,
"__VIEWSTATEENCRYPTED": []string{""},
}
client := NewNoRedirectCLient()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// Send the POST request, preventing redirects
req, err = http.NewRequestWithContext(ctx, http.MethodPost, newURL, strings.NewReader(data.Encode()))
resp, err = client.PostForm(newURL, data)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Extract the desired cookie value
cookie := resp.Cookies()
@@ -152,7 +153,6 @@ func (d *OnedriveSharelink) getHeaders(ctx context.Context) (http.Header, error)
if err != nil {
return nil, err
}
defer answerNoRedirect.Body.Close()
redirectUrl := answerNoRedirect.Header.Get("Location")
log.Debugln("redirectUrl:", redirectUrl)
if redirectUrl == "" {
@@ -169,7 +169,7 @@ func (d *OnedriveSharelink) getHeaders(ctx context.Context) (http.Header, error)
header.Set("authority", u.Host)
return header, nil
} else {
cookie, err := getCookiesWithPassword(ctx, d.ShareLinkURL, d.ShareLinkPassword)
cookie, err := getCookiesWithPassword(d.ShareLinkURL, d.ShareLinkPassword)
if err != nil {
return nil, err
}
@@ -197,7 +197,6 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
if err != nil {
return nil, err
}
_ = answerNoRedirect.Body.Close()
redirectUrl = answerNoRedirect.Header.Get("Location")
} else {
header = d.Headers
@@ -206,7 +205,6 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
if err != nil {
return nil, err
}
_ = answerNoRedirect.Body.Close()
redirectUrl = answerNoRedirect.Header.Get("Location")
}
redirectSplitURL := strings.Split(redirectUrl, "/")
@@ -291,9 +289,9 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
}
tempHeader["Content-Type"] = []string{"application/json;odata=verbose"}
client := base.HttpClient
client := &http.Client{}
postUrl := strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/v2.1/graphql"
req, err = http.NewRequestWithContext(ctx, http.MethodPost, postUrl, strings.NewReader(graphqlVar))
req, err = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(graphqlVar))
if err != nil {
return nil, err
}
@@ -328,10 +326,7 @@ func (d *OnedriveSharelink) getFiles(ctx context.Context, path string) ([]Item,
log.Debugln("nextHref:", nextHref)
graphqlReqNEW := GraphQLNEWRequest{}
postUrl = strings.Join(redirectSplitURL[:len(redirectSplitURL)-3], "/") + "/_api/web/GetListUsingPath(DecodedUrl=@a1)/RenderListDataAsStream" + nextHref
req, err = http.NewRequestWithContext(ctx, http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar))
if err != nil {
return nil, err
}
req, _ = http.NewRequest(http.MethodPost, postUrl, strings.NewReader(renderListDataAsStreamVar))
req.Header = tempHeader
resp, err := client.Do(req)
-46
View File
@@ -1,46 +0,0 @@
package onedrive_sharelink
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
internalNet "github.com/OpenListTeam/OpenList/v4/internal/net"
)
func TestNoRedirectClientUsesSharedSettings(t *testing.T) {
client := NewNoRedirectCLient()
sharedClient := internalNet.NewHttpClient()
if client.Timeout != sharedClient.Timeout {
t.Fatalf("expected shared timeout %s, got %s", sharedClient.Timeout, client.Timeout)
}
if reflect.TypeOf(client.Transport) != reflect.TypeOf(sharedClient.Transport) {
t.Fatalf("expected shared transport type %T, got %T", sharedClient.Transport, client.Transport)
}
}
func TestNoRedirectClientStopsRedirects(t *testing.T) {
targetRequested := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/target" {
targetRequested = true
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, "/target", http.StatusFound)
}))
defer server.Close()
resp, err := NewNoRedirectCLient().Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected redirect response, got %d", resp.StatusCode)
}
if targetRequested {
t.Fatal("redirect target was requested")
}
}
+1 -2
View File
@@ -10,11 +10,10 @@ type Addition struct {
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true"`
Platform string `json:"platform" required:"true" default:"web" type:"select" options:"android,web,pc"`
RefreshToken string `json:"refresh_token" required:"false" default:""`
RefreshToken string `json:"refresh_token" required:"true" default:""`
CaptchaToken string `json:"captcha_token" default:""`
DeviceID string `json:"device_id" required:"false" default:""`
DisableMediaLink bool `json:"disable_media_link" default:"true"`
SkipVerification bool `json:"skip_verification" default:"false" help:"ignore the human verification URL returned by the captcha API instead of failing; enabling this may trigger PikPak risk control"`
}
var config = driver.Config{
+12 -34
View File
@@ -100,13 +100,12 @@ func (d *PikPak) login() error {
return errors.New("username or password is empty")
}
// Clear expired access token so captcha requests don't carry a stale bearer
d.AccessToken = ""
url := "https://user.mypikpak.net/v1/auth/signin"
// Always refresh captcha token before signin (it may be expired)
if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil {
return err
// 使用 用户填写的 CaptchaToken —————— (验证后的captcha_token)
if d.GetCaptchaToken() == "" {
if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil {
return err
}
}
var e ErrResp
@@ -126,12 +125,7 @@ func (d *PikPak) login() error {
data := res.Body()
d.RefreshToken = jsoniter.Get(data, "refresh_token").ToString()
d.AccessToken = jsoniter.Get(data, "access_token").ToString()
if d.AccessToken == "" || d.RefreshToken == "" {
return errors.New("login failed: server returned empty tokens")
}
d.Common.SetUserID(jsoniter.Get(data, "sub").ToString())
d.Addition.RefreshToken = d.RefreshToken
op.MustSaveDriverStorage(d)
return nil
}
@@ -165,14 +159,9 @@ func (d *PikPak) refreshToken(refreshToken string) error {
return errors.New(e.Error())
}
data := res.Body()
newAccessToken := jsoniter.Get(data, "access_token").ToString()
newRefreshToken := jsoniter.Get(data, "refresh_token").ToString()
if newAccessToken == "" || newRefreshToken == "" {
return errors.New("refresh failed: server returned empty tokens")
}
d.Status = "work"
d.RefreshToken = newRefreshToken
d.AccessToken = newAccessToken
d.RefreshToken = jsoniter.Get(data, "refresh_token").ToString()
d.AccessToken = jsoniter.Get(data, "access_token").ToString()
d.Common.SetUserID(jsoniter.Get(data, "sub").ToString())
d.Addition.RefreshToken = d.RefreshToken
op.MustSaveDriverStorage(d)
@@ -208,18 +197,12 @@ func (d *PikPak) request(url string, method string, callback base.ReqCallback, r
case 0:
return res.Body(), nil
case 4122, 4121, 16:
if strings.Contains(url, "/v1/auth/") || strings.Contains(url, "/v1/shield/captcha/") {
return nil, errors.New(e.Error())
}
// access_token expired, refresh and retry
// access_token 过期
if err1 := d.refreshToken(d.RefreshToken); err1 != nil {
return nil, err1
}
return d.request(url, method, callback, resp)
case 9: // captcha token expired
if strings.Contains(url, "/v1/shield/captcha/") {
return nil, errors.New(e.Error())
}
case 9: // 验证码token过期
if err = d.RefreshCaptchaTokenAtLogin(GetAction(method, url), d.GetUserID()); err != nil {
return nil, err
}
@@ -386,9 +369,6 @@ func (d *PikPak) RefreshCaptchaTokenInLogin(action, username string) error {
} else {
metas["username"] = username
}
metas["client_version"] = d.ClientVersion
metas["package_name"] = d.PackageName
metas["timestamp"], metas["captcha_sign"] = d.Common.GetCaptchaSign()
return d.refreshCaptchaToken(action, metas)
}
@@ -427,7 +407,7 @@ func (d *PikPak) refreshCaptchaToken(action string, metas map[string]string) err
return errors.New(e.Error())
}
if resp.Url != "" && !d.Addition.SkipVerification {
if resp.Url != "" {
return fmt.Errorf(`need verify: <a target="_blank" href="%s">Click Here</a>`, resp.Url)
}
@@ -523,11 +503,9 @@ func (d *PikPak) UploadByMultipart(ctx context.Context, params *S3Params, fileSi
for chunk := range chunksCh {
var part oss.UploadPart // 出现错误就继续尝试,共尝试3次
for retry := 0; retry < 3; retry++ {
if utils.IsCanceled(ctx) {
err = ctx.Err()
break
}
select {
case <-ctx.Done():
break
case <-ticker.C:
errCh <- errors.Wrap(err, "ossToken 过期")
default:
-720
View File
@@ -1,720 +0,0 @@
package pikpak
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/glebarez/sqlite"
"github.com/go-resty/resty/v2"
"gorm.io/gorm"
)
// --- Helper function tests ---
func TestGetAction(t *testing.T) {
tests := []struct {
method string
url string
want string
}{
{"GET", "https://api-drive.mypikpak.net/drive/v1/files", "GET:/drive/v1/files"},
{"POST", "https://user.mypikpak.net/v1/auth/signin", "POST:/v1/auth/signin"},
{"POST", "https://user.mypikpak.net/v1/shield/captcha/init", "POST:/v1/shield/captcha/init"},
{"GET", "https://api-drive.mypikpak.net/drive/v1/files?page_token=abc", "GET:/drive/v1/files"},
{"POST", "https://user.mypikpak.net/v1/auth/token", "POST:/v1/auth/token"},
}
for _, tt := range tests {
t.Run(tt.method+":"+tt.url, func(t *testing.T) {
got := GetAction(tt.method, tt.url)
if got != tt.want {
t.Errorf("GetAction(%q, %q) = %q, want %q", tt.method, tt.url, got, tt.want)
}
})
}
}
func TestGetCaptchaSign(t *testing.T) {
c := &Common{
ClientID: "YNxT9w7GMdWvEOKa",
ClientVersion: "1.53.2",
PackageName: "com.pikcloud.pikpak",
DeviceID: "test-device-id",
Algorithms: AndroidAlgorithms,
}
timestamp, sign := c.GetCaptchaSign()
if timestamp == "" {
t.Fatal("timestamp should not be empty")
}
if len(sign) != 34 {
t.Fatalf("sign length should be 34 (\"1.\" + 32 hex), got %d: %q", len(sign), sign)
}
if sign[:2] != "1." {
t.Errorf("sign should start with '1.', got %q", sign[:2])
}
}
func TestGenerateDeviceSign(t *testing.T) {
sign := generateDeviceSign("test-device", "com.pikcloud.pikpak")
if len(sign) < 7 {
t.Fatal("device sign too short")
}
if sign[:7] != "div101." {
t.Errorf("device sign should start with 'div101.', got %q", sign[:7])
}
// Deterministic
if sign != generateDeviceSign("test-device", "com.pikcloud.pikpak") {
t.Error("generateDeviceSign should be deterministic")
}
}
func TestBuildCustomUserAgent(t *testing.T) {
ua := BuildCustomUserAgent("dev123", AndroidClientID, AndroidPackageName,
AndroidSdkVersion, AndroidClientVersion, AndroidPackageName, "user456")
for _, want := range []string{"ANDROID-", "clientid/", "deviceid/dev123", "usrno/user456"} {
if !strings.Contains(ua, want) {
t.Errorf("user agent should contain %q", want)
}
}
}
// --- Auth recovery behavior tests ---
func TestErrRespErrorClassification(t *testing.T) {
tests := []struct {
name string
resp ErrResp
wantError bool
wantCode int64
}{
{"success", ErrResp{ErrorCode: 0}, false, 0},
{"access_token_expired_4122", ErrResp{ErrorCode: 4122, ErrorMsg: "access_token expired"}, true, 4122},
{"access_token_expired_4121", ErrResp{ErrorCode: 4121, ErrorMsg: "access_token expired"}, true, 4121},
{"unauthenticated_16", ErrResp{ErrorCode: 16, ErrorMsg: "unauthenticated"}, true, 16},
{"refresh_token_invalid_4126", ErrResp{ErrorCode: 4126, ErrorMsg: "invalid_grant"}, true, 4126},
{"captcha_expired_9", ErrResp{ErrorCode: 9, ErrorMsg: "captcha_invalid"}, true, 9},
{"rate_limit_10", ErrResp{ErrorCode: 10, ErrorDescription: "too frequent"}, true, 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotError := tt.resp.IsError()
if gotError != tt.wantError {
t.Errorf("IsError() = %v, want %v", gotError, tt.wantError)
}
if tt.resp.ErrorCode != tt.wantCode {
t.Errorf("ErrorCode = %d, want %d", tt.resp.ErrorCode, tt.wantCode)
}
})
}
}
// TestGuardClauseOnAuthURLDoesNotRefresh verifies that when the auth endpoint
// itself reports 4122, request() fails fast instead of calling refreshToken()
// (which would recurse). Real behavior, real code path: with the guard
// removed from request(), the token endpoint would be hit a second time.
func TestGuardClauseOnAuthURLDoesNotRefresh(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
m.tokenStatus = http.StatusBadRequest
m.tokenBody = map[string]any{"error_code": 4122, "error": "access_token_expired"}
_, err := d.request("https://user.mypikpak.net/v1/auth/token", http.MethodPost, nil, nil)
if err == nil {
t.Fatal("request() to an auth URL must fail on 4122 instead of refreshing")
}
if got := m.count(pathToken); got != 1 {
t.Errorf("guard clause violated: token endpoint hit %d times, want exactly 1 (no refreshToken recursion)", got)
}
if got := m.count(pathSignin); got != 0 {
t.Errorf("no re-login expected, got %d signin calls", got)
}
}
// --- Integration scaffolding: in-memory DB + mock PikPak endpoints ---
var (
setupDBOnce sync.Once
setupDBErr error
rowSeq int64
)
// setupTestDB mirrors internal/op/storage_test.go: an in-memory SQLite
// database behind internal/db, so op.MustSaveDriverStorage really persists
// and tests can assert on the saved row instead of on comments.
func setupTestDB(t *testing.T) {
t.Helper()
setupDBOnce.Do(func() {
var gormDB *gorm.DB
gormDB, setupDBErr = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if setupDBErr != nil {
return
}
conf.Conf = conf.DefaultConfig("testdata")
db.Init(gormDB)
})
if setupDBErr != nil {
t.Fatalf("failed to set up test database: %v", setupDBErr)
}
}
// createStorageRow inserts a fresh storage row and returns it, so that
// MustSaveDriverStorage during a test performs an UPDATE that can be read
// back afterwards.
func createStorageRow(t *testing.T) *model.Storage {
t.Helper()
rowSeq++
st := &model.Storage{
Driver: "PikPak",
MountPath: fmt.Sprintf("/pikpak-test-%d", rowSeq),
Addition: `{"username":"tester@example.com","password":"pw"}`,
}
if err := db.CreateStorage(st); err != nil {
t.Fatalf("failed to create storage row: %v", err)
}
return st
}
func persistedRefreshToken(t *testing.T, id uint) string {
t.Helper()
st, err := db.GetStorageById(id)
if err != nil {
t.Fatalf("failed to read storage back: %v", err)
}
var a Addition
if err := json.Unmarshal([]byte(st.Addition), &a); err != nil {
t.Fatalf("failed to decode persisted addition %q: %v", st.Addition, err)
}
return a.RefreshToken
}
// mockCall records one request received by the mock server.
type mockCall struct {
headers http.Header
body map[string]any
}
func (c mockCall) captchaToken() string {
s, _ := c.body["captcha_token"].(string)
return s
}
// pikpakMock emulates the captcha/auth endpoints used by login() and
// refreshToken(), plus one drive endpoint that serves as the entry point of
// the recovery chain. The drive endpoint fails exactly once (with the code
// configured in driveFirstStatus) and succeeds afterwards, so request() can
// only complete if recovery actually ran.
type pikpakMock struct {
t *testing.T
srv *httptest.Server
mu sync.Mutex
calls map[string][]mockCall
captchaTokenOut string
captchaURL string
tokenStatus int
tokenBody map[string]any
signinStatus int
signinBody map[string]any
driveFirstStatus int
driveFirstBody map[string]any // body served on the first drive call only
driveBody map[string]any // body served afterwards
driveHits int
}
func newPikpakMock(t *testing.T) *pikpakMock {
t.Helper()
m := &pikpakMock{
t: t,
calls: map[string][]mockCall{},
captchaTokenOut: "cap-fresh",
tokenStatus: http.StatusOK,
tokenBody: map[string]any{"access_token": "at-2", "refresh_token": "rt-2", "sub": "user-1"},
signinStatus: http.StatusOK,
signinBody: map[string]any{"access_token": "at-new", "refresh_token": "rt-new", "sub": "user-1"},
driveFirstStatus: http.StatusOK,
driveFirstBody: map[string]any{"files": []any{}, "next_page_token": ""},
driveBody: map[string]any{"files": []any{}, "next_page_token": ""},
}
m.srv = httptest.NewServer(http.HandlerFunc(m.serve))
return m
}
func (m *pikpakMock) close() { m.srv.Close() }
func (m *pikpakMock) serve(w http.ResponseWriter, r *http.Request) {
body := map[string]any{}
if raw, err := io.ReadAll(r.Body); err == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &body)
}
m.mu.Lock()
m.calls[r.URL.Path] = append(m.calls[r.URL.Path], mockCall{headers: r.Header.Clone(), body: body})
status := http.StatusOK
payload := any(map[string]any{})
switch {
case strings.HasSuffix(r.URL.Path, "/v1/shield/captcha/init"):
payload = map[string]any{"captcha_token": m.captchaTokenOut, "expires_in": 3600, "url": m.captchaURL}
case strings.HasSuffix(r.URL.Path, "/v1/auth/signin"):
status = m.signinStatus
payload = m.signinBody
case strings.HasSuffix(r.URL.Path, "/v1/auth/token"):
status = m.tokenStatus
payload = m.tokenBody
case strings.HasSuffix(r.URL.Path, "/drive/v1/files"):
m.driveHits++
if m.driveHits == 1 {
status = m.driveFirstStatus
payload = m.driveFirstBody
} else {
payload = m.driveBody
}
default:
m.mu.Unlock()
m.t.Errorf("unexpected request to %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func (m *pikpakMock) count(path string) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.calls[path])
}
func (m *pikpakMock) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = map[string][]mockCall{}
m.driveHits = 0
}
func (m *pikpakMock) last(path string) mockCall {
m.mu.Lock()
defer m.mu.Unlock()
calls := m.calls[path]
if len(calls) == 0 {
m.t.Fatalf("no recorded call for %s", path)
}
return calls[len(calls)-1]
}
// installMockClient replaces base.RestyClient with a client whose requests to
// the hard-coded PikPak hosts are rewritten onto the mock server, and returns
// a restore function. The rewrite happens in OnBeforeRequest, which resty
// runs before its internal parseRequestURL/createHTTPRequest middlewares.
func installMockClient(m *pikpakMock) func() {
old := base.RestyClient
client := resty.New()
client.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error {
for _, host := range []string{"https://user.mypikpak.net", "https://api-drive.mypikpak.net"} {
if strings.HasPrefix(req.URL, host) {
req.URL = strings.Replace(req.URL, host, m.srv.URL, 1)
}
}
return nil
})
base.RestyClient = client
return func() { base.RestyClient = old }
}
// newTestDriver builds a PikPak with a fully initialized Common (web platform
// constants) and a fresh storage row in the DB, ready for auth-flow tests.
func newTestDriver(t *testing.T) (*PikPak, uint) {
t.Helper()
setupTestDB(t)
st := createStorageRow(t)
d := &PikPak{}
d.SetStorage(*st)
d.Platform = "web"
d.Username = "tester@example.com"
d.Password = "pw"
d.Common = &Common{
ClientID: WebClientID,
ClientSecret: WebClientSecret,
ClientVersion: WebClientVersion,
PackageName: WebPackageName,
DeviceID: "test-device",
UserAgent: "test-agent",
Algorithms: WebAlgorithms,
}
d.Common.RefreshCTokenCk = func(token string) {
d.Common.CaptchaToken = token
}
return d, st.ID
}
const (
pathCaptchaInit = "/v1/shield/captcha/init"
pathSignin = "/v1/auth/signin"
pathToken = "/v1/auth/token"
pathFiles = "/drive/v1/files"
)
// --- Main auth recovery path ---
// TestMainRecoveryPath exercises the full chain the PR is about: a drive
// request fails with 4122, refreshToken fails with 4126, login() runs (fresh
// captcha + password signin), the new refresh token is persisted to the DB,
// and request() retries the original call successfully with the new tokens.
func TestMainRecoveryPath(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, id := newTestDriver(t)
d.RefreshToken = "rt-old"
d.AccessToken = "at-stale"
d.SetCaptchaToken("cap-stale")
d.Addition.RefreshToken = "rt-old"
// refresh attempt fails with "refresh token invalid"
m.tokenStatus = http.StatusBadRequest
m.tokenBody = map[string]any{"error_code": 4126, "error": "invalid_grant"}
// the first drive call reports an expired access token; the retry succeeds
m.driveFirstStatus = http.StatusBadRequest
m.driveFirstBody = map[string]any{"error_code": 4122, "error": "access_token_expired"}
m.driveBody = map[string]any{"files": []any{}, "next_page_token": ""}
var resp Files
if _, err := d.request("https://api-drive.mypikpak.net/drive/v1/files", http.MethodGet, nil, &resp); err != nil {
t.Fatalf("request() returned error even though recovery should succeed: %v", err)
}
if got := m.count(pathToken); got != 1 {
t.Errorf("expected exactly 1 refresh request, got %d", got)
}
if got := m.count(pathSignin); got != 1 {
t.Errorf("expected exactly 1 signin (re-login), got %d", got)
}
if got := m.count(pathFiles); got != 2 {
t.Errorf("expected 2 files requests (failed + retried), got %d", got)
}
if got := m.count(pathCaptchaInit); got != 1 {
t.Errorf("expected exactly 1 captcha/init call during re-login, got %d", got)
}
// The retry must carry the tokens obtained via re-login, not the stale ones.
lastFiles := m.last(pathFiles)
if got := lastFiles.headers.Get("Authorization"); got != "Bearer at-new" {
t.Errorf("retried request Authorization = %q, want %q", got, "Bearer at-new")
}
if got := lastFiles.headers.Get("X-Captcha-Token"); got != "cap-fresh" {
t.Errorf("retried request X-Captcha-Token = %q, want %q", got, "cap-fresh")
}
// Tokens were rotated in memory...
if d.AccessToken != "at-new" {
t.Errorf("AccessToken = %q, want %q", d.AccessToken, "at-new")
}
if d.RefreshToken != "rt-new" {
t.Errorf("RefreshToken = %q, want %q", d.RefreshToken, "rt-new")
}
// ...and the rotated refresh token was really persisted.
if got := persistedRefreshToken(t, id); got != "rt-new" {
t.Errorf("persisted addition refresh_token = %q, want %q", got, "rt-new")
}
}
// TestRefreshToken4126WithoutCredentialsDoesNotLogin checks that a 4126 with
// empty username/password yields the "re-provide refresh_token" error instead
// of attempting a password login.
func TestRefreshToken4126WithoutCredentialsDoesNotLogin(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
d.Username = ""
d.Password = ""
m.tokenStatus = http.StatusBadRequest
m.tokenBody = map[string]any{"error_code": 4126, "error": "invalid_grant"}
err := d.refreshToken("rt-old")
if err == nil {
t.Fatal("refreshToken() with invalid refresh token and no credentials must fail")
}
if !strings.Contains(err.Error(), "re-provide") {
t.Errorf("unexpected error text: %v", err)
}
if got := m.count(pathSignin); got != 0 {
t.Errorf("signin must not be attempted without credentials, got %d calls", got)
}
}
// TestRefreshTokenOtherErrorDoesNotLogin checks that a non-4126 refresh
// failure propagates without triggering a re-login (4126 is the single
// documented trigger).
func TestRefreshTokenOtherErrorDoesNotLogin(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
m.tokenStatus = http.StatusBadRequest
m.tokenBody = map[string]any{"error_code": 10, "error_description": "too frequent"}
if err := d.refreshToken("rt-old"); err == nil {
t.Fatal("refreshToken() must propagate a non-4126 error")
}
if got := m.count(pathSignin); got != 0 {
t.Errorf("signin must not be attempted for non-4126 errors, got %d calls", got)
}
}
// --- Token validation (replaces TestTokenValidationRejectsEmpty) ---
// TestTokenValidationRejectsEmpty drives login() and refreshToken() against
// 200 responses that carry empty tokens and requires both paths to refuse
// them without persisting anything.
func TestTokenValidationRejectsEmpty(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
// login(): signin answers 200 but with an empty access_token.
d, id := newTestDriver(t)
m.signinBody = map[string]any{"access_token": "", "refresh_token": "rt-x", "sub": "user-1"}
if err := d.login(); err == nil {
t.Fatal("login() must reject empty access_token")
}
if got := persistedRefreshToken(t, id); got != "" {
t.Errorf("login() must not persist tokens when validation fails, persisted %q", got)
}
// login(): symmetric case — empty refresh_token but non-empty access_token.
d3, id3 := newTestDriver(t)
m.reset()
m.signinBody = map[string]any{"access_token": "at-x", "refresh_token": "", "sub": "user-1"}
if err := d3.login(); err == nil {
t.Fatal("login() must reject empty refresh_token")
}
if got := persistedRefreshToken(t, id3); got != "" {
t.Errorf("login() must not persist tokens when validation fails, persisted %q", got)
}
// refreshToken(): 200 but empty refresh_token.
d2, id2 := newTestDriver(t)
m.tokenStatus = http.StatusOK
m.tokenBody = map[string]any{"access_token": "at-x", "refresh_token": "", "sub": "user-1"}
if err := d2.refreshToken("rt-old"); err == nil {
t.Fatal("refreshToken() must reject empty refresh_token")
}
if got := persistedRefreshToken(t, id2); got != "" {
t.Errorf("refreshToken() must not persist tokens when validation fails, persisted %q", got)
}
}
// --- Captcha refresh (replaces TestCaptchaAlwaysRefreshedBeforeLogin) ---
// TestCaptchaAlwaysRefreshedBeforeLogin proves login() fetches a fresh captcha
// even when a (possibly expired) token is already present, and that signin is
// performed with the fresh token rather than the stale one.
func TestCaptchaAlwaysRefreshedBeforeLogin(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
d.SetCaptchaToken("cap-stale") // non-empty and (conceptually) expired
if err := d.login(); err != nil {
t.Fatalf("login() failed: %v", err)
}
if got := m.count(pathCaptchaInit); got != 1 {
t.Fatalf("expected exactly 1 captcha/init call despite a non-empty stale token, got %d", got)
}
if got := m.last(pathSignin).captchaToken(); got != "cap-fresh" {
t.Errorf("signin used captcha_token %q, want the fresh %q", got, "cap-fresh")
}
if got := d.GetCaptchaToken(); got != "cap-fresh" {
t.Errorf("driver CaptchaToken = %q after login, want %q", got, "cap-fresh")
}
}
// --- Stale bearer cleared before login ---
// TestLoginClearsStaleAccessToken checks that the captcha/init and signin
// requests issued by login() do not carry the expired bearer token.
func TestLoginClearsStaleAccessToken(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
d.AccessToken = "at-stale"
if err := d.login(); err != nil {
t.Fatalf("login() failed: %v", err)
}
for _, path := range []string{pathCaptchaInit, pathSignin} {
if got := m.last(path).headers.Get("Authorization"); got != "" {
t.Errorf("%s request carried Authorization %q, want it cleared before login", path, got)
}
}
}
// --- Captcha meta completeness ---
// TestCaptchaMetaCompleteness asserts captcha/init on the login path carries
// the same meta fields RefreshCaptchaTokenAtLogin sends on main.
func TestCaptchaMetaCompleteness(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
if err := d.login(); err != nil {
t.Fatalf("login() failed: %v", err)
}
meta, _ := m.last(pathCaptchaInit).body["meta"].(map[string]any)
for _, key := range []string{"email", "client_version", "package_name", "timestamp", "captcha_sign"} {
if v, ok := meta[key]; !ok || v == "" {
t.Errorf("captcha meta missing or empty %q (got %#v)", key, meta)
}
}
}
// --- refreshToken success path (highest-frequency production path) ---
// TestRefreshTokenSuccessRotatesAndPersists covers 4122 -> refreshToken()
// succeeding: rotated tokens land in memory, the retry carries the new bearer,
// and the new refresh token is persisted to the DB.
func TestRefreshTokenSuccessRotatesAndPersists(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, id := newTestDriver(t)
d.RefreshToken = "rt-old"
d.AccessToken = "at-stale"
d.Addition.RefreshToken = "rt-old"
m.tokenStatus = http.StatusOK
m.tokenBody = map[string]any{"access_token": "at-2", "refresh_token": "rt-2", "sub": "user-1"}
m.driveFirstStatus = http.StatusBadRequest
m.driveFirstBody = map[string]any{"error_code": 4122, "error": "access_token_expired"}
m.driveBody = map[string]any{"files": []any{}, "next_page_token": ""}
var resp Files
if _, err := d.request("https://api-drive.mypikpak.net/drive/v1/files", http.MethodGet, nil, &resp); err != nil {
t.Fatalf("request() failed even though refresh should succeed: %v", err)
}
if got := m.count(pathSignin); got != 0 {
t.Errorf("a successful refresh must not fall through to password login, got %d signin calls", got)
}
if d.AccessToken != "at-2" {
t.Errorf("AccessToken = %q, want %q", d.AccessToken, "at-2")
}
if d.RefreshToken != "rt-2" {
t.Errorf("RefreshToken = %q, want %q", d.RefreshToken, "rt-2")
}
if got := m.last(pathFiles).headers.Get("Authorization"); got != "Bearer at-2" {
t.Errorf("retried request Authorization = %q, want %q", got, "Bearer at-2")
}
if got := persistedRefreshToken(t, id); got != "rt-2" {
t.Errorf("persisted addition refresh_token = %q, want %q", got, "rt-2")
}
}
// --- captcha expired (case 9) ---
// TestCaptchaExpiredRefreshesAndRetries covers request() case 9: a captcha
// error on a drive call triggers a captcha refresh and one retry.
func TestCaptchaExpiredRefreshesAndRetries(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
d, _ := newTestDriver(t)
d.AccessToken = "at-ok"
d.RefreshToken = "rt-ok"
d.SetCaptchaToken("cap-stale")
m.driveFirstStatus = http.StatusBadRequest
m.driveFirstBody = map[string]any{"error_code": 9, "error": "captcha_invalid"}
m.driveBody = map[string]any{"files": []any{}, "next_page_token": ""}
var resp Files
if _, err := d.request("https://api-drive.mypikpak.net/drive/v1/files", http.MethodGet, nil, &resp); err != nil {
t.Fatalf("request() failed even though captcha refresh should recover: %v", err)
}
if got := m.count(pathCaptchaInit); got == 0 {
t.Fatal("expected a captcha refresh after error code 9")
}
if got := m.count(pathFiles); got != 2 {
t.Errorf("expected 2 files requests (failed + retried), got %d", got)
}
if got := m.count(pathSignin); got != 0 {
t.Errorf("captcha recovery must not re-login, got %d signin calls", got)
}
if got := m.last(pathFiles).headers.Get("X-Captcha-Token"); got != "cap-fresh" {
t.Errorf("retried request X-Captcha-Token = %q, want %q", got, "cap-fresh")
}
}
// --- SkipVerification (added by this PR) ---
// TestSkipVerificationControlsVerificationURL covers the new config option:
// a captcha/init response carrying a human-verification url is fatal by
// default and ignored only when skip_verification is enabled.
func TestSkipVerificationControlsVerificationURL(t *testing.T) {
m := newPikpakMock(t)
defer m.close()
restore := installMockClient(m)
defer restore()
m.captchaURL = "https://user.mypikpak.net/forbidden/test"
d, _ := newTestDriver(t)
if err := d.login(); err == nil {
t.Fatal("login() must fail on a verification url by default")
} else if !strings.Contains(err.Error(), "need verify") {
t.Errorf("unexpected error: %v", err)
}
d2, _ := newTestDriver(t)
d2.SkipVerification = true
if err := d2.login(); err != nil {
t.Fatalf("login() with skip_verification must ignore the url, got: %v", err)
}
if d2.AccessToken != "at-new" {
t.Errorf("AccessToken = %q after skipped verification, want %q", d2.AccessToken, "at-new")
}
}
+58 -46
View File
@@ -16,11 +16,11 @@ import (
"github.com/OpenListTeam/OpenList/v4/pkg/cron"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/smithy-go"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
@@ -28,10 +28,10 @@ import (
type S3 struct {
model.Storage
Addition
Session *session.Session
client *s3.S3
linkClient *s3.S3
directUploadClient *s3.S3
cfg aws.Config
client *s3.Client
linkClient *s3.Client
directUploadClient *s3.Client
config driver.Config
cron *cron.Cron
@@ -81,9 +81,9 @@ func (d *S3) Drop(ctx context.Context) error {
func (d *S3) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
if d.ListObjectVersion == "v2" {
return d.listV2(dir.GetPath(), args)
return d.listV2(ctx, dir.GetPath(), args)
}
return d.listV1(dir.GetPath(), args)
return d.listV1(ctx, dir.GetPath(), args)
}
func (d *S3) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
@@ -103,18 +103,27 @@ func (d *S3) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*mo
input.ResponseContentDisposition = &disposition
}
req, _ := d.linkClient.GetObjectRequest(input)
if req == nil {
return nil, fmt.Errorf("failed to create GetObject request")
presignClient := s3.NewPresignClient(d.linkClient)
if presignClient == nil {
return nil, fmt.Errorf("failed to create PresignClient")
}
var link model.Link
var err error
if d.CustomHost != "" {
if d.EnableCustomHostPresign {
link.URL, err = req.Presign(time.Hour * time.Duration(d.SignURLExpire))
result, presignErr := presignClient.PresignGetObject(ctx, input, s3.WithPresignExpires(time.Hour*time.Duration(d.SignURLExpire)))
if presignErr != nil {
return nil, fmt.Errorf("failed to presign link URL: %w", presignErr)
}
link.URL = result.URL
} else {
err = req.Build()
link.URL = req.HTTPRequest.URL.String()
// Use a long-lived presigned URL with the custom host
// The middleware will set the custom host on the request
result, presignErr := presignClient.PresignGetObject(ctx, input, s3.WithPresignExpires(365*24*time.Hour))
if presignErr != nil {
return nil, fmt.Errorf("failed to generate link URL: %w", presignErr)
}
link.URL = result.URL
}
if err != nil {
return nil, fmt.Errorf("failed to generate link URL: %w", err)
@@ -143,11 +152,17 @@ func (d *S3) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*mo
}
} else {
if common.ShouldProxy(d, fileName) {
err = req.Sign()
link.URL = req.HTTPRequest.URL.String()
link.Header = req.HTTPRequest.Header
result, presignErr := presignClient.PresignGetObject(ctx, input, s3.WithPresignExpires(time.Hour*time.Duration(d.SignURLExpire)))
if presignErr != nil {
return nil, fmt.Errorf("failed to sign link URL: %w", presignErr)
}
link.URL = result.URL
} else {
link.URL, err = req.Presign(time.Hour * time.Duration(d.SignURLExpire))
result, presignErr := presignClient.PresignGetObject(ctx, input, s3.WithPresignExpires(time.Hour*time.Duration(d.SignURLExpire)))
if presignErr != nil {
return nil, fmt.Errorf("failed to presign link URL: %w", presignErr)
}
link.URL = result.URL
}
}
if err != nil {
@@ -178,7 +193,7 @@ func (d *S3) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
}
func (d *S3) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
err := d.copy(ctx, srcObj.GetPath(), stdpath.Join(stdpath.Dir(srcObj.GetPath()), newName), srcObj.GetSize(), srcObj.IsDir())
err := d.copy(ctx, srcObj.GetPath(), stdpath.Join(stdpath.Dir(srcObj.GetPath()), newName), srcObj.IsDir())
if err != nil {
return err
}
@@ -186,7 +201,7 @@ func (d *S3) Rename(ctx context.Context, srcObj model.Obj, newName string) error
}
func (d *S3) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
return d.copy(ctx, srcObj.GetPath(), stdpath.Join(dstDir.GetPath(), srcObj.GetName()), srcObj.GetSize(), srcObj.IsDir())
return d.copy(ctx, srcObj.GetPath(), stdpath.Join(dstDir.GetPath(), srcObj.GetName()), srcObj.IsDir())
}
func (d *S3) Remove(ctx context.Context, obj model.Obj) error {
@@ -197,14 +212,15 @@ func (d *S3) Remove(ctx context.Context, obj model.Obj) error {
}
func (d *S3) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up driver.UpdateProgress) error {
uploader := s3manager.NewUploader(d.Session)
if s.GetSize() > s3manager.MaxUploadParts*s3manager.DefaultUploadPartSize {
uploader.PartSize = s.GetSize() / (s3manager.MaxUploadParts - 1)
}
key := getKey(stdpath.Join(dstDir.GetPath(), s.GetName()), false)
contentType := s.GetMimetype()
log.Debugln("key:", key)
input := &s3manager.UploadInput{
tmClient := transfermanager.New(d.client, func(o *transfermanager.Options) {
if s.GetSize() > int64(8*utils.MB)*10000 {
o.PartSizeBytes = s.GetSize() / 9999
}
})
_, err := tmClient.UploadObject(ctx, &transfermanager.UploadObjectInput{
Bucket: &d.Bucket,
Key: &key,
Body: driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{
@@ -212,8 +228,7 @@ func (d *S3) Put(ctx context.Context, dstDir model.Obj, s model.FileStreamer, up
UpdateProgress: up,
}),
ContentType: &contentType,
}
_, err := uploader.UploadWithContext(ctx, input)
})
return err
}
@@ -229,19 +244,16 @@ func (d *S3) GetDirectUploadInfo(ctx context.Context, _ string, dstDir model.Obj
return nil, errs.NotImplement
}
path := getKey(stdpath.Join(dstDir.GetPath(), fileName), false)
req, _ := d.directUploadClient.PutObjectRequest(&s3.PutObjectInput{
presignClient := s3.NewPresignClient(d.directUploadClient)
result, err := presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: &d.Bucket,
Key: &path,
})
if req == nil {
return nil, fmt.Errorf("failed to create PutObject request")
}
link, err := req.Presign(time.Hour * time.Duration(d.SignURLExpire))
}, s3.WithPresignExpires(time.Hour*time.Duration(d.SignURLExpire)))
if err != nil {
return nil, err
}
return &model.HttpDirectUploadInfo{
UploadURL: link,
UploadURL: result.URL,
Method: "PUT",
}, nil
}
@@ -263,7 +275,7 @@ func (d *S3) Get(ctx context.Context, path string) (model.Obj, error) {
Bucket: &d.Bucket,
Key: &key,
}
headOutput, err := d.client.HeadObjectWithContext(ctx, headInput)
headOutput, err := d.client.HeadObject(ctx, headInput)
if err == nil {
// Object exists as a file
fileName := stdpath.Base(path)
@@ -274,23 +286,23 @@ func (d *S3) Get(ctx context.Context, path string) (model.Obj, error) {
Path: path,
}, nil
}
var awsErr awserr.Error
if errors.As(err, &awsErr) && awsErr.Code() != "NotFound" {
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() != "NotFound" {
return nil, errors.WithMessage(err, "failed to head object")
}
// If HeadObject fails with 404, check if it's a directory
prefix := getKey(path, true)
var contents []*s3.Object
var commonPrefixes []*s3.CommonPrefix
var contents []types.Object
var commonPrefixes []types.CommonPrefix
switch d.ListObjectVersion {
case "v1":
listInput := &s3.ListObjectsInput{
Bucket: &d.Bucket,
Prefix: &prefix,
MaxKeys: aws.Int64(1), // Only need to check if at least one object exists
MaxKeys: aws.Int32(1),
}
listResult, err := d.client.ListObjectsWithContext(ctx, listInput)
listResult, err := d.client.ListObjects(ctx, listInput)
if err != nil {
return nil, errors.WithMessage(err, "failed to list objects with prefix")
}
@@ -300,9 +312,9 @@ func (d *S3) Get(ctx context.Context, path string) (model.Obj, error) {
listInput := &s3.ListObjectsV2Input{
Bucket: &d.Bucket,
Prefix: &prefix,
MaxKeys: aws.Int64(1),
MaxKeys: aws.Int32(1),
}
listResult, err := d.client.ListObjectsV2WithContext(ctx, listInput)
listResult, err := d.client.ListObjectsV2(ctx, listInput)
if err != nil {
return nil, errors.WithMessage(err, "failed to list objects v2 with prefix")
}
+88 -171
View File
@@ -3,7 +3,6 @@ package s3
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path"
@@ -12,25 +11,17 @@ import (
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
log "github.com/sirupsen/logrus"
)
const (
maxCopyObjectSize int64 = 5 * 1000 * 1000 * 1000
defaultCopyPartSize int64 = 100 * 1024 * 1024
maxCopyPartSize int64 = 5 * 1024 * 1024 * 1024
maxCopyParts int64 = 10000
)
// do others that not defined in Driver interface
func (d *S3) initSession() error {
var err error
accessKeyID, secretAccessKey, sessionToken := d.AccessKeyID, d.SecretAccessKey, d.SessionToken
if d.config.Name == "Doge" {
credentialsTmp, err := getCredentials(d.AccessKeyID, d.SecretAccessKey)
@@ -39,14 +30,11 @@ func (d *S3) initSession() error {
}
accessKeyID, secretAccessKey, sessionToken = credentialsTmp.AccessKeyId, credentialsTmp.SecretAccessKey, credentialsTmp.SessionToken
}
cfg := &aws.Config{
Credentials: credentials.NewStaticCredentials(accessKeyID, secretAccessKey, sessionToken),
Region: &d.Region,
Endpoint: &d.Endpoint,
S3ForcePathStyle: aws.Bool(d.ForcePathStyle),
d.cfg = aws.Config{
Credentials: credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, sessionToken),
Region: d.Region,
}
d.Session, err = session.NewSession(cfg)
return err
return nil
}
const (
@@ -55,43 +43,75 @@ const (
ClientTypeDirectUpload
)
func (d *S3) getClient(clientType int) *s3.S3 {
client := s3.New(d.Session)
if d.UserAgent != "" {
client.Handlers.Build.PushBack(func(r *request.Request) {
r.HTTPRequest.Header.Set("User-Agent", d.UserAgent)
func (d *S3) getClient(clientType int) *s3.Client {
return s3.NewFromConfig(d.cfg, func(o *s3.Options) {
o.UsePathStyle = d.ForcePathStyle
o.BaseEndpoint = aws.String(d.Endpoint)
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
// User-Agent middleware
if d.UserAgent != "" {
if err := stack.Build.Add(middleware.BuildMiddlewareFunc("SetUserAgent",
func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (middleware.BuildOutput, middleware.Metadata, error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return next.HandleBuild(ctx, in)
}
req.Header.Set("User-Agent", d.UserAgent)
return next.HandleBuild(ctx, in)
},
), middleware.After); err != nil {
return err
}
}
// CustomHost middleware
if clientType == ClientTypeLink && d.CustomHost != "" {
if err := stack.Build.Add(middleware.BuildMiddlewareFunc("SetCustomHost",
func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (middleware.BuildOutput, middleware.Metadata, error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return next.HandleBuild(ctx, in)
}
if req.Method == http.MethodGet {
split := strings.SplitN(d.CustomHost, "://", 2)
if len(split) > 1 && utils.SliceContains([]string{"http", "https"}, split[0]) {
req.URL.Scheme = split[0]
req.URL.Host = split[1]
} else {
req.URL.Host = d.CustomHost
}
}
return next.HandleBuild(ctx, in)
},
), middleware.After); err != nil {
return err
}
}
// DirectUploadHost middleware
if clientType == ClientTypeDirectUpload && d.DirectUploadHost != "" {
if err := stack.Build.Add(middleware.BuildMiddlewareFunc("SetDirectUploadHost",
func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) (middleware.BuildOutput, middleware.Metadata, error) {
req, ok := in.Request.(*smithyhttp.Request)
if !ok {
return next.HandleBuild(ctx, in)
}
if req.Method == http.MethodPut {
split := strings.SplitN(d.DirectUploadHost, "://", 2)
if len(split) > 1 && utils.SliceContains([]string{"http", "https"}, split[0]) {
req.URL.Scheme = split[0]
req.URL.Host = split[1]
} else {
req.URL.Host = d.DirectUploadHost
}
}
return next.HandleBuild(ctx, in)
},
), middleware.After); err != nil {
return err
}
}
return nil
})
}
if clientType == ClientTypeLink && d.CustomHost != "" {
client.Handlers.Build.PushBack(func(r *request.Request) {
if r.HTTPRequest.Method != http.MethodGet {
return
}
//判断CustomHost是否以http://或https://开头
split := strings.SplitN(d.CustomHost, "://", 2)
if utils.SliceContains([]string{"http", "https"}, split[0]) {
r.HTTPRequest.URL.Scheme = split[0]
r.HTTPRequest.URL.Host = split[1]
} else {
r.HTTPRequest.URL.Host = d.CustomHost
}
})
}
if clientType == ClientTypeDirectUpload && d.DirectUploadHost != "" {
client.Handlers.Build.PushBack(func(r *request.Request) {
if r.HTTPRequest.Method != http.MethodPut {
return
}
split := strings.SplitN(d.DirectUploadHost, "://", 2)
if utils.SliceContains([]string{"http", "https"}, split[0]) {
r.HTTPRequest.URL.Scheme = split[0]
r.HTTPRequest.URL.Host = split[1]
} else {
r.HTTPRequest.URL.Host = d.DirectUploadHost
}
})
}
return client
})
}
func getKey(path string, dir bool) string {
@@ -111,7 +131,7 @@ func getPlaceholderName(placeholder string) string {
return placeholder
}
func (d *S3) listV1(dirPath string, args model.ListArgs) ([]model.Obj, error) {
func (d *S3) listV1(ctx context.Context, dirPath string, args model.ListArgs) ([]model.Obj, error) {
prefix := getKey(dirPath, true)
log.Debugf("list: %s", prefix)
files := make([]model.Obj, 0)
@@ -123,7 +143,7 @@ func (d *S3) listV1(dirPath string, args model.ListArgs) ([]model.Obj, error) {
Prefix: &prefix,
Delimiter: aws.String("/"),
}
listObjectsResult, err := d.client.ListObjects(input)
listObjectsResult, err := d.client.ListObjects(ctx, input)
if err != nil {
return nil, err
}
@@ -162,7 +182,7 @@ func (d *S3) listV1(dirPath string, args model.ListArgs) ([]model.Obj, error) {
return files, nil
}
func (d *S3) listV2(dirPath string, args model.ListArgs) ([]model.Obj, error) {
func (d *S3) listV2(ctx context.Context, dirPath string, args model.ListArgs) ([]model.Obj, error) {
prefix := getKey(dirPath, true)
files := make([]model.Obj, 0)
var continuationToken, startAfter *string
@@ -174,7 +194,7 @@ func (d *S3) listV2(dirPath string, args model.ListArgs) ([]model.Obj, error) {
Delimiter: aws.String("/"),
StartAfter: startAfter,
}
listObjectsResult, err := d.client.ListObjectsV2(input)
listObjectsResult, err := d.client.ListObjectsV2(ctx, input)
if err != nil {
return nil, err
}
@@ -205,7 +225,7 @@ func (d *S3) listV2(dirPath string, args model.ListArgs) ([]model.Obj, error) {
}
files = append(files, &file)
}
if !aws.BoolValue(listObjectsResult.IsTruncated) {
if !aws.ToBool(listObjectsResult.IsTruncated) {
break
}
if listObjectsResult.NextContinuationToken != nil {
@@ -220,129 +240,26 @@ func (d *S3) listV2(dirPath string, args model.ListArgs) ([]model.Obj, error) {
return files, nil
}
func (d *S3) copy(ctx context.Context, src string, dst string, size int64, isDir bool) error {
func (d *S3) copy(ctx context.Context, src string, dst string, isDir bool) error {
if isDir {
return d.copyDir(ctx, src, dst)
}
return d.copyFile(ctx, src, dst, size)
return d.copyFile(ctx, src, dst)
}
func (d *S3) copyFile(ctx context.Context, src string, dst string, size int64) error {
func (d *S3) copyFile(ctx context.Context, src string, dst string) error {
srcKey := getKey(src, false)
dstKey := getKey(dst, false)
encodedKey := strings.ReplaceAll(url.PathEscape(d.Bucket+"/"+srcKey), "+", "%2B")
if size > maxCopyObjectSize {
return d.copyFileMultipart(ctx, srcKey, dstKey, encodedKey, size)
}
input := &s3.CopyObjectInput{
Bucket: &d.Bucket,
CopySource: aws.String(encodedKey),
Key: &dstKey,
}
_, err := d.client.CopyObjectWithContext(ctx, input)
_, err := d.client.CopyObject(ctx, input)
return err
}
func (d *S3) copyFileMultipart(ctx context.Context, srcKey, dstKey, encodedKey string, size int64) (err error) {
head, err := d.client.HeadObjectWithContext(ctx, &s3.HeadObjectInput{
Bucket: &d.Bucket,
Key: &srcKey,
})
if err != nil {
return err
}
if head.ContentLength != nil {
size = *head.ContentLength
}
partSize, err := getCopyPartSize(size)
if err != nil {
return err
}
createInput := &s3.CreateMultipartUploadInput{
Bucket: &d.Bucket,
Key: &dstKey,
CacheControl: head.CacheControl,
ContentDisposition: head.ContentDisposition,
ContentEncoding: head.ContentEncoding,
ContentLanguage: head.ContentLanguage,
ContentType: head.ContentType,
Metadata: head.Metadata,
WebsiteRedirectLocation: head.WebsiteRedirectLocation,
}
if head.Expires != nil {
if expires, parseErr := http.ParseTime(*head.Expires); parseErr == nil {
createInput.Expires = &expires
}
}
created, err := d.client.CreateMultipartUploadWithContext(ctx, createInput)
if err != nil {
return err
}
uploadID := aws.StringValue(created.UploadId)
if uploadID == "" {
return errors.New("create multipart upload returned an empty upload ID")
}
completed := false
defer func() {
if completed {
return
}
_, abortErr := d.client.AbortMultipartUploadWithContext(context.WithoutCancel(ctx), &s3.AbortMultipartUploadInput{
Bucket: &d.Bucket,
Key: &dstKey,
UploadId: &uploadID,
})
if abortErr != nil {
err = errors.Join(err, fmt.Errorf("failed to abort multipart copy: %w", abortErr))
}
}()
parts := make([]*s3.CompletedPart, 0, (size+partSize-1)/partSize)
for start, partNumber := int64(0), int64(1); start < size; start, partNumber = start+partSize, partNumber+1 {
end := min(start+partSize, size) - 1
copied, copyErr := d.client.UploadPartCopyWithContext(ctx, &s3.UploadPartCopyInput{
Bucket: &d.Bucket,
CopySource: &encodedKey,
CopySourceRange: aws.String(fmt.Sprintf("bytes=%d-%d", start, end)),
Key: &dstKey,
PartNumber: &partNumber,
UploadId: &uploadID,
})
if copyErr != nil {
return copyErr
}
if copied.CopyPartResult == nil || aws.StringValue(copied.CopyPartResult.ETag) == "" {
return fmt.Errorf("multipart copy part %d returned an empty ETag", partNumber)
}
parts = append(parts, &s3.CompletedPart{
ETag: copied.CopyPartResult.ETag,
PartNumber: &partNumber,
})
}
_, err = d.client.CompleteMultipartUploadWithContext(ctx, &s3.CompleteMultipartUploadInput{
Bucket: &d.Bucket,
Key: &dstKey,
UploadId: &uploadID,
MultipartUpload: &s3.CompletedMultipartUpload{
Parts: parts,
},
})
if err != nil {
return err
}
completed = true
return nil
}
func getCopyPartSize(size int64) (int64, error) {
partSize := max(defaultCopyPartSize, (size-1)/maxCopyParts+1)
if partSize > maxCopyPartSize {
return 0, fmt.Errorf("object size %d exceeds multipart copy limit", size)
}
return partSize, nil
}
func (d *S3) copyDir(ctx context.Context, src string, dst string) error {
objs, err := op.List(ctx, d, src, model.ListArgs{S3ShowPlaceholder: true})
if err != nil {
@@ -354,7 +271,7 @@ func (d *S3) copyDir(ctx context.Context, src string, dst string) error {
if obj.IsDir() {
err = d.copyDir(ctx, cSrc, cDst)
} else {
err = d.copyFile(ctx, cSrc, cDst, obj.GetSize())
err = d.copyFile(ctx, cSrc, cDst)
}
if err != nil {
return err
@@ -390,6 +307,6 @@ func (d *S3) removeFile(ctx context.Context, src string) error {
Bucket: &d.Bucket,
Key: &key,
}
_, err := d.client.DeleteObjectWithContext(ctx, input)
_, err := d.client.DeleteObject(ctx, input)
return err
}
-215
View File
@@ -1,215 +0,0 @@
package s3
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
awss3 "github.com/aws/aws-sdk-go/service/s3"
)
func TestCopyFileUsesCopyObjectAtLimit(t *testing.T) {
copyRequests := 0
d := newTestS3Driver(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Query().Get("uploadId") != "" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
w.WriteHeader(http.StatusBadRequest)
return
}
copyRequests++
writeTestXML(t, w, `<CopyObjectResult><ETag>"copy"</ETag></CopyObjectResult>`)
})
if err := d.copyFile(context.Background(), "source+file", "destination", maxCopyObjectSize); err != nil {
t.Fatalf("copyFile: %v", err)
}
if copyRequests != 1 {
t.Fatalf("copy requests = %d, want 1", copyRequests)
}
}
func TestCopyFileUsesMultipartCopyAboveLimit(t *testing.T) {
size := maxCopyObjectSize + 1
wantParts := int((size + defaultCopyPartSize - 1) / defaultCopyPartSize)
ranges := make(map[int]string, wantParts)
completed := false
aborted := false
d := newTestS3Driver(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodHead:
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "max-age=60")
w.Header().Set("Content-Disposition", "attachment")
w.Header().Set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT")
w.Header().Set("X-Amz-Meta-Source", "preserved")
w.Header().Set("X-Amz-Website-Redirect-Location", "/redirect")
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Query().Has("uploads"):
if got := r.Header.Get("Cache-Control"); got != "max-age=60" {
t.Errorf("Cache-Control = %q, want %q", got, "max-age=60")
}
if got := r.Header.Get("Content-Disposition"); got != "attachment" {
t.Errorf("Content-Disposition = %q, want %q", got, "attachment")
}
if got := r.Header.Get("Content-Type"); got != "application/octet-stream" {
t.Errorf("Content-Type = %q, want %q", got, "application/octet-stream")
}
if got := r.Header.Get("Expires"); got != "Wed, 21 Oct 2015 07:28:00 GMT" {
t.Errorf("Expires = %q, want an unchanged HTTP date", got)
}
if got := r.Header.Get("X-Amz-Meta-Source"); got != "preserved" {
t.Errorf("metadata = %q, want %q", got, "preserved")
}
if got := r.Header.Get("X-Amz-Website-Redirect-Location"); got != "/redirect" {
t.Errorf("website redirect = %q, want %q", got, "/redirect")
}
writeTestXML(t, w, `<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>`)
case r.Method == http.MethodPut && r.URL.Query().Get("uploadId") == "upload-id":
partNumber, err := strconv.Atoi(r.URL.Query().Get("partNumber"))
if err != nil {
t.Errorf("invalid part number: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("X-Amz-Copy-Source"); !strings.Contains(got, "source%2Bfile") {
t.Errorf("copy source = %q, want encoded source key", got)
}
ranges[partNumber] = r.Header.Get("X-Amz-Copy-Source-Range")
writeTestXML(t, w, fmt.Sprintf(`<CopyPartResult><ETag>"part-%d"</ETag></CopyPartResult>`, partNumber))
case r.Method == http.MethodPost && r.URL.Query().Get("uploadId") == "upload-id":
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read complete body: %v", err)
}
if got := strings.Count(string(body), "<Part>"); got != wantParts {
t.Errorf("completed parts = %d, want %d", got, wantParts)
}
completed = true
writeTestXML(t, w, `<CompleteMultipartUploadResult><ETag>"complete"</ETag></CompleteMultipartUploadResult>`)
case r.Method == http.MethodDelete && r.URL.Query().Get("uploadId") == "upload-id":
aborted = true
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
w.WriteHeader(http.StatusBadRequest)
}
})
if err := d.copyFile(context.Background(), "source+file", "destination", size); err != nil {
t.Fatalf("copyFile: %v", err)
}
if !completed {
t.Fatal("multipart upload was not completed")
}
if aborted {
t.Fatal("successful multipart upload was aborted")
}
if len(ranges) != wantParts {
t.Fatalf("copied parts = %d, want %d", len(ranges), wantParts)
}
if got := ranges[1]; got != fmt.Sprintf("bytes=0-%d", defaultCopyPartSize-1) {
t.Errorf("first range = %q", got)
}
lastStart := int64(wantParts-1) * defaultCopyPartSize
if got := ranges[wantParts]; got != fmt.Sprintf("bytes=%d-%d", lastStart, size-1) {
t.Errorf("last range = %q", got)
}
}
func TestCopyFileMultipartAbortsOnPartFailure(t *testing.T) {
size := maxCopyObjectSize + 1
aborted := false
completed := false
d := newTestS3Driver(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodHead:
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPost && r.URL.Query().Has("uploads"):
writeTestXML(t, w, `<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>`)
case r.Method == http.MethodPut && r.URL.Query().Get("uploadId") == "upload-id":
w.WriteHeader(http.StatusInternalServerError)
writeTestXML(t, w, `<Error><Code>InternalError</Code><Message>copy failed</Message></Error>`)
case r.Method == http.MethodDelete && r.URL.Query().Get("uploadId") == "upload-id":
aborted = true
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPost && r.URL.Query().Get("uploadId") == "upload-id":
completed = true
w.WriteHeader(http.StatusOK)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.String())
w.WriteHeader(http.StatusBadRequest)
}
})
if err := d.copyFile(context.Background(), "source", "destination", size); err == nil {
t.Fatal("copyFile returned nil error")
}
if !aborted {
t.Fatal("failed multipart upload was not aborted")
}
if completed {
t.Fatal("failed multipart upload was completed")
}
}
func TestGetCopyPartSize(t *testing.T) {
partSize, err := getCopyPartSize(defaultCopyPartSize * maxCopyParts)
if err != nil {
t.Fatalf("getCopyPartSize: %v", err)
}
if partSize != defaultCopyPartSize {
t.Fatalf("part size = %d, want %d", partSize, defaultCopyPartSize)
}
partSize, err = getCopyPartSize(defaultCopyPartSize*maxCopyParts + 1)
if err != nil {
t.Fatalf("getCopyPartSize: %v", err)
}
if partSize != defaultCopyPartSize+1 {
t.Fatalf("grown part size = %d, want %d", partSize, defaultCopyPartSize+1)
}
if _, err := getCopyPartSize(maxCopyPartSize*maxCopyParts + 1); err == nil {
t.Fatal("getCopyPartSize returned nil error for an oversized object")
}
}
func newTestS3Driver(t *testing.T, handler http.HandlerFunc) *S3 {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
sess, err := session.NewSession(&aws.Config{
Credentials: credentials.NewStaticCredentials("access-key", "secret-key", ""),
Endpoint: aws.String(server.URL),
Region: aws.String("us-east-1"),
S3ForcePathStyle: aws.Bool(true),
MaxRetries: aws.Int(0),
})
if err != nil {
t.Fatalf("create AWS session: %v", err)
}
return &S3{
Addition: Addition{Bucket: "bucket"},
client: awss3.New(sess),
}
}
func writeTestXML(t *testing.T, w http.ResponseWriter, body string) {
t.Helper()
w.Header().Set("Content-Type", "application/xml")
if _, err := io.WriteString(w, body); err != nil {
t.Errorf("write response: %v", err)
}
}
-2
View File
@@ -27,7 +27,6 @@ type Strm struct {
supportSuffix map[string]struct{}
downloadSuffix map[string]struct{}
minSizeBytes int64
}
func (d *Strm) Config() driver.Config {
@@ -121,7 +120,6 @@ func (d *Strm) Init(ctx context.Context) error {
if len(d.SaveLocalMode) == 0 {
d.SaveLocalMode = SaveLocalInsertMode
}
d.minSizeBytes = d.MinFileSize * 1024 * 1024
return nil
}

Some files were not shown because too many files have changed in this diff Show More